From 4e14e21cbb385f48a17a859bd88e6546133bfaa7 Mon Sep 17 00:00:00 2001 From: LunarECL Date: Sat, 10 Oct 2026 21:21:03 +0900 Subject: [PATCH 1/6] feat(leaderboard): add and remove devices, in the app's domain (slice 13, #507) --- Modules/Leaderboard/Sources/Device.swift | 174 ++++++++++ .../Internal/LeaderboardHTTPClient.swift | 115 ++++++- .../Sources/Internal/Platform.swift | 7 +- .../Internal/Windows/Platform+Windows.swift | 6 +- .../Internal/macOS/IOKitMachineIdentity.swift | 36 +++ .../Sources/Internal/macOS/MacModel.swift | 27 ++ .../Internal/macOS/Platform+macOS.swift | 6 +- Modules/Leaderboard/Sources/Leaderboard.swift | 12 +- .../Leaderboard/Sources/LeaderboardAPI.swift | 56 +++- .../Sources/LeaderboardMembership.swift | 228 ++++++++++++- .../LeaderboardSettingsRepository.swift | 6 +- .../Sources/LeaderboardUploader.swift | 4 + .../Leaderboard/Sources/MachineIdentity.swift | 11 + .../Leaderboard/Sources/RequestSigner.swift | 12 +- Modules/Leaderboard/Tests/BoardTests.swift | 2 +- Modules/Leaderboard/Tests/DeviceServer.swift | 82 +++++ Modules/Leaderboard/Tests/DeviceTests.swift | 95 ++++++ .../Tests/LeaderboardDevicesTests.swift | 302 ++++++++++++++++++ .../Leaderboard/Tests/LeaderboardFakes.swift | 10 +- .../Tests/LeaderboardHTTPClientTests.swift | 148 ++++++++- .../Tests/LeaderboardMembershipTests.swift | 7 +- .../Tests/LeaderboardUploaderTests.swift | 36 ++- .../Tests/macOS/MacModelTests.swift | 28 ++ Sources/App/Leaderboard/AppLeaderboard.swift | 7 +- .../Storage/JSONSettingsRepository.swift | 4 +- .../Leaderboard/LeaderboardStorageTests.swift | 13 + codecov.yml | 3 +- docs/architecture/MODULAR_DESIGN.md | 2 +- docs/features/leaderboard/design.md | 28 +- 29 files changed, 1394 insertions(+), 73 deletions(-) create mode 100644 Modules/Leaderboard/Sources/Device.swift create mode 100644 Modules/Leaderboard/Sources/Internal/macOS/IOKitMachineIdentity.swift create mode 100644 Modules/Leaderboard/Sources/Internal/macOS/MacModel.swift create mode 100644 Modules/Leaderboard/Sources/MachineIdentity.swift create mode 100644 Modules/Leaderboard/Tests/DeviceServer.swift create mode 100644 Modules/Leaderboard/Tests/DeviceTests.swift create mode 100644 Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift create mode 100644 Modules/Leaderboard/Tests/macOS/MacModelTests.swift diff --git a/Modules/Leaderboard/Sources/Device.swift b/Modules/Leaderboard/Sources/Device.swift new file mode 100644 index 000000000..364650001 --- /dev/null +++ b/Modules/Leaderboard/Sources/Device.swift @@ -0,0 +1,174 @@ +import Foundation + +/// *DEVICE* (design §2a): one of a member's machines, which is one key. `/me` +/// lists them, removed ones included; a removed device's days stay and count. +public struct Device: Sendable, Equatable, Codable { + /// The device's public key, base64url, as `X-Key` names it. + public let publicKey: String + public let label: String + public let addedAt: Date + public let removedAt: Date? + /// The device that removed it, `nil` while it's in use. + public let removedBy: DeviceRef? + + public init(publicKey: String, label: String, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) { + self.publicKey = publicKey + self.label = label + self.addedAt = addedAt + self.removedAt = removedAt + self.removedBy = removedBy + } + + public var isRemoved: Bool { removedAt != nil } + + private enum CodingKeys: String, CodingKey { case publicKey, label, addedAt, removedAt, removedBy } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + publicKey = try container.decode(String.self, forKey: .publicKey) + label = try container.decode(String.self, forKey: .label) + addedAt = try ServerTime.decode(from: container, forKey: .addedAt) + removedAt = try ServerTime.decodeIfPresent(from: container, forKey: .removedAt) + removedBy = try container.decodeIfPresent(DeviceRef.self, forKey: .removedBy) + } + + /// Written as `ServerTime` reads it, so an exported `/me` answer reads back + /// as the same instants; `JSONEncoder`'s own form counts from 2001. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(publicKey, forKey: .publicKey) + try container.encode(label, forKey: .label) + try container.encode(ServerTime.string(addedAt), forKey: .addedAt) + try container.encodeIfPresent(removedAt.map(ServerTime.string), forKey: .removedAt) + try container.encodeIfPresent(removedBy, forKey: .removedBy) + } +} + +/// A device named in passing: the one that removed another. +public struct DeviceRef: Sendable, Equatable, Codable { + public let publicKey: String + public let label: String + + public init(publicKey: String, label: String) { + self.publicKey = publicKey + self.label = label + } +} + +/// What a device is called on the member's list: the label the person typed +/// when it joined or was added, filled in with the machine's model and never +/// its computer name, which is often a person's (design §6). The server takes +/// 1–40 characters without control characters. +public struct DeviceLabel: Sendable, Hashable, CustomStringConvertible { + public let value: String + + public init?(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...40).contains(trimmed.count), + !trimmed.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { return nil } + value = trimmed + } + + public var description: String { value } +} + +/// The code a new device shows until one the member has approves it: 8 of +/// RFC 8628's consonants, shown as `WDJB-MJHT` and read however it's typed. +public struct DeviceCode: Sendable, Hashable, CustomStringConvertible { + /// The 8 letters, upper case, as the server's paths take them. + public let value: String + + private static let alphabet = Set("BCDFGHJKLMNPQRSTVWXZ") + + public init?(_ typed: String) { + let letters = typed.uppercased().filter { $0 != "-" && !$0.isWhitespace } + guard letters.count == 8, letters.allSatisfy(Self.alphabet.contains) else { return nil } + value = letters + } + + public var description: String { "\(value.prefix(4))-\(value.suffix(4))" } +} + +/// What approving a code would add: the label the new device asked with, and +/// when, shown above *Approve* so a code read out to a stranger isn't +/// approved blind (RFC 8628 §5.4). +public struct PendingDevice: Sendable, Equatable, Decodable { + public let label: String + public let requestedAt: Date + + public init(label: String, requestedAt: Date) { + self.label = label + self.requestedAt = requestedAt + } + + private enum CodingKeys: String, CodingKey { case label, requestedAt } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + label = try container.decode(String.self, forKey: .label) + requestedAt = try ServerTime.decode(from: container, forKey: .requestedAt) + } +} + +/// The server's answer to a new device asking to be added: its code, how long +/// the code lasts and how often to ask whether it was approved. +public struct DeviceAuthorization: Sendable, Equatable { + public let code: DeviceCode + public let expiresIn: TimeInterval + public let interval: TimeInterval + + public init(code: DeviceCode, expiresIn: TimeInterval, interval: TimeInterval) { + self.code = code + self.expiresIn = expiresIn + self.interval = interval + } +} + +/// Where a new device's code stands, by `GET /me` signed with its own key. +public enum DeviceApproval: Sendable, Equatable { + /// Not approved yet: `202`, with no member data. + case waiting + /// Approved: the member it joined, as `/me` answers. + case approved(MemberSummary) +} + +/// A time as the server writes it. §5 doesn't fix the form, so the ones a +/// Worker on D1 is likely to send are read: ISO 8601 with or without +/// fractional seconds, SQLite's `YYYY-MM-DD HH:MM:SS` in UTC, and Unix time in +/// seconds or milliseconds. Anything else fails the answer rather than guess. +/// Written back as ISO 8601 in UTC with milliseconds. +enum ServerTime { + static func decode(from container: KeyedDecodingContainer, forKey key: Key) throws -> Date { + if let seconds = try? container.decode(Double.self, forKey: key) { + return Date(timeIntervalSince1970: seconds > 100_000_000_000 ? seconds / 1000 : seconds) + } + let text = try container.decode(String.self, forKey: key) + guard let date = date(text) else { + throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Not a time: \(text)") + } + return date + } + + static func decodeIfPresent(from container: KeyedDecodingContainer, forKey key: Key) throws -> Date? { + guard container.contains(key), try !container.decodeNil(forKey: key) else { return nil } + return try decode(from: container, forKey: key) + } + + static func string(_ date: Date) -> String { + let iso = ISO8601DateFormatter() + iso.formatOptions.insert(.withFractionalSeconds) + return iso.string(from: date) + } + + private static func date(_ text: String) -> Date? { + let iso = ISO8601DateFormatter() + if let date = iso.date(from: text) { return date } + iso.formatOptions.insert(.withFractionalSeconds) + if let date = iso.date(from: text) { return date } + let sqlite = DateFormatter() + sqlite.locale = Locale(identifier: "en_US_POSIX") + sqlite.timeZone = TimeZone(identifier: "UTC") + sqlite.dateFormat = "yyyy-MM-dd HH:mm:ss" + return sqlite.date(from: text) + } +} diff --git a/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift b/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift index 247ded4ed..eeda3ff4f 100644 --- a/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift +++ b/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift @@ -29,13 +29,13 @@ struct LeaderboardHTTPClient: LeaderboardAPI { // MARK: - LeaderboardAPI - func join(username: String, publicKey: String) async throws { - _ = try await send("POST", "/join", body: try JSONEncoder().encode(["username": username, "publicKey": publicKey])) + func join(username: String, publicKey: String, label: String) async throws { + _ = try await send("POST", "/join", body: try JSONEncoder().encode(["username": username, "publicKey": publicKey, "label": label])) } func upload(_ days: [DailyTokens], as credentials: MemberCredentials) async throws -> [RefusedDay] { let body = try JSONEncoder().encode(Upload(today: DailyTokens.day(of: now()), days: days)) - let answer = try await send("PUT", "/usage", body: body, signedBy: credentials) + let answer = try await send("PUT", "/usage", body: body, signedBy: Signer(credentials)) // A server from before devices answers with no body, or with an object that has // no `refused`: it refused nothing alone. Any other answer that can't be read - // cut short, not an object, a `refused` of the wrong shape - fails the upload, so @@ -50,16 +50,16 @@ struct LeaderboardHTTPClient: LeaderboardAPI { } func me(period: BoardPeriod, provider: String?, as credentials: MemberCredentials) async throws -> MemberSummary { - let data = try await send("GET", "/me", query: Self.query(period, provider), signedBy: credentials) + let data = try await send("GET", "/me", query: Self.query(period, provider), signedBy: Signer(credentials)) return try decode(MemberSummary.self, data) } func update(_ change: MemberChange, as credentials: MemberCredentials) async throws { - _ = try await send("PATCH", "/me", body: try JSONEncoder().encode(change), signedBy: credentials) + _ = try await send("PATCH", "/me", body: try JSONEncoder().encode(change), signedBy: Signer(credentials)) } func leave(as credentials: MemberCredentials) async throws { - _ = try await send("DELETE", "/me", signedBy: credentials) + _ = try await send("DELETE", "/me", signedBy: Signer(credentials)) } func board(period: BoardPeriod, provider: String?) async throws -> [Board.Member] { @@ -70,6 +70,44 @@ struct LeaderboardHTTPClient: LeaderboardAPI { try decode(GlobeSummary.self, try await send("GET", "/globe", query: Self.query(period, nil))) } + // MARK: - Devices + + func requestDevice(publicKey: String, label: String) async throws -> DeviceAuthorization { + let data = try await send("POST", "/devices", body: try JSONEncoder().encode(["publicKey": publicKey, "label": label])) + let answer = try decode(AuthorizationAnswer.self, data) + guard let code = DeviceCode(answer.code) else { + throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") + } + return DeviceAuthorization(code: code, expiresIn: answer.expiresIn, interval: answer.interval) + } + + func approval(of key: SigningKey) async throws -> DeviceApproval { + do { + let (data, status) = try await exchange("GET", "/me", query: Self.query(.today, nil), signedBy: Signer(member: nil, key: key)) + return status == 202 ? .waiting : .approved(try decode(MemberSummary.self, data)) + } catch LeaderboardError.unauthorized { + throw LeaderboardError.codeExpired + } + } + + func pendingDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> PendingDevice { + try decode(PendingDevice.self, try await send("GET", "/me/devices/pending/\(code.value)", signedBy: Signer(credentials))) + } + + func approveDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> Device { + let body = try JSONEncoder().encode(["code": code.value]) + return try decode(Device.self, try await send("POST", "/me/devices", body: body, signedBy: Signer(credentials))) + } + + func removeDevice(_ publicKey: String, as credentials: MemberCredentials) async throws { + _ = try await send("DELETE", "/me/devices/\(publicKey)", signedBy: Signer(credentials)) + } + + func deleteDays(of publicKey: String, provider: String?, day: String?, as credentials: MemberCredentials) async throws { + _ = try await send("DELETE", "/me/devices/\(publicKey)/days", query: Self.query([("provider", provider), ("day", day)]), + signedBy: Signer(credentials)) + } + // MARK: - Wire private struct Upload: Encodable { @@ -105,17 +143,62 @@ struct LeaderboardHTTPClient: LeaderboardAPI { let standings: [Board.Member] } + private struct AuthorizationAnswer: Decodable { + let code: String + let expiresIn: TimeInterval + let interval: TimeInterval + } + private struct Failure: Decodable { let error: String let message: String + /// On a removed device's key: the device that removed it. + let removedBy: DeviceRef? } - private static func query(_ period: BoardPeriod, _ provider: String?) -> String { - "period=\(period.rawValue)" + (provider.map { "&provider=\($0)" } ?? "") + /// Who signs: a member's device, or a new device still waiting for + /// approval, which names no member. + private struct Signer { + let member: Username? + let key: SigningKey + + init(member: Username?, key: SigningKey) { + self.member = member + self.key = key + } + + init(_ credentials: MemberCredentials) { + self.init(member: credentials.username, key: credentials.key) + } + } + + private static func query(_ period: BoardPeriod, _ provider: String?) -> String? { + query([("period", period.rawValue), ("provider", provider)]) + } + + /// `name=value` pairs, the values percent-encoded, leaving out those without + /// a value; `nil` when none has one. The signer signs this exact string, + /// so a value can't change the query the server reads. + private static func query(_ pairs: [(String, String?)]) -> String? { + let parts = pairs.compactMap { name, value in value.map { "\(name)=\(encoded($0))" } } + return parts.isEmpty ? nil : parts.joined(separator: "&") + } + + /// RFC 3986's unreserved characters stay; every other byte is escaped, the + /// same on every platform's Foundation. + private static func encoded(_ value: String) -> String { + let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value } private func send(_ method: String, _ path: String, query: String? = nil, body: Data? = nil, - signedBy credentials: MemberCredentials? = nil) async throws -> Data { + signedBy signer: Signer? = nil) async throws -> Data { + try await exchange(method, path, query: query, body: body, signedBy: signer).data + } + + /// A request and its answer's body and status, for a call that tells `200` from `202`. + private func exchange(_ method: String, _ path: String, query: String? = nil, body: Data? = nil, + signedBy signer: Signer? = nil) async throws -> (data: Data, status: Int) { let pathAndQuery = path + (query.map { "?" + $0 } ?? "") guard let url = URL(string: pathAndQuery, relativeTo: host) else { throw LeaderboardError.unreachable } // The board is cacheable for the web page; the app always reads it fresh, @@ -125,9 +208,9 @@ struct LeaderboardHTTPClient: LeaderboardAPI { request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(client, forHTTPHeaderField: "X-Client") - if let credentials { + if let signer { let headers = try RequestSigner.headers( - member: credentials.username, key: credentials.key, method: method, pathAndQuery: pathAndQuery, + member: signer.member, key: signer.key, method: method, pathAndQuery: pathAndQuery, body: body ?? Data(), timestamp: Int(now().timeIntervalSince1970), nonce: RequestSigner.makeNonce()) for (field, value) in headers { request.setValue(value, forHTTPHeaderField: field) } } @@ -140,15 +223,19 @@ struct LeaderboardHTTPClient: LeaderboardAPI { throw LeaderboardError.unreachable } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - if (200..<300).contains(status) { return data } + if (200..<300).contains(status) { return (data, status) } throw Self.error(status: status, data: data) } private static func error(status: Int, data: Data) -> LeaderboardError { let failure = try? JSONDecoder().decode(Failure.self, from: data) switch (status, failure?.error) { - case (409, _): return .usernameTaken - case (401, "unauthorized"), (401, nil): return .unauthorized + case (401, "unauthorized"), (401, nil): return failure?.removedBy.map { .removed(by: $0.label) } ?? .unauthorized + case (409, "usernameTaken"), (409, nil): return .usernameTaken + case (409, "deviceLimit"): return .deviceLimit + case (409, "lastDevice"): return .lastDevice + case (403, "deviceTooNew"): return .deviceTooNew + case (404, "unknownCode"): return .unknownCode case (400..<500, _): return .rejected(failure?.message ?? "The leaderboard refused that (\(status)).") default: return .unreachable } diff --git a/Modules/Leaderboard/Sources/Internal/Platform.swift b/Modules/Leaderboard/Sources/Internal/Platform.swift index b0867f606..3104ae9a1 100644 --- a/Modules/Leaderboard/Sources/Internal/Platform.swift +++ b/Modules/Leaderboard/Sources/Internal/Platform.swift @@ -1,11 +1,14 @@ import Foundation /// What this machine offers the leaderboard (MODULAR_DESIGN §10, "One place -/// picks the platform"): today, where it keeps the key's private half. Each -/// platform folder defines `current` once; only the factory reads it. +/// picks the platform"): where it keeps the key's private half, and how it +/// reads the machine. Each platform folder defines `current` once; only the +/// factory reads it. /// /// A `nil` connection means one thing: this platform has none yet. struct Platform: Sendable { /// Where the key's private half is kept. var signingKeyStore: (any SigningKeyStore)? + /// The machine, read from its hardware. + var machineIdentity: (any MachineIdentity)? } diff --git a/Modules/Leaderboard/Sources/Internal/Windows/Platform+Windows.swift b/Modules/Leaderboard/Sources/Internal/Windows/Platform+Windows.swift index 354a63914..d3870cc56 100644 --- a/Modules/Leaderboard/Sources/Internal/Windows/Platform+Windows.swift +++ b/Modules/Leaderboard/Sources/Internal/Windows/Platform+Windows.swift @@ -3,8 +3,8 @@ import Foundation extension Platform { /// ClaudeBar for Windows so far (MODULAR_DESIGN §10): it counts and signs a - /// day as the Mac does, and keeps no key yet. Phase 3 keeps it in - /// Credential Manager. - static let current = Platform(signingKeyStore: nil) + /// day as the Mac does, and keeps no key and reads no machine yet. Phase 3 + /// keeps the key in Credential Manager and reads the machine GUID. + static let current = Platform(signingKeyStore: nil, machineIdentity: nil) } #endif diff --git a/Modules/Leaderboard/Sources/Internal/macOS/IOKitMachineIdentity.swift b/Modules/Leaderboard/Sources/Internal/macOS/IOKitMachineIdentity.swift new file mode 100644 index 000000000..5563af86a --- /dev/null +++ b/Modules/Leaderboard/Sources/Internal/macOS/IOKitMachineIdentity.swift @@ -0,0 +1,36 @@ +#if os(macOS) +import Foundation +import IOKit + +/// The Mac read through the I/O Registry: its model, live from the hardware's +/// registry, never the computer name the person gave it. +struct IOKitMachineIdentity: MachineIdentity { + var model: DeviceLabel { + let name = MacModel.name(productName: Self.string(at: "IODeviceTree:/product", "product-name"), + identifier: Self.platformString("model")) + return DeviceLabel(name) ?? DeviceLabel(String(name.prefix(40))) ?? DeviceLabel("Mac")! + } + + private static func platformString(_ key: String) -> String? { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + guard service != 0 else { return nil } + defer { IOObjectRelease(service) } + return string(of: service, key) + } + + private static func string(at path: String, _ key: String) -> String? { + let entry = IORegistryEntryFromPath(kIOMainPortDefault, path) + guard entry != 0 else { return nil } + defer { IOObjectRelease(entry) } + return string(of: entry, key) + } + + /// A registry property as text: it is kept as NUL-terminated bytes. + private static func string(of entry: io_registry_entry_t, _ key: String) -> String? { + let value = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() + if let text = value as? String { return text } + guard let data = value as? Data else { return nil } + return String(decoding: data.prefix { $0 != 0 }, as: UTF8.self) + } +} +#endif diff --git a/Modules/Leaderboard/Sources/Internal/macOS/MacModel.swift b/Modules/Leaderboard/Sources/Internal/macOS/MacModel.swift new file mode 100644 index 000000000..9539b27f5 --- /dev/null +++ b/Modules/Leaderboard/Sources/Internal/macOS/MacModel.swift @@ -0,0 +1,27 @@ +#if os(macOS) +import Foundation + +/// The Mac's model as people read it (design §6): the device tree's +/// `product-name` on Apple silicon ("MacBook Pro (14-inch, 2021)") with the +/// parenthetical dropped; on an Intel Mac, `IOPlatformExpertDevice`'s `model` +/// ("MacBookPro16,1"), its family named ("MacBook Pro"); "Mac" when neither +/// can be read. +enum MacModel { + private static let families = [ + "MacBookPro": "MacBook Pro", "MacBookAir": "MacBook Air", "MacBook": "MacBook", "Macmini": "Mac mini", + "iMacPro": "iMac Pro", "iMac": "iMac", "MacPro": "Mac Pro", + ] + + static func name(productName: String?, identifier: String?) -> String { + if let productName { + let name = productName.split(separator: "(", maxSplits: 1).first.map(String.init) ?? "" + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + if let identifier, let family = families[String(identifier.prefix { !$0.isNumber })] { + return family + } + return "Mac" + } +} +#endif diff --git a/Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift b/Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift index 37a937efd..436a9df0c 100644 --- a/Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift +++ b/Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift @@ -3,9 +3,11 @@ import Foundation extension Platform { /// The Mac: the key in the Keychain, and in the app's UserDefaults when the - /// Keychain refuses a locally built, ad-hoc signed app. + /// Keychain refuses a locally built, ad-hoc signed app; the machine through + /// the I/O Registry. static let current = Platform( - signingKeyStore: FallbackSigningKeyStore(secure: KeychainSigningKeyStore(), fallback: DefaultsSigningKeyStore()) + signingKeyStore: FallbackSigningKeyStore(secure: KeychainSigningKeyStore(), fallback: DefaultsSigningKeyStore()), + machineIdentity: IOKitMachineIdentity() ) } #endif diff --git a/Modules/Leaderboard/Sources/Leaderboard.swift b/Modules/Leaderboard/Sources/Leaderboard.swift index 12f769765..8b6a0bb12 100644 --- a/Modules/Leaderboard/Sources/Leaderboard.swift +++ b/Modules/Leaderboard/Sources/Leaderboard.swift @@ -1,8 +1,8 @@ import Foundation -/// The factory (MODULAR_DESIGN §4): the board's server, and where this -/// machine keeps the key. A client builds `LeaderboardMembership`, -/// `LeaderboardUploader` and `Board` over them. +/// The factory (MODULAR_DESIGN §4): the board's server, where this machine +/// keeps the key, and the machine itself. A client builds +/// `LeaderboardMembership`, `LeaderboardUploader` and `Board` over them. public enum Leaderboard { /// The board's server over HTTPS. `client` is how the app names itself on /// every request (`X-Client`), such as `claudebar-macos/0.5.11`, so the @@ -16,4 +16,10 @@ public enum Leaderboard { public static func makeKeyStore() -> (any SigningKeyStore)? { Platform.current.signingKeyStore } + + /// This machine as the leaderboard knows it, or `nil` where ClaudeBar reads + /// none yet: Windows, until MODULAR_DESIGN §10's phase 3. + public static func makeMachineIdentity() -> (any MachineIdentity)? { + Platform.current.machineIdentity + } } diff --git a/Modules/Leaderboard/Sources/LeaderboardAPI.swift b/Modules/Leaderboard/Sources/LeaderboardAPI.swift index 513b7d412..9798ea212 100644 --- a/Modules/Leaderboard/Sources/LeaderboardAPI.swift +++ b/Modules/Leaderboard/Sources/LeaderboardAPI.swift @@ -18,8 +18,8 @@ public enum BoardPeriod: String, Sendable, CaseIterable, Codable { /// What the server holds about you: your name, you on the board asked for, -/// whether you're shown, whether your country is on the globe, and every day -/// you uploaded. +/// whether you're shown, whether your country is on the globe, every day +/// you uploaded, and your devices. public struct MemberSummary: Sendable, Equatable, Codable { /// The member's name on the server, which another device may have changed. /// `nil` from a server that doesn't say it. @@ -32,6 +32,9 @@ public struct MemberSummary: Sendable, Equatable, Codable { /// The country the server keeps for the globe, when you opted in. public let country: String? public let link: ProfileLink? + /// The member's devices, removed ones included. A device in its own first + /// week sees only itself; a server from before devices lists none. + public let devices: [Device] /// Whether the answer said where the member stands on the globe, and /// whether it said what their link is (a `null` link says there is none). /// A device follows only what the server said. @@ -39,7 +42,7 @@ public struct MemberSummary: Sendable, Equatable, Codable { let reportsLink: Bool public init(username: String? = nil, onBoard: Board.Member?, days: [DailyTokens], visible: Bool, sharesCountry: Bool = false, - country: String? = nil, link: ProfileLink? = nil) { + country: String? = nil, link: ProfileLink? = nil, devices: [Device] = []) { self.username = username self.onBoard = onBoard self.days = days @@ -47,12 +50,13 @@ public struct MemberSummary: Sendable, Equatable, Codable { self.sharesCountry = sharesCountry self.country = country self.link = link + self.devices = devices reportsSharesCountry = true reportsLink = true } private enum CodingKeys: String, CodingKey { - case username, days, visible, country, link + case username, days, visible, country, link, devices case onBoard = "standing" case sharesCountry = "shareCountry" } @@ -66,6 +70,7 @@ public struct MemberSummary: Sendable, Equatable, Codable { sharesCountry = try container.decodeIfPresent(Bool.self, forKey: .sharesCountry) ?? false country = try container.decodeIfPresent(String.self, forKey: .country) link = try? container.decodeIfPresent(ProfileLink.self, forKey: .link) + devices = try container.decodeIfPresent([Device].self, forKey: .devices) ?? [] reportsSharesCountry = container.contains(.sharesCountry) // A link that doesn't fit its platform's rules is dropped, and not taken as "no link". reportsLink = container.contains(.link) && (link != nil || (try? container.decodeNil(forKey: .link)) == true) @@ -174,7 +179,7 @@ public struct GlobeSummary: Sendable, Equatable, Decodable { } } -/// Who signs a request: the name and the key only this Mac holds. +/// Who signs a request: the name and the key only this device holds. public struct MemberCredentials: Sendable { public let username: Username public let key: SigningKey @@ -192,6 +197,18 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case notJoined /// The server refused the signature: the key no longer matches the name. case unauthorized + /// Another device of the member removed this one: its key is revoked. + case removed(by: String) + /// A new device's code expired before a device of the member approved it. + case codeExpired + /// No code like that is waiting: unknown, expired or used. + case unknownCode + /// The member already has five devices in use. + case deviceLimit + /// A device in its first week can't do this yet. + case deviceTooNew + /// The member's last device can't be removed: that is leaving. + case lastDevice case rejected(String) case unreachable @@ -202,24 +219,47 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case .nothingShared: "Pick at least one provider to share." case .notJoined: "You haven't joined the leaderboard." case .unauthorized: "The leaderboard didn't accept this Mac's key for your username." + case .removed(let label): "\(label) removed this Mac from your leaderboard devices." + case .codeExpired: "The code expired before it was approved. Ask for a new one." + case .unknownCode: "No device is waiting with that code. Check it, or ask for a new one." + case .deviceLimit: "You already have five devices. Remove one to add another." + case .deviceTooNew: "A device can do this once it has been added a week." + case .lastDevice: "This is your only device. To remove it, leave the leaderboard." case .rejected(let reason): reason case .unreachable: "The leaderboard can't be reached right now." } } } -/// The leaderboard server. Every call but `join` and `board` is signed. +/// The leaderboard server. Every call but `join`, `requestDevice` and `board` is signed. @Mockable public protocol LeaderboardAPI: Sendable { - func join(username: String, publicKey: String) async throws + /// Creates the member and its first device, named `label`. + func join(username: String, publicKey: String, label: String) async throws /// Sends days; answers the ones the server refused, each alone. A server /// from before devices refuses none this way: it fails the whole upload. func upload(_ days: [DailyTokens], as credentials: MemberCredentials) async throws -> [RefusedDay] - /// You on one board, with your name and settings. `provider` `nil` = everyone. + /// You on one board, with your name, settings and devices. `provider` `nil` = everyone. func me(period: BoardPeriod, provider: String?, as credentials: MemberCredentials) async throws -> MemberSummary func update(_ change: MemberChange, as credentials: MemberCredentials) async throws func leave(as credentials: MemberCredentials) async throws /// One board's members, in rank order. `provider` `nil` = everyone. func board(period: BoardPeriod, provider: String?) async throws -> [Board.Member] func globe(period: BoardPeriod) async throws -> GlobeSummary + + // MARK: Devices (design §2a) + + /// A new device asks to be added: the server answers with the code it shows. + func requestDevice(publicKey: String, label: String) async throws -> DeviceAuthorization + /// Whether the new device holding `key` was approved, signed with that key + /// alone. Throws `codeExpired` once its code expired. + func approval(of key: SigningKey) async throws -> DeviceApproval + /// What approving `code` would add. + func pendingDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> PendingDevice + /// Approves the device that showed `code`, and answers it. + func approveDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> Device + /// Removes a device, this one included: its key is revoked, its days stay. + func removeDevice(_ publicKey: String, as credentials: MemberCredentials) async throws + /// Deletes a removed device's days: all of them, or one provider's, or one day's. + func deleteDays(of publicKey: String, provider: String?, day: String?, as credentials: MemberCredentials) async throws } diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index 473f1ddf9..d09cac40e 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -27,24 +27,54 @@ public final class LeaderboardMembership { /// Days the server refused, each alone, with why: sent again with each /// upload until the server takes them or they are 30 days old. public private(set) var refused: [RefusedDay] = [] + /// The member's devices as `/me` last listed them: a copy, like the name. + public private(set) var devices: [Device] = [] + /// A new device on its way in: the code it shows, then the member a device + /// of theirs added it to, until the person agrees. `nil` otherwise. + public private(set) var joining: Joining? + /// The device that removed this one, once its key was refused for that: + /// the membership is forgotten, and this says why. + public private(set) var removedBy: String? private var key: SigningKey? + /// Devices already shown as added, by key: each is shown once. + private var shownDevices: Set = [] + /// A new device's key and what the server said, while it is joining. + private var joiningKey: SigningKey? + private var joiningInterval: TimeInterval = 5 + private var approvedSummary: MemberSummary? /// Changes made here, counted, so a `/me` answer that crossed one is /// not taken over it. private var changesMadeHere = 0 + /// Where a new device stands on its way in (design §2a). + public enum Joining: Sendable, Equatable { + /// It shows `code` until a device of the member approves it. + case waiting(code: DeviceCode) + /// A device of `member` approved it; nothing is sent until the person agrees. + case approved(member: Username) + } + + /// A device added less than this long ago can't yet act for the member. + static let firstWeek: TimeInterval = 7 * 24 * 60 * 60 + @ObservationIgnored private let api: any LeaderboardAPI @ObservationIgnored private let keys: any SigningKeyStore @ObservationIgnored private let settings: any LeaderboardSettingsRepository @ObservationIgnored private let logs: any TokenLogs + @ObservationIgnored private let machine: any MachineIdentity @ObservationIgnored private let calendar: Calendar + @ObservationIgnored private let now: () -> Date public init(api: any LeaderboardAPI, keys: any SigningKeyStore, settings: any LeaderboardSettingsRepository, - logs: any TokenLogs, calendar: Calendar = .current) { + logs: any TokenLogs, machine: any MachineIdentity, calendar: Calendar = .current, + now: @escaping () -> Date = Date.init) { self.api = api self.keys = keys self.settings = settings self.logs = logs + self.machine = machine self.calendar = calendar + self.now = now restore() } @@ -66,12 +96,16 @@ public final class LeaderboardMembership { // MARK: - Joining and leaving + /// What this device is called unless the person types another name: the + /// machine's model, never its computer name. + public var suggestedLabel: DeviceLabel { machine.model } + public func join(as username: Username, sharing providers: Set, sharesCountry: Bool = false, - link: ProfileLink? = nil) async throws { + link: ProfileLink? = nil, label: DeviceLabel? = nil) async throws { guard !providers.isEmpty else { throw LeaderboardError.nothingShared } try requireShareable(providers) let key = SigningKey.generate() - try await api.join(username: username.value, publicKey: key.publicKey) + try await api.join(username: username.value, publicKey: key.publicKey, label: (label ?? suggestedLabel).value) keys.save(key.rawRepresentation) self.key = key self.username = username @@ -82,11 +116,100 @@ public final class LeaderboardMembership { self.sharesCountry = false globeHintDismissed = false self.link = nil + startFresh() save() if sharesCountry { try? await setSharesCountry(true) } if let link { try? await setLink(link) } } + // MARK: - Joining as a new device of a member (design §2a) + + /// *Already a member? Add this Mac*: makes this device's key and asks the + /// server to add it; answers the code to type on a device the member has. + public func requestToJoin(label: DeviceLabel? = nil) async throws -> DeviceCode { + let key = SigningKey.generate() + let authorization = try await api.requestDevice(publicKey: key.publicKey, label: (label ?? suggestedLabel).value) + joiningKey = key + joiningInterval = authorization.interval + approvedSummary = nil + joining = .waiting(code: authorization.code) + return authorization.code + } + + /// Asks, at the interval the server gave, until a device of the member + /// approves the code; answers the member it joined. Nothing is kept or + /// sent until the person agrees (`confirmJoining`): a code that leaked + /// could have been approved by someone else. + public func waitForApproval() async throws -> Username { + guard case .waiting = joining, let key = joiningKey else { throw LeaderboardError.notJoined } + while true { + let approval: DeviceApproval + do { + approval = try await api.approval(of: key) + } catch LeaderboardError.codeExpired { + endJoining() + throw LeaderboardError.codeExpired + } + switch approval { + case .waiting: + try await Task.sleep(for: .seconds(joiningInterval)) + case .approved(let summary): + guard let member = summary.username.flatMap(Username.init) else { + throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") + } + approvedSummary = summary + joining = .approved(member: member) + return member + } + } + } + + /// The person knows the member this device was added to: it joins as that + /// member, sharing `providers`, with nothing uploaded yet, so its first + /// upload sends the last thirty days. + public func confirmJoining(sharing providers: Set) throws { + guard case .approved(let member) = joining, let key = joiningKey, let summary = approvedSummary else { + throw LeaderboardError.notJoined + } + guard !providers.isEmpty else { throw LeaderboardError.nothingShared } + try requireShareable(providers) + keys.save(key.rawRepresentation) + self.key = key + username = member + sharing = providers + isVisible = true + lastUpload = nil + refused = [] + sharesCountry = false + globeHintDismissed = false + link = nil + startFresh() + follow(summary) + endJoining() + } + + /// The person doesn't know the member this device was added to: it removes + /// itself from that member, and keeps nothing. + public func declineJoining() async throws { + guard case .approved(let member) = joining, let key = joiningKey else { throw LeaderboardError.notJoined } + try await api.removeDevice(key.publicKey, as: MemberCredentials(username: member, key: key)) + endJoining() + } + + private func endJoining() { + joining = nil + joiningKey = nil + approvedSummary = nil + } + + /// A membership this device starts: no devices known or shown yet, and no + /// word of an earlier one's removal. + private func startFresh() { + devices = [] + shownDevices = [] + removedBy = nil + } + /// Deletes the member and every row on the server first; the key is /// forgotten only once the server confirmed, or the name would be lost /// with the data still there. @@ -103,6 +226,13 @@ public final class LeaderboardMembership { forget() } + /// Another device of the member removed this one: its key is revoked, so + /// the membership is forgotten here, and `removedBy` says which device did it. + func forgetRemoved(by label: String) { + forget() + removedBy = label + } + private func forget() { keys.delete() key = nil @@ -114,6 +244,8 @@ public final class LeaderboardMembership { sharesCountry = false globeHintDismissed = false link = nil + devices = [] + shownDevices = [] settings.saveLeaderboardRecord(nil) } @@ -228,9 +360,96 @@ public final class LeaderboardMembership { country = summary.country } if summary.reportsLink { link = summary.link } + devices = summary.devices + save() + } + + // MARK: - Devices (design §2a) + + /// This device as `/me` lists it, once read. + public var thisDevice: Device? { + key.flatMap { key in devices.first { $0.publicKey == key.publicKey } } + } + + /// What approving `code` would add, shown above *Approve* so a code read + /// out to a stranger isn't approved blind. + public func pendingDevice(code: DeviceCode) async throws -> PendingDevice { + guard let credentials else { throw LeaderboardError.notJoined } + return try await api.pendingDevice(code: code, as: credentials) + } + + /// Adds the device that shows `code`. This device approved it, so it + /// isn't shown here as one added since this device last looked. + public func approve(code: DeviceCode) async throws { + guard let credentials else { throw LeaderboardError.notJoined } + let device = try await api.approveDevice(code: code, as: credentials) + shownDevices.insert(device.publicKey) + if !devices.contains(where: { $0.publicKey == device.publicKey }) { devices.append(device) } + save() + } + + /// Each device added since this one, in use and not yet shown: shown once, + /// with *Remove* beside it. A device turned off shows them once it is + /// turned on, and one in its own first week once `/me` lists the others, + /// which is when that week ends. Devices the member had before this one + /// aren't news. + public var addedDevices: [Device] { + guard isOn, let me = thisDevice else { return [] } + return devices.filter { + !$0.isRemoved && $0.publicKey != me.publicKey && $0.addedAt > me.addedAt && !shownDevices.contains($0.publicKey) + } + } + + /// `device` was shown as added, so it isn't again. + public func markShown(_ device: Device) { + shownDevices.insert(device.publicKey) save() } + /// Whether removing `device` offers *Remove and delete its days* beside + /// *Remove*: only for another device still in its first week, which a + /// stranger's device approved by mistake would be. + public func offersDeletingDays(whenRemoving device: Device) -> Bool { + device.publicKey != thisDevice?.publicKey && isInFirstWeek(device) + } + + /// Removes `device`: its key is revoked, and its days stay unless + /// `deletingDays`, which deletes them once it is removed. If deleting + /// fails, the device is removed all the same and the error says why. + /// Removing this device forgets the membership once the server confirmed. + public func remove(device: Device, deletingDays: Bool = false) async throws { + guard let credentials else { throw LeaderboardError.notJoined } + try await api.removeDevice(device.publicKey, as: credentials) + if device.publicKey == credentials.key.publicKey { + forget() + return + } + let by = DeviceRef(publicKey: credentials.key.publicKey, label: thisDevice?.label ?? suggestedLabel.value) + devices = devices.map { + $0.publicKey == device.publicKey + ? Device(publicKey: $0.publicKey, label: $0.label, addedAt: $0.addedAt, removedAt: now(), removedBy: by) + : $0 + } + changesMadeHere += 1 + if deletingDays { + try await api.deleteDays(of: device.publicKey, provider: nil, day: nil, as: credentials) + } + } + + /// Deletes a removed device's days: all of them, or one provider's, or + /// one day's. Never a side effect of removing it. + public func deleteDays(of device: Device, provider: String? = nil, day: String? = nil) async throws { + guard let credentials else { throw LeaderboardError.notJoined } + try await api.deleteDays(of: device.publicKey, provider: provider, day: day, as: credentials) + } + + /// Added less than a week ago, and not the device that joined, which is + /// never held to a first week: that one is the member's earliest. + private func isInFirstWeek(_ device: Device) -> Bool { + let joined = devices.min { $0.addedAt < $1.addedAt } + return device.publicKey != joined?.publicKey && now().timeIntervalSince(device.addedAt) < Self.firstWeek + } + // MARK: - Private private func requireShareable(_ providers: Set) throws { @@ -252,6 +471,7 @@ public final class LeaderboardMembership { globeHintDismissed = record.globeHintDismissed link = record.link refused = record.refused + shownDevices = Set(record.shownDevices) } private func save() { @@ -259,6 +479,6 @@ public final class LeaderboardMembership { settings.saveLeaderboardRecord(LeaderboardRecord(username: username.value, sharing: Array(sharing), visible: isVisible, lastUpload: lastUpload, sharesCountry: sharesCountry, globeHintDismissed: globeHintDismissed, - link: link, refused: refused)) + link: link, refused: refused, shownDevices: Array(shownDevices))) } } diff --git a/Modules/Leaderboard/Sources/LeaderboardSettingsRepository.swift b/Modules/Leaderboard/Sources/LeaderboardSettingsRepository.swift index 4c8bd653b..8c038e06f 100644 --- a/Modules/Leaderboard/Sources/LeaderboardSettingsRepository.swift +++ b/Modules/Leaderboard/Sources/LeaderboardSettingsRepository.swift @@ -12,10 +12,13 @@ public struct LeaderboardRecord: Sendable, Equatable { public let link: ProfileLink? /// Days the server refused, to send again. public let refused: [RefusedDay] + /// The keys of the devices already shown as added, each shown once. + /// This device's own, never sent. + public let shownDevices: [String] public init(username: String, sharing: [String], visible: Bool, lastUpload: Date?, sharesCountry: Bool = false, globeHintDismissed: Bool = false, link: ProfileLink? = nil, - refused: [RefusedDay] = []) { + refused: [RefusedDay] = [], shownDevices: [String] = []) { self.link = link self.username = username self.sharing = sharing.sorted() @@ -24,6 +27,7 @@ public struct LeaderboardRecord: Sendable, Equatable { self.sharesCountry = sharesCountry self.globeHintDismissed = globeHintDismissed self.refused = refused + self.shownDevices = shownDevices.sorted() } } diff --git a/Modules/Leaderboard/Sources/LeaderboardUploader.swift b/Modules/Leaderboard/Sources/LeaderboardUploader.swift index ac62fc43b..643c4d3f5 100644 --- a/Modules/Leaderboard/Sources/LeaderboardUploader.swift +++ b/Modules/Leaderboard/Sources/LeaderboardUploader.swift @@ -84,6 +84,10 @@ public final class LeaderboardUploader { membership.forgetUnknownMember() lastError = nil AppLog.network.info("Leaderboard no longer knows this member; forgot the membership here") + } catch LeaderboardError.removed(let label) { + membership.forgetRemoved(by: label) + lastError = nil + AppLog.network.info("Another device of this member removed this one; forgot the membership here") } catch { lastError = error as? LeaderboardError ?? .unreachable AppLog.network.info("Leaderboard upload failed: \(lastError?.localizedDescription ?? "")") diff --git a/Modules/Leaderboard/Sources/MachineIdentity.swift b/Modules/Leaderboard/Sources/MachineIdentity.swift new file mode 100644 index 000000000..1f2e5c900 --- /dev/null +++ b/Modules/Leaderboard/Sources/MachineIdentity.swift @@ -0,0 +1,11 @@ +import Mockable + +/// This machine, as the leaderboard may know it (design §2a, §6). Faked in +/// tests to stand for another machine. +@Mockable +public protocol MachineIdentity: Sendable { + /// The machine's model as a device's label ("MacBook Pro"), never its + /// computer name; what the platform calls the machine when its model can't + /// be read. + var model: DeviceLabel { get } +} diff --git a/Modules/Leaderboard/Sources/RequestSigner.swift b/Modules/Leaderboard/Sources/RequestSigner.swift index 678ce8258..47331ac57 100644 --- a/Modules/Leaderboard/Sources/RequestSigner.swift +++ b/Modules/Leaderboard/Sources/RequestSigner.swift @@ -14,19 +14,21 @@ public enum RequestSigner { /// The headers of a signed request. `X-Key` names the device's key, which is /// how the server finds the device and, through it, the member; `X-Member` - /// stays for a server from before devices, which finds the member by name. - /// Neither is part of the signed string, so the shared vectors don't change. - public static func headers(member: Username, key: SigningKey, method: String, pathAndQuery: String, body: Data, + /// stays for a server from before devices, which finds the member by name, + /// and is left out by a new device still waiting for approval, which has + /// none. Neither is part of the signed string, so the shared vectors don't change. + public static func headers(member: Username?, key: SigningKey, method: String, pathAndQuery: String, body: Data, timestamp: Int, nonce: String) throws -> [String: String] { let message = canonical(method: method, pathAndQuery: pathAndQuery, timestamp: timestamp, nonce: nonce, body: body) let signature = try key.signature(for: Data(message.utf8)) - return [ + var headers = [ "X-Key": key.publicKey, - "X-Member": member.value, "X-Timestamp": String(timestamp), "X-Nonce": nonce, "X-Signature": signature.base64URLEncoded ] + headers["X-Member"] = member?.value + return headers } /// Sixteen random bytes, base64url: a signed request is accepted once. diff --git a/Modules/Leaderboard/Tests/BoardTests.swift b/Modules/Leaderboard/Tests/BoardTests.swift index 1a8b9d159..8d55a2d46 100644 --- a/Modules/Leaderboard/Tests/BoardTests.swift +++ b/Modules/Leaderboard/Tests/BoardTests.swift @@ -19,7 +19,7 @@ struct BoardTests { private func board(period: BoardPeriod = .sevenDays, provider: String? = nil) async throws -> Board { let membership = LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, - calendar: LeaderboardFixtures.calendar) + machine: MockMachineIdentity.named("MacBook Pro"), calendar: LeaderboardFixtures.calendar) try await membership.join(as: #require(Username("tokenwhale")), sharing: ["claude"]) return Board(period: period, provider: provider, api: api, membership: membership) } diff --git a/Modules/Leaderboard/Tests/DeviceServer.swift b/Modules/Leaderboard/Tests/DeviceServer.swift new file mode 100644 index 000000000..590315d19 --- /dev/null +++ b/Modules/Leaderboard/Tests/DeviceServer.swift @@ -0,0 +1,82 @@ +import Foundation +import Mockable +@testable import Leaderboard + +/// A leaderboard server for the device tests: it answers as told, and keeps +/// what each device call asked of it, so a test reads the outcome. +final class DeviceServer: LeaderboardAPI, @unchecked Sendable { + struct DeletedDays: Equatable { + let publicKey: String + let provider: String? + let day: String? + } + + var summary = MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: true) + var authorization = DeviceAuthorization(code: DeviceCode("WDJBMJHT")!, expiresIn: 600, interval: 0) + /// `GET /me` from a waiting key, answered in turn; the last answer repeats. + var approvals: [Result] = [] + var pending = PendingDevice(label: "Mac mini", requestedAt: Date(timeIntervalSince1970: 1_791_106_200)) + var approved = Device(publicKey: "newkey", label: "Mac mini", addedAt: Date(timeIntervalSince1970: 1_791_106_260)) + var removeFailure: LeaderboardError? + var deleteDaysFailure: LeaderboardError? + + private(set) var joinedLabel: String? + private(set) var requested: (publicKey: String, label: String)? + private(set) var approvalsAsked = 0 + private(set) var approvedCode: DeviceCode? + private(set) var removed: [String] = [] + private(set) var deletedDays: [DeletedDays] = [] + + func join(username: String, publicKey: String, label: String) async throws { + joinedLabel = label + } + + func requestDevice(publicKey: String, label: String) async throws -> DeviceAuthorization { + requested = (publicKey, label) + return authorization + } + + func approval(of key: SigningKey) async throws -> DeviceApproval { + let answer = approvals[min(approvalsAsked, approvals.count - 1)] + approvalsAsked += 1 + return try answer.get() + } + + func pendingDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> PendingDevice { + pending + } + + func approveDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> Device { + approvedCode = code + return approved + } + + func removeDevice(_ publicKey: String, as credentials: MemberCredentials) async throws { + if let removeFailure { throw removeFailure } + removed.append(publicKey) + } + + func deleteDays(of publicKey: String, provider: String?, day: String?, as credentials: MemberCredentials) async throws { + if let deleteDaysFailure { throw deleteDaysFailure } + deletedDays.append(DeletedDays(publicKey: publicKey, provider: provider, day: day)) + } + + func me(period: BoardPeriod, provider: String?, as credentials: MemberCredentials) async throws -> MemberSummary { + summary + } + + func upload(_ days: [DailyTokens], as credentials: MemberCredentials) async throws -> [RefusedDay] { [] } + func update(_ change: MemberChange, as credentials: MemberCredentials) async throws {} + func leave(as credentials: MemberCredentials) async throws {} + func board(period: BoardPeriod, provider: String?) async throws -> [Board.Member] { [] } + func globe(period: BoardPeriod) async throws -> GlobeSummary { GlobeSummary(countries: [], present: []) } +} + +extension MockMachineIdentity { + /// A machine whose model reads as `model`. + static func named(_ model: String) -> MockMachineIdentity { + let machine = MockMachineIdentity() + given(machine).model.willReturn(DeviceLabel(model)!) + return machine + } +} diff --git a/Modules/Leaderboard/Tests/DeviceTests.swift b/Modules/Leaderboard/Tests/DeviceTests.swift new file mode 100644 index 000000000..83ce3a83d --- /dev/null +++ b/Modules/Leaderboard/Tests/DeviceTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import Leaderboard + +/// What a device is called, the code a new one shows, and a device as `/me` lists it. +@Suite +struct DeviceTests { + // MARK: - The label + + @Test func `should take a label the person typed, without the spaces around it`() { + #expect(DeviceLabel(" Mac mini ")?.value == "Mac mini") + } + + @Test(arguments: ["", " ", String(repeating: "a", count: 41), "Mac\nmini", "Mac\u{7}"]) + func `should refuse a label the server would refuse: empty, over 40 characters, or with a control character`(text: String) { + #expect(DeviceLabel(text) == nil) + } + + @Test func `should take a label of exactly 40 characters`() { + #expect(DeviceLabel(String(repeating: "a", count: 40)) != nil) + } + + // MARK: - The code + + @Test(arguments: ["WDJB-MJHT", "wdjbmjht", "wdjb mjht", " WDJB-mjht "]) + func `should read a code however it's typed: case, dash and spaces`(typed: String) { + #expect(DeviceCode(typed)?.value == "WDJBMJHT") + } + + @Test func `should show a code in two groups of four`() { + #expect(DeviceCode("wdjbmjht")?.description == "WDJB-MJHT") + } + + @Test(arguments: ["WDJB-MJH", "WDJB-MJHTX", "WDJB-MJHA", "WDJB-MJH1"]) + func `should refuse a code that isn't 8 of RFC 8628's consonants`(typed: String) { + #expect(DeviceCode(typed) == nil) + } + + // MARK: - A device on /me + + @Test func `should read a device on /me, removed or in use`() throws { + let json = #""" + [{"publicKey":"k1","label":"MacBook Pro","addedAt":"2026-10-01T08:00:00Z","removedAt":null,"removedBy":null}, + {"publicKey":"k2","label":"Mac mini","addedAt":"2026-10-03T08:00:00.250Z","removedAt":"2026-10-04T09:30:00Z", + "removedBy":{"publicKey":"k1","label":"MacBook Pro"}}] + """# + + let devices = try JSONDecoder().decode([Device].self, from: Data(json.utf8)) + + #expect(devices[0].publicKey == "k1") + #expect(devices[0].label == "MacBook Pro") + #expect(devices[0].addedAt == Date(timeIntervalSince1970: 1_790_841_600)) + #expect(devices[0].removedAt == nil && devices[0].removedBy == nil) + #expect(devices[1].addedAt == Date(timeIntervalSince1970: 1_791_014_400.25)) + #expect(devices[1].removedAt == Date(timeIntervalSince1970: 1_791_106_200)) + #expect(devices[1].removedBy == DeviceRef(publicKey: "k1", label: "MacBook Pro")) + } + + /// §5 doesn't say how the server writes a time, so the forms a Worker + /// on D1 is likely to send all read as the same instant. + @Test(arguments: [#""2026-10-01T08:00:00Z""#, #""2026-10-01T08:00:00.000Z""#, #""2026-10-01 08:00:00""#, + "1790841600", "1790841600000"]) + func `should read a time however the server writes it`(written: String) throws { + let json = #"{"publicKey":"k1","label":"Mac","addedAt":"# + written + "}" + + let device = try JSONDecoder().decode(Device.self, from: Data(json.utf8)) + + #expect(device.addedAt == Date(timeIntervalSince1970: 1_790_841_600)) + } + + @Test func `should refuse a device whose time it can't read, rather than guess one`() { + let json = #"{"publicKey":"k1","label":"Mac","addedAt":"last Tuesday"}"# + + #expect(throws: DecodingError.self) { try JSONDecoder().decode(Device.self, from: Data(json.utf8)) } + } + + /// Settings' *Export…* writes `/me`'s answer with a plain `JSONEncoder`; read + /// back, every device keeps the times the server gave it. + @Test func `should write a member's devices so their times read back as the same instants`() throws { + let json = #""" + {"username":"tokenwhale","visible":true,"standing":null,"days":[],"devices":[ + {"publicKey":"k1","label":"MacBook Pro","addedAt":"2026-10-01T08:00:00Z","removedAt":null,"removedBy":null}, + {"publicKey":"k2","label":"Mac mini","addedAt":1791014400123,"removedAt":"2026-10-04 09:30:00", + "removedBy":{"publicKey":"k1","label":"MacBook Pro"}}]} + """# + let summary = try JSONDecoder().decode(MemberSummary.self, from: Data(json.utf8)) + + let exported = try JSONEncoder().encode(summary) + + let readBack = try JSONDecoder().decode(MemberSummary.self, from: exported).devices + #expect(readBack.map(\.addedAt) == [Date(timeIntervalSince1970: 1_790_841_600), Date(timeIntervalSince1970: 1_791_014_400.123)]) + #expect(readBack.map(\.removedAt) == [nil, Date(timeIntervalSince1970: 1_791_106_200)]) + #expect(readBack == summary.devices) + } +} diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift new file mode 100644 index 000000000..95b62d832 --- /dev/null +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -0,0 +1,302 @@ +import Foundation +import Mockable +import Testing +@testable import Leaderboard + +/// Slice 13, adding and removing devices (design §2a, §8): a new device waits +/// for a code to be approved and asks before it uploads; a device past its +/// first week shows each device added since it last looked, once; removing +/// revokes a key, and only another device in its first week offers to delete +/// its days too. +@MainActor +@Suite +struct LeaderboardDevicesTests { + private let server = DeviceServer() + private let keys = InMemorySigningKeyStore() + private let settings = InMemoryLeaderboardSettings() + private let logs = FakeTokenLogs(providersWithLogs: ["claude", "codex"]) + /// Day 20: devices added on day 13 or before are past their first week. + private let today = LeaderboardFixtures.date(20) + + private func membership(machine: String = "MacBook Pro") -> LeaderboardMembership { + let today = today + return LeaderboardMembership(api: server, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named(machine), + calendar: LeaderboardFixtures.calendar, now: { today }) + } + + private func joined() async throws -> LeaderboardMembership { + let membership = membership() + try await membership.join(as: #require(Username("tokenwhale")), sharing: ["claude"]) + return membership + } + + /// This device, as `/me` lists it once it reads it. + private func thisDevice(_ membership: LeaderboardMembership, addedOn day: Int) throws -> Device { + let key = try SigningKey(rawRepresentation: #require(keys.stored)).publicKey + return Device(publicKey: key, label: "MacBook Pro", addedAt: LeaderboardFixtures.date(day)) + } + + /// `/me` lists `devices`, and this device reads it. + private func read(_ devices: [Device], into membership: LeaderboardMembership) async throws { + server.summary = MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: true, devices: devices) + _ = try await membership.summary(period: .sevenDays) + } + + private func device(_ key: String, addedOn day: Int, removedOn removed: Int? = nil) -> Device { + Device(publicKey: key, label: "Mac \(key)", addedAt: LeaderboardFixtures.date(day), + removedAt: removed.map { LeaderboardFixtures.date($0) }) + } + + // MARK: - What a device is called + + @Test func `should name this device by the machine's model when it joins`() async throws { + _ = try await joined() + + #expect(server.joinedLabel == "MacBook Pro") + } + + @Test func `should name this device as the person typed it when they change the label`() async throws { + try await membership().join(as: #require(Username("tokenwhale")), sharing: ["claude"], label: #require(DeviceLabel("Work laptop"))) + + #expect(server.joinedLabel == "Work laptop") + } + + // MARK: - A new device: the code, the wait, the question + + @Test func `should show the code the server gave, and stay outside while it waits`() async throws { + let membership = membership(machine: "Mac mini") + + let code = try await membership.requestToJoin() + + #expect(code == server.authorization.code) + #expect(server.requested?.label == "Mac mini") + #expect(membership.joining == .waiting(code: code)) + #expect(!membership.isJoined) + #expect(keys.stored == nil) + } + + @Test func `should name the member it joined once approved, and send nothing before the person agrees`() async throws { + let membership = membership() + server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] + _ = try await membership.requestToJoin() + + let member = try await membership.waitForApproval() + + #expect(member.value == "tokenwhale") + #expect(server.approvalsAsked == 3) + #expect(membership.joining == .approved(member: member)) + #expect(!membership.isJoined) + #expect(membership.uploadCredentials == nil) + #expect(keys.stored == nil && settings.record == nil) + } + + @Test func `should join as the member it was added to once the person agrees, with nothing uploaded yet`() async throws { + let membership = membership() + server.approvals = [.success(.approved(MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: false)))] + _ = try await membership.requestToJoin() + _ = try await membership.waitForApproval() + + try membership.confirmJoining(sharing: ["codex"]) + + #expect(membership.isJoined) + #expect(membership.joining == nil) + #expect(membership.username?.value == "tokenwhale") + #expect(membership.sharing == ["codex"]) + #expect(!membership.isVisible) + #expect(membership.lastUpload == nil) + #expect(try SigningKey(rawRepresentation: #require(keys.stored)).publicKey == server.requested?.publicKey) + #expect(settings.record?.username == "tokenwhale") + } + + @Test func `should forget the code when it expires before anyone approves it`() async throws { + let membership = membership() + server.approvals = [.success(.waiting), .failure(.codeExpired)] + _ = try await membership.requestToJoin() + + await #expect(throws: LeaderboardError.codeExpired) { _ = try await membership.waitForApproval() } + #expect(membership.joining == nil) + #expect(!membership.isJoined) + } + + @Test func `should remove itself and keep nothing when the person doesn't know the member it was added to`() async throws { + let membership = membership() + server.approvals = [.success(.approved(server.summary))] + _ = try await membership.requestToJoin() + _ = try await membership.waitForApproval() + + try await membership.declineJoining() + + #expect(server.removed == [server.requested?.publicKey]) + #expect(membership.joining == nil) + #expect(!membership.isJoined) + #expect(keys.stored == nil && settings.record == nil) + } + + // MARK: - Approving a new device + + @Test func `should show what a code would add before approving it`() async throws { + let membership = try await joined() + + let pending = try await membership.pendingDevice(code: #require(DeviceCode("WDJB-MJHT"))) + + #expect(pending == server.pending) + } + + @Test func `should not show a device it approved itself as newly added`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 1) + let code = try #require(DeviceCode("WDJB-MJHT")) + + try await membership.approve(code: code) + try await read([me, server.approved], into: membership) + + #expect(server.approvedCode == code) + #expect(membership.addedDevices.isEmpty) + } + + // MARK: - Seeing a device that was added + + @Test func `should show a device added since this one, once, and not again after a relaunch`() async throws { + let first = try await joined() + let me = try thisDevice(first, addedOn: 1) + let added = device("k2", addedOn: 5) + try await read([me, added], into: first) + + #expect(first.addedDevices == [added]) + first.markShown(added) + #expect(first.addedDevices.isEmpty) + + let relaunched = membership() + try await read([me, added], into: relaunched) + #expect(relaunched.addedDevices.isEmpty) + } + + @Test func `should not show the devices the member had before this one, nor one already removed`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 3) + + try await read([device("k1", addedOn: 1), me, device("k2", addedOn: 5, removedOn: 6)], into: membership) + + #expect(membership.addedDevices.isEmpty) + } + + @Test func `should show a device added during this one's first week once that week ends`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 10) + // In its first week, `/me` lists this device alone. + try await read([me], into: membership) + #expect(membership.addedDevices.isEmpty) + + let added = device("k3", addedOn: 12) + try await read([device("k1", addedOn: 1), me, added], into: membership) + + #expect(membership.addedDevices == [added]) + } + + @Test func `should show a device added while this one was turned off once it is turned on`() async throws { + let membership = try await joined() + let added = device("k2", addedOn: 5) + membership.turnOff() + try await read([thisDevice(membership, addedOn: 1), added], into: membership) + #expect(membership.addedDevices.isEmpty) + + membership.turnOn() + + #expect(membership.addedDevices == [added]) + } + + // MARK: - Removing a device + + @Test func `should offer to delete the days only of another device still in its first week`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 1) + let older = device("k2", addedOn: 5) + let recent = device("k3", addedOn: 17) + try await read([me, older, recent], into: membership) + + #expect(membership.offersDeletingDays(whenRemoving: recent)) + #expect(!membership.offersDeletingDays(whenRemoving: older)) + #expect(!membership.offersDeletingDays(whenRemoving: me)) + } + + @Test func `should never count the device that joined as in its first week`() async throws { + let membership = try await joined() + let joinedDevice = device("k1", addedOn: 18) + let me = try thisDevice(membership, addedOn: 19) + // This device was added a day ago, so it is the one in its first week; the one that joined isn't. + try await read([joinedDevice, me], into: membership) + + #expect(!membership.offersDeletingDays(whenRemoving: joinedDevice)) + } + + @Test func `should remove another device and keep its days`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 1) + let older = device("k2", addedOn: 5) + try await read([me, older], into: membership) + + try await membership.remove(device: older, deletingDays: false) + + #expect(server.removed == ["k2"]) + #expect(server.deletedDays.isEmpty) + #expect(membership.devices.first { $0.publicKey == "k2" }?.removedBy == DeviceRef(publicKey: me.publicKey, label: me.label)) + #expect(membership.isJoined) + } + + @Test func `should remove a device in its first week and then delete its days`() async throws { + let membership = try await joined() + let recent = device("k3", addedOn: 17) + try await read([thisDevice(membership, addedOn: 1), recent], into: membership) + + try await membership.remove(device: recent, deletingDays: true) + + #expect(server.removed == ["k3"]) + #expect(server.deletedDays == [.init(publicKey: "k3", provider: nil, day: nil)]) + } + + @Test func `should count the device as removed when deleting its days fails, and say why`() async throws { + let membership = try await joined() + let recent = device("k3", addedOn: 17) + try await read([thisDevice(membership, addedOn: 1), recent], into: membership) + server.deleteDaysFailure = .unreachable + + await #expect(throws: LeaderboardError.unreachable) { try await membership.remove(device: recent, deletingDays: true) } + + #expect(server.removed == ["k3"]) + #expect(membership.devices.first { $0.publicKey == "k3" }?.isRemoved == true) + } + + @Test func `should forget this device's key only once the server removed it`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 1) + try await read([me, device("k2", addedOn: 5)], into: membership) + + try await membership.remove(device: me) + + #expect(server.removed == [me.publicKey]) + #expect(!membership.isJoined) + #expect(keys.stored == nil && settings.record == nil) + } + + @Test func `should keep this device joined when the server doesn't remove it`() async throws { + let membership = try await joined() + let me = try thisDevice(membership, addedOn: 1) + try await read([me], into: membership) + server.removeFailure = .lastDevice + + await #expect(throws: LeaderboardError.lastDevice) { try await membership.remove(device: me) } + + #expect(membership.isJoined) + #expect(keys.stored != nil) + } + + @Test func `should delete a removed device's days for one provider on one day`() async throws { + let membership = try await joined() + let removed = device("k2", addedOn: 5, removedOn: 9) + try await read([thisDevice(membership, addedOn: 1), removed], into: membership) + + try await membership.deleteDays(of: removed, provider: "claude", day: "2026-10-06") + + #expect(server.deletedDays == [.init(publicKey: "k2", provider: "claude", day: "2026-10-06")]) + } +} diff --git a/Modules/Leaderboard/Tests/LeaderboardFakes.swift b/Modules/Leaderboard/Tests/LeaderboardFakes.swift index ed08100c6..6d10c3ba7 100644 --- a/Modules/Leaderboard/Tests/LeaderboardFakes.swift +++ b/Modules/Leaderboard/Tests/LeaderboardFakes.swift @@ -59,7 +59,7 @@ extension MockLeaderboardAPI { /// A server that accepts every call. static func accepting() -> MockLeaderboardAPI { let api = MockLeaderboardAPI() - given(api).join(username: .any, publicKey: .any).willReturn(()) + given(api).join(username: .any, publicKey: .any, label: .any).willReturn(()) given(api).upload(.any, as: .any).willReturn([]) given(api).update(.any, as: .any).willReturn(()) given(api).leave(as: .any).willReturn(()) @@ -83,10 +83,16 @@ actor CrossingLeaderboardAPI: LeaderboardAPI { await withCheckedContinuation { pending = $0 } } - func join(username: String, publicKey: String) async throws {} + func join(username: String, publicKey: String, label: String) async throws {} func upload(_ days: [DailyTokens], as credentials: MemberCredentials) async throws -> [RefusedDay] { [] } func update(_ change: MemberChange, as credentials: MemberCredentials) async throws {} func leave(as credentials: MemberCredentials) async throws {} func board(period: BoardPeriod, provider: String?) async throws -> [Board.Member] { [] } func globe(period: BoardPeriod) async throws -> GlobeSummary { GlobeSummary(countries: [], present: []) } + func requestDevice(publicKey: String, label: String) async throws -> DeviceAuthorization { throw LeaderboardError.unreachable } + func approval(of key: SigningKey) async throws -> DeviceApproval { .waiting } + func pendingDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> PendingDevice { throw LeaderboardError.unknownCode } + func approveDevice(code: DeviceCode, as credentials: MemberCredentials) async throws -> Device { throw LeaderboardError.unknownCode } + func removeDevice(_ publicKey: String, as credentials: MemberCredentials) async throws {} + func deleteDays(of publicKey: String, provider: String?, day: String?, as credentials: MemberCredentials) async throws {} } diff --git a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift index 7eb9e39bf..b8e811b19 100644 --- a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift @@ -52,21 +52,21 @@ struct LeaderboardHTTPClientTests { // MARK: - Join - @Test func `should ask to join with the name and public key, unsigned`() async throws { + @Test func `should ask to join with the name, public key and this device's label, unsigned`() async throws { var sent: URLRequest? - try await client(status: 201, sent: { sent = $0 }).join(username: "tokenwhale", publicKey: key.publicKey) + try await client(status: 201, sent: { sent = $0 }).join(username: "tokenwhale", publicKey: key.publicKey, label: "MacBook Pro") let body = try JSONSerialization.jsonObject(with: #require(sent?.httpBody)) as? [String: String] #expect(sent?.url?.absoluteString == "https://leaderboard.test/join") #expect(sent?.httpMethod == "POST") - #expect(body == ["username": "tokenwhale", "publicKey": key.publicKey]) + #expect(body == ["username": "tokenwhale", "publicKey": key.publicKey, "label": "MacBook Pro"]) #expect(sent?.value(forHTTPHeaderField: "X-Signature") == nil) } @Test func `should say the name is taken when someone already has it`() async { await #expect(throws: LeaderboardError.usernameTaken) { try await client(status: 409, body: #"{"error":"usernameTaken","message":"That username is taken."}"#) - .join(username: "tokenwhale", publicKey: key.publicKey) + .join(username: "tokenwhale", publicKey: key.publicKey, label: "Mac") } } @@ -129,7 +129,7 @@ struct LeaderboardHTTPClientTests { var sent: [URLRequest] = [] let client = client(body: #"{"standings":[],"countries":[]}"#, sent: { sent.append($0) }) - try await client.join(username: "tokenwhale", publicKey: key.publicKey) + try await client.join(username: "tokenwhale", publicKey: key.publicKey, label: "Mac") _ = try await client.board(period: .sevenDays, provider: nil) _ = try await client.globe(period: .thirtyDays) _ = try await client.upload([], as: member) @@ -249,6 +249,144 @@ struct LeaderboardHTTPClientTests { #expect(try isSigned(#require(sent), by: key)) } + // MARK: - Devices (§2a) + + @Test func `should ask to add this device with its key and label, unsigned, and read the code it shows`() async throws { + var sent: URLRequest? + let body = #"{"code":"WDJBMJHT","expiresIn":600,"interval":5}"# + + let authorization = try await client(status: 201, body: body, sent: { sent = $0 }) + .requestDevice(publicKey: key.publicKey, label: "Mac mini") + + let request = try #require(sent) + let sentBody = try JSONSerialization.jsonObject(with: #require(request.httpBody)) as? [String: String] + #expect(request.url?.path == "/devices") + #expect(request.httpMethod == "POST") + #expect(sentBody == ["publicKey": key.publicKey, "label": "Mac mini"]) + #expect(request.value(forHTTPHeaderField: "X-Signature") == nil) + #expect(authorization == DeviceAuthorization(code: try #require(DeviceCode("WDJB-MJHT")), expiresIn: 600, interval: 5)) + } + + @Test func `should ask whether this device was approved signed with its own key, naming no member`() async throws { + var sent: URLRequest? + + let approval = try await client(status: 202, body: #"{"waiting":true,"expiresIn":540}"#, sent: { sent = $0 }) + .approval(of: key) + + let request = try #require(sent) + #expect(approval == .waiting) + #expect(request.url?.path == "/me") + #expect(request.value(forHTTPHeaderField: "X-Key") == key.publicKey) + #expect(request.value(forHTTPHeaderField: "X-Member") == nil) + #expect(try isSigned(request, by: key)) + } + + @Test func `should read the member this device joined once its code is approved`() async throws { + let body = #"{"username":"tokenwhale","visible":true,"standing":null,"days":[],"devices":[]}"# + + let approval = try await client(body: body).approval(of: key) + + guard case .approved(let summary) = approval else { Issue.record("not approved: \(approval)"); return } + #expect(summary.username == "tokenwhale") + } + + @Test func `should say the code expired when the server no longer knows the waiting key`() async { + await #expect(throws: LeaderboardError.codeExpired) { + _ = try await client(status: 401, body: #"{"error":"unauthorized","message":"expired"}"#).approval(of: key) + } + } + + @Test func `should read what approving a code would add, signed, before approving it`() async throws { + var sent: URLRequest? + let code = try #require(DeviceCode("WDJB-MJHT")) + + let pending = try await client(body: #"{"label":"Mac mini","requestedAt":"2026-10-04T09:30:00Z"}"#, sent: { sent = $0 }) + .pendingDevice(code: code, as: member) + + let request = try #require(sent) + #expect(request.url?.path == "/me/devices/pending/WDJBMJHT") + #expect(request.httpMethod == "GET") + #expect(try isSigned(request, by: key)) + #expect(pending == PendingDevice(label: "Mac mini", requestedAt: Date(timeIntervalSince1970: 1_791_106_200))) + } + + @Test func `should approve a code, signed, and read the device it added`() async throws { + var sent: URLRequest? + let code = try #require(DeviceCode("WDJB-MJHT")) + let body = #"{"publicKey":"newkey","label":"Mac mini","addedAt":"2026-10-04T09:31:00Z"}"# + + let device = try await client(status: 201, body: body, sent: { sent = $0 }).approveDevice(code: code, as: member) + + let request = try #require(sent) + let sentBody = try JSONSerialization.jsonObject(with: #require(request.httpBody)) as? [String: String] + #expect(request.url?.path == "/me/devices") + #expect(request.httpMethod == "POST") + #expect(sentBody == ["code": "WDJBMJHT"]) + #expect(try isSigned(request, by: key)) + #expect(device == Device(publicKey: "newkey", label: "Mac mini", addedAt: Date(timeIntervalSince1970: 1_791_106_260))) + } + + @Test func `should remove a device by its key, signed`() async throws { + var sent: URLRequest? + try await client(sent: { sent = $0 }).removeDevice("k-2_x", as: member) + + #expect(sent?.url?.path == "/me/devices/k-2_x") + #expect(sent?.httpMethod == "DELETE") + #expect(try isSigned(#require(sent), by: key)) + } + + @Test func `should delete a removed device's days, all of them or one provider's on one day`() async throws { + var sent: [URLRequest] = [] + let client = client(sent: { sent.append($0) }) + + try await client.deleteDays(of: "k2", provider: nil, day: nil, as: member) + try await client.deleteDays(of: "k2", provider: "claude", day: "2026-10-06", as: member) + + #expect(sent.map(\.httpMethod) == ["DELETE", "DELETE"]) + #expect(sent[0].url?.path == "/me/devices/k2/days" && sent[0].url?.query == nil) + #expect(sent[1].url?.query == "provider=claude&day=2026-10-06") + #expect(try sent.allSatisfy { try isSigned($0, by: key) }) + } + + @Test func `should encode a provider that holds query characters, and sign the query as sent`() async throws { + var sent: [URLRequest] = [] + let client = client(body: #"{"standings":[]}"#, sent: { sent.append($0) }) + + try await client.deleteDays(of: "k2", provider: "a&day=x#y", day: nil, as: member) + _ = try await client.board(period: .today, provider: "a&b") + + #expect(sent[0].url?.query == "provider=a%26day%3Dx%23y") + #expect(try isSigned(sent[0], by: key)) + #expect(sent[1].url?.query == "period=today&provider=a%26b") + } + + @Test func `should read the member's devices on /me`() async throws { + let body = #"{"username":"tokenwhale","visible":true,"standing":null,"days":[],"devices":[{"publicKey":"k1","label":"MacBook Pro","addedAt":"2026-10-01T08:00:00Z","removedAt":null,"removedBy":null}]}"# + + let summary = try await client(body: body).me(period: .sevenDays, provider: nil, as: member) + + #expect(summary.devices == [Device(publicKey: "k1", label: "MacBook Pro", addedAt: Date(timeIntervalSince1970: 1_790_841_600))]) + } + + @Test(arguments: [ + (404, #"{"error":"unknownCode","message":"No such code."}"#, LeaderboardError.unknownCode), + (409, #"{"error":"deviceLimit","message":"Five devices."}"#, LeaderboardError.deviceLimit), + (403, #"{"error":"deviceTooNew","message":"Too new."}"#, LeaderboardError.deviceTooNew), + (409, #"{"error":"lastDevice","message":"Last one."}"#, LeaderboardError.lastDevice), + ]) + func `should tell each device refusal apart`(status: Int, body: String, error: LeaderboardError) async throws { + let code = try #require(DeviceCode("WDJB-MJHT")) + await #expect(throws: error) { _ = try await client(status: status, body: body).approveDevice(code: code, as: member) } + } + + @Test func `should say which device removed this one when the server refuses its key`() async { + let body = #"{"error":"unauthorized","message":"This device was removed.","removedBy":{"publicKey":"k1","label":"MacBook Pro"}}"# + + await #expect(throws: LeaderboardError.removed(by: "MacBook Pro")) { + _ = try await client(status: 401, body: body).upload([], as: member) + } + } + // MARK: - Failures @Test func `should say the key no longer matches when the server refuses the signature`() async { diff --git a/Modules/Leaderboard/Tests/LeaderboardMembershipTests.swift b/Modules/Leaderboard/Tests/LeaderboardMembershipTests.swift index 0d41a4a3e..9e03f1881 100644 --- a/Modules/Leaderboard/Tests/LeaderboardMembershipTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardMembershipTests.swift @@ -12,7 +12,8 @@ struct LeaderboardMembershipTests { private let logs = FakeTokenLogs(providersWithLogs: ["claude", "codex", "mistral"]) private func membership() -> LeaderboardMembership { - LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, calendar: LeaderboardFixtures.calendar) + LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named("MacBook Pro"), + calendar: LeaderboardFixtures.calendar) } private func joined(sharing: Set = ["claude"]) async throws -> LeaderboardMembership { @@ -37,7 +38,7 @@ struct LeaderboardMembershipTests { @Test func `should leave the person outside, with no key kept, when their name is taken`() async throws { api.reset([.given]) - given(api).join(username: .any, publicKey: .any).willThrow(LeaderboardError.usernameTaken) + given(api).join(username: .any, publicKey: .any, label: .any).willThrow(LeaderboardError.usernameTaken) let membership = membership() await #expect(throws: LeaderboardError.usernameTaken) { @@ -315,7 +316,7 @@ struct LeaderboardMembershipTests { @Test func `should keep a change made here over a /me answer that crossed it`() async throws { let api = CrossingLeaderboardAPI() let membership = LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, - calendar: LeaderboardFixtures.calendar) + machine: MockMachineIdentity.named("MacBook Pro"), calendar: LeaderboardFixtures.calendar) try await membership.join(as: #require(Username("tokenwhale")), sharing: ["claude"]) async let read = membership.summary(period: .sevenDays) diff --git a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift index a63225cee..f68d09a76 100644 --- a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift @@ -15,7 +15,8 @@ struct LeaderboardUploaderTests { private var now = LeaderboardFixtures.date(4, hour: 15) private func membership() -> LeaderboardMembership { - LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, calendar: calendar) + LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named("MacBook Pro"), + calendar: calendar) } private func uploader(_ membership: LeaderboardMembership) -> LeaderboardUploader { @@ -100,6 +101,37 @@ struct LeaderboardUploaderTests { #expect(settings.record == nil) } + @Test func `should forget the membership and say which device removed this one when the server refuses its key for that`() async throws { + let membership = try await joined() + logs.logins = [LoginDays(providerId: "claude", days: [LeaderboardFixtures.stat(day: 4, input: 10)])] + api.reset([.given]) + given(api).upload(.any, as: .any).willThrow(LeaderboardError.removed(by: "Mac mini")) + + await uploader(membership).uploadDue() + + #expect(!membership.isJoined) + #expect(membership.removedBy == "Mac mini") + #expect(keys.stored == nil) + #expect(settings.record == nil) + } + + @Test func `should send the last thirty days once a device added to the member joins`() async throws { + let server = DeviceServer() + server.approvals = [.success(.approved(server.summary))] + let membership = LeaderboardMembership(api: server, keys: keys, settings: settings, logs: logs, + machine: MockMachineIdentity.named("Mac mini"), calendar: calendar) + _ = try await membership.requestToJoin() + _ = try await membership.waitForApproval() + try membership.confirmJoining(sharing: ["claude"]) + logs.logins = [LoginDays(providerId: "claude", days: [LeaderboardFixtures.stat(day: 4, input: 10)])] + + let now = now + await LeaderboardUploader(membership: membership, logs: logs, api: server, calendar: calendar, now: { now }).uploadNow() + + #expect(logs.askedFor == DateRange.last(30, endingOn: now, calendar: calendar)) + #expect(membership.lastUpload == now) + } + @Test func `should count as up to date when there is nothing to send`() async throws { let membership = try await joined() @@ -204,7 +236,7 @@ struct LeaderboardUploaderTests { @Test func `should send everything after joining again, though the days are the same`() async throws { let (membership, uploader, clock) = try await sentOnce(at: now) given(api).leave(as: .any).willReturn(()) - given(api).join(username: .any, publicKey: .any).willReturn(()) + given(api).join(username: .any, publicKey: .any, label: .any).willReturn(()) try await membership.leave() try await membership.join(as: #require(Username("tokenwhale")), sharing: ["claude"]) diff --git a/Modules/Leaderboard/Tests/macOS/MacModelTests.swift b/Modules/Leaderboard/Tests/macOS/MacModelTests.swift new file mode 100644 index 000000000..7f8608cfe --- /dev/null +++ b/Modules/Leaderboard/Tests/macOS/MacModelTests.swift @@ -0,0 +1,28 @@ +#if os(macOS) +import Testing +@testable import Leaderboard + +/// The Mac's model as a device's label (design §6): the product name on Apple +/// silicon, an Intel Mac's family, "Mac" when neither can be read, and never +/// the computer's name, which this reads nothing of. +@Suite +struct MacModelTests { + @Test func `should name an Apple silicon Mac by its product name, without the parenthetical`() { + #expect(MacModel.name(productName: "MacBook Pro (14-inch, 2021)", identifier: "MacBookPro18,3") == "MacBook Pro") + #expect(MacModel.name(productName: "Mac mini (2023)", identifier: "Mac14,3") == "Mac mini") + } + + @Test(arguments: [ + ("MacBookPro16,1", "MacBook Pro"), ("MacBookAir9,1", "MacBook Air"), ("MacBook10,1", "MacBook"), + ("Macmini8,1", "Mac mini"), ("iMac20,1", "iMac"), ("iMacPro1,1", "iMac Pro"), ("MacPro7,1", "Mac Pro"), + ]) + func `should name an Intel Mac by its family`(identifier: String, name: String) { + #expect(MacModel.name(productName: nil, identifier: identifier) == name) + } + + @Test func `should call the Mac "Mac" when neither its product name nor its family can be read`() { + #expect(MacModel.name(productName: nil, identifier: nil) == "Mac") + #expect(MacModel.name(productName: " ", identifier: "VirtualMac2,1") == "Mac") + } +} +#endif diff --git a/Sources/App/Leaderboard/AppLeaderboard.swift b/Sources/App/Leaderboard/AppLeaderboard.swift index aa4e73210..b2a2ca8bb 100644 --- a/Sources/App/Leaderboard/AppLeaderboard.swift +++ b/Sources/App/Leaderboard/AppLeaderboard.swift @@ -32,16 +32,17 @@ final class AppLeaderboard { @ObservationIgnored private var wakeObserver: (any NSObjectProtocol)? @ObservationIgnored private var boards: [Board] = [] - /// The key store is the Mac's Keychain, with the app's fallback: the - /// factory has none only where ClaudeBar keeps no key yet (Windows). + /// The key store and the machine are the Mac's: the factory has none only + /// where ClaudeBar keeps no key and reads no machine yet (Windows). init(monitor: QuotaMonitor, api: any LeaderboardAPI = Leaderboard.makeAPI(client: AppLeaderboard.client), keys: any SigningKeyStore = Leaderboard.makeKeyStore()!, + machine: any MachineIdentity = Leaderboard.makeMachineIdentity()!, settings: any LeaderboardSettingsRepository = JSONSettingsRepository.shared) { let logs = MonitorTokenLogs(monitor: monitor) self.api = api self.logs = logs - let membership = LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs) + let membership = LeaderboardMembership(api: api, keys: keys, settings: settings, logs: logs, machine: machine) self.membership = membership uploader = LeaderboardUploader(membership: membership, logs: logs, api: api) let sevenDays = Board(period: .sevenDays, api: api, membership: membership) diff --git a/Sources/Infrastructure/Storage/JSONSettingsRepository.swift b/Sources/Infrastructure/Storage/JSONSettingsRepository.swift index 227f381fc..5ed550ccd 100644 --- a/Sources/Infrastructure/Storage/JSONSettingsRepository.swift +++ b/Sources/Infrastructure/Storage/JSONSettingsRepository.swift @@ -827,7 +827,8 @@ extension JSONSettingsRepository: LeaderboardSettingsRepository { refused: (store.read(key: "leaderboard.refused") as [[String: String]]? ?? []).compactMap { day in guard let provider = day["provider"], let date = day["day"], let reason = day["reason"] else { return nil } return RefusedDay(provider: provider, day: date, reason: reason) - } + }, + shownDevices: store.read(key: "leaderboard.shownDevices") ?? [] ) } @@ -850,5 +851,6 @@ extension JSONSettingsRepository: LeaderboardSettingsRepository { store.write(value: record?.link?.handle, key: "leaderboard.linkHandle") let refused = record?.refused.map { ["provider": $0.provider, "day": $0.day, "reason": $0.reason] } store.write(value: refused?.isEmpty == false ? refused : nil, key: "leaderboard.refused") + store.write(value: record?.shownDevices.isEmpty == false ? record?.shownDevices : nil, key: "leaderboard.shownDevices") } } diff --git a/Tests/InfrastructureTests/Leaderboard/LeaderboardStorageTests.swift b/Tests/InfrastructureTests/Leaderboard/LeaderboardStorageTests.swift index bdf257dfc..1e4155f8e 100644 --- a/Tests/InfrastructureTests/Leaderboard/LeaderboardStorageTests.swift +++ b/Tests/InfrastructureTests/Leaderboard/LeaderboardStorageTests.swift @@ -49,6 +49,19 @@ struct LeaderboardStorageTests { #expect(relaunched.leaderboardRecord()?.refused == refused) } + @Test func `should remember which added devices were shown after a relaunch, and forget them with the membership`() throws { + let (settings, url) = repository() + settings.saveLeaderboardRecord(LeaderboardRecord(username: "tokenwhale", sharing: ["claude"], visible: true, + lastUpload: nil, shownDevices: ["k3", "k2"])) + + let relaunched = JSONSettingsRepository(store: JSONSettingsStore(fileURL: url), credentials: UserDefaults(suiteName: UUID().uuidString)!, + secureCredentials: KeepingCredentials()) + #expect(relaunched.leaderboardRecord()?.shownDevices == ["k2", "k3"]) + + relaunched.saveLeaderboardRecord(nil) + #expect(try !String(contentsOf: url, encoding: .utf8).contains("k2")) + } + @Test func `should leave nothing of the membership in settings when it is forgotten`() throws { let (settings, url) = repository() settings.saveLeaderboardRecord(LeaderboardRecord(username: "tokenwhale", sharing: ["claude"], visible: true, lastUpload: nil)) diff --git a/codecov.yml b/codecov.yml index cd2faf185..7a523c883 100644 --- a/codecov.yml +++ b/codecov.yml @@ -33,8 +33,9 @@ ignore: - "Modules/DataSources/Sources/Internal/macOS/Network/InsecureLocalhostNetworkClient.swift" # URLSession - "Modules/DataSources/Sources/Internal/macOS/Lookup/SystemBrowserCookies.swift" # browser cookie stores - "Modules/DataSources/Sources/Internal/macOS/Lookup/SystemBrowserStorage.swift" # browser profiles - - "Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift" # hands out the key store below + - "Modules/Leaderboard/Sources/Internal/macOS/Platform+macOS.swift" # hands out the key store and machine below - "Modules/Leaderboard/Sources/Internal/macOS/KeychainSigningKeyStore.swift" # Keychain + - "Modules/Leaderboard/Sources/Internal/macOS/IOKitMachineIdentity.swift" # IOKit; MacModel names what it reads - "Sources/Infrastructure/Hooks/HookHTTPServer.swift" # a local socket server - "Sources/Infrastructure/Shared/SystemAlertSender.swift" # NSAlert, notifications - "Sources/Infrastructure/Shared/SystemPowerStateProvider.swift" # IOKit diff --git a/docs/architecture/MODULAR_DESIGN.md b/docs/architecture/MODULAR_DESIGN.md index 77e4ce9f9..18165ad09 100644 --- a/docs/architecture/MODULAR_DESIGN.md +++ b/docs/architecture/MODULAR_DESIGN.md @@ -69,7 +69,7 @@ let monitor = Monitoring.makeMonitor(providers: catalog.load()) | `Monitoring` | Monitoring · conductor | `QuotaMonitor`, `MonitoringEvent`, `RefreshInterval`, `RefreshKind`, `Clock`, `PowerStateProvider` | `SystemClock`, `SystemPowerStateProvider`, `SingleFlightCache` | | `Alerting` | Alerting | `QuotaAlerter`, Notify! values, `NotifySettingsRepository` | `NotificationAlerter`, `SystemAlertSender`, `NotifyGatewayClient` | | `Activity` | Activity | `ClaudeSession`, `SessionEvent`, `SessionMonitor`, `NotchActivity`, `HookSettingsRepository` | `HookHTTPServer`, `HookInstaller`, `PortDiscovery`, `SessionEventParser` | -| `Leaderboard` | Leaderboard | `LeaderboardMembership`, `DailyTokens`, `Username`, `RequestSigner`, `LeaderboardUploader`, the devices' values, the ports `LeaderboardAPI` · `SigningKeyStore` · `MachineIdentity` · `TokenLogs` ([its design §7](../features/leaderboard/design.md#7--architecture)); the factory `Leaderboard.makeAPI(client:)` · `makeKeyStore()` | `LeaderboardHTTPClient`; `FallbackSigningKeyStore` over `KeychainSigningKeyStore` (macOS) and `DefaultsSigningKeyStore`; `IOKitMachineIdentity` (macOS); Windows' own in `Internal/Windows/` (§10, phase 3) | +| `Leaderboard` | Leaderboard | `LeaderboardMembership`, `DailyTokens`, `Username`, `RequestSigner`, `LeaderboardUploader`, the devices' values (`Device`, `DeviceCode`, `DeviceLabel`), the ports `LeaderboardAPI` · `SigningKeyStore` · `MachineIdentity` · `TokenLogs` ([its design §7](../features/leaderboard/design.md#7--architecture)); the factory `Leaderboard.makeAPI(client:)` · `makeKeyStore()` · `makeMachineIdentity()` | `LeaderboardHTTPClient`; `FallbackSigningKeyStore` over `KeychainSigningKeyStore` (macOS) and `DefaultsSigningKeyStore`; `IOKitMachineIdentity` with `MacModel` (macOS); Windows' own in `Internal/Windows/` (§10, phase 3) | | `Storage` | Vault & Settings · generic | `Storage.makeSettings()`, `Storage.makeVault()`, `AppSettingsRepository` | `JSONSettingsRepository`, `JSONSettingsStore`, `KeychainCredentialRepository`, `UserDefaults…`, `SecureCredentialMigration` | | `Diagnostics` | — cross-cutting | `AppLog` and its categories | the `LogSink`s: `FileLogSink`, `OSLogSink` (macOS) | | `ClaudeBar` (App) | — the composition root | SwiftUI views, themes, menu-bar label, page state, the Add Provider sheet | — | diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index 397e6af35..d0cc5cafc 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -188,7 +188,7 @@ The person thinks of the board as something their app has: what they saw a minut ## 2a · Devices: one member, several machines -**Status:** confirmed ([#512](https://github.com/tddworks/ClaudeBar/pull/512)). The server side, slice 11, is built (`tddworks/claudebar-server` #1); in the app, slice 12 is built ([#522](https://github.com/tddworks/ClaudeBar/pull/522)) and slices 13–15 are not. +**Status:** confirmed ([#512](https://github.com/tddworks/ClaudeBar/pull/512)). The server side, slice 11, is built (`tddworks/claudebar-server` #1); in the app, slices 12 ([#522](https://github.com/tddworks/ClaudeBar/pull/522)) and 13 ([#540](https://github.com/tddworks/ClaudeBar/pull/540)) are built, and 14–15 are not. People code on more than one machine: a MacBook, a Mac mini, a Windows PC. A tool's logs hold only what ran on that machine, so a member's day is the sum of their machines' days. v1 can't express that: the key is per install, a second Mac can't join under a name already taken, and a copy of one Mac's key on another makes their uploads replace each other's days (§9). @@ -343,9 +343,13 @@ leaderboard.turnOff() // tab gone, uploads stop; says leaderboard.turnOn() // tab back; uploads now, from where they stopped // Devices (§2a). The new device shows a code; one the member already has approves it. -let code = try await membership.requestToJoin(label: "Mac mini") // on the new machine; then it waits for approval +let code = try await membership.requestToJoin(label: DeviceLabel("Mac mini")) // on the new machine; the label defaults to its model +let member = try await membership.waitForApproval() // asks /me every 5 seconds until approved; the member it joined +try membership.confirmJoining(sharing: ["claude"]) // the person knows that member: join, then upload 30 days +try await membership.declineJoining() // or not: remove this device, keep nothing let pending = try await membership.pendingDevice(code: code) // on a joined device: the label and when it asked try await membership.approve(code: code) // from a device past its first week +membership.addedDevices // each device added since this one last looked, once try await membership.remove(device: oldMac, deletingDays: false) // revokes its key; its days stay try await membership.remove(device: stranger, deletingDays: true) // another device in its first week: removes, then deletes its days try await membership.deleteDays(of: oldMac, provider: "claude", day: nil) // a removed device's; never a side effect @@ -404,16 +408,16 @@ let days = membership.dailyTokens(from: usageHistories, in: range) | Turning it back on catches up the days missed, up to 30 | `LeaderboardUploader`'s range from `lastUpload`, unchanged; `Leaderboard.turnOn()` asks for it at once | | While off there is no Leaderboard tab, and the popover falls back to its provider | The popover reads `membership.isOn` | | A member has at most 5 devices that aren't removed | Server | -| A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device | +| A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device (`LeaderboardMembership.waitForApproval` and `confirmJoining`) | | For its first 7 days an added device changes and reads only its own days and settings, with the member's settings and place: `/me` leaves out the other devices and their rows, and `/me/export` is refused; it can't rename or hide the member, change its globe or link, approve a device, remove another, delete a removed device's days, or leave. After that, and from the start for the device that joined, it may do what the member may | Server | -| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and the app's dialog | +| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.offersDeletingDays(whenRemoving:)` for the dialog | | Only a removed device's days can be deleted, by another device of the member past its first week, for one provider, one day or all | Server | -| Every device in use past its first week shows, once, each device added since it last read `/me`, with **Remove**; a device in its first week sees only itself | `LeaderboardMembership.devices` | -| A device whose key the server no longer knows forgets its membership | `LeaderboardUploader`, as for a member the server forgot | +| Every device in use past its first week shows, once, each device added since it last read `/me`, with **Remove**; a device in its first week sees only itself | `LeaderboardMembership.addedDevices`, with the devices already shown kept as `shownDevices` | +| A device whose key the server no longer knows forgets its membership; one another device removed also says which | `LeaderboardUploader`, as for a member the server forgot; `LeaderboardMembership.removedBy` | | The member's name, visibility, globe, country and link come from the server; a device's copy follows every `/me` | `LeaderboardMembership` | | A day the server refused is sent again with each upload until it is taken or 30 days old | `LeaderboardMembership.refused`, read by `LeaderboardUploader` | | A key whose `machine` hash isn't this Mac's uploads nothing, and asks until answered: *Make this Mac its own device* or *Keep the key here* | `LeaderboardMembership.holdsCopiedKey` | -| A new device's label is filled in with the Mac's model, never its computer name | `DeviceLabel` | +| A new device's label is filled in with the Mac's model, never its computer name | `MachineIdentity` (`IOKitMachineIdentity` on the Mac, "Mac" when the model can't be read); `DeviceLabel` keeps it, or what the person typed, to the server's 1–40 characters | | Every request names its client in `X-Client`, signed or not; the macOS app as `claudebar-macos/` | `LeaderboardHTTPClient` | | Until a board and you on it have come back once, the tab says it is loading; it never says *0 tokens* or *No one is on the board* for an answer it doesn't have yet | `Board.members`, `nil` until then | | Reading a board again (opening the popover, Refresh, an upload, picking it back) keeps its last answer on screen until the new one replaces it; a failed read keeps it and says it couldn't update | `Board.read()` | @@ -546,19 +550,19 @@ Its code is the `Leaderboard` module of the package that also builds on Windows, | Piece | Home | |---|---| | `LeaderboardMembership`, `Board` and `Board.Member`, `DailyTokens`, `Username`, `RankCard`, `LeaderboardUploader` | `Modules/Leaderboard/Sources/` | -| `@Mockable` ports `LeaderboardAPI` and `SigningKeyStore`, and, with slices 13–15, `MachineIdentity` (this Mac's hardware UUID and model, faked in tests to stand for another Mac); plain `LeaderboardSettingsRepository` (like Notify!'s, now also keeping `refused`, the `machine` hash and the devices already shown) and `@MainActor` `TokenLogs`, faked in tests | `Modules/Leaderboard/Sources/` | -| `Device`, `DeviceCode`, `DeviceLabel` (slices 13–15, not built yet) | `Modules/Leaderboard/Sources/` | -| The factory, `Leaderboard.makeAPI(client:)` and `Leaderboard.makeKeyStore()`: the server, and the key store of the platform the module is built for | `Modules/Leaderboard/Sources/` | +| `@Mockable` ports `LeaderboardAPI`, `SigningKeyStore` and `MachineIdentity` (this Mac's model, and with slice 14 its hardware UUID, faked in tests to stand for another Mac); plain `LeaderboardSettingsRepository` (like Notify!'s, now also keeping `refused`, the devices already shown and, with slice 14, the `machine` hash) and `@MainActor` `TokenLogs`, faked in tests | `Modules/Leaderboard/Sources/` | +| `Device`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | +| The factory, `Leaderboard.makeAPI(client:)`, `makeKeyStore()` and `makeMachineIdentity()`: the server, and the key store and machine of the platform the module is built for | `Modules/Leaderboard/Sources/` | | `LeaderboardHTTPClient`; `FallbackSigningKeyStore`, the key in the Keychain first and in UserDefaults when the Keychain refuses it (`DefaultsSigningKeyStore`) | `Modules/Leaderboard/Sources/Internal/` | | `KeychainSigningKeyStore`, the same Keychain item as before the module | `Modules/Leaderboard/Sources/Internal/macOS/` | -| `IOKitMachineIdentity` (slices 13–15, not built yet): `MachineIdentity` read through IOKit (`IOPlatformUUID`, the model) | `Modules/Leaderboard/Sources/Internal/macOS/` | +| `IOKitMachineIdentity`: `MachineIdentity` read through IOKit (the model; with slice 14, `IOPlatformUUID`) | `Modules/Leaderboard/Sources/Internal/macOS/` | | Settings as `leaderboard.*` in `JSONSettingsRepository` | `Sources/Infrastructure/` | | `AppLeaderboard` (wiring through the factory with the app's `X-Client` name, `board` and `board(period:provider:)`, so a board outlives the popover, the 5-minute check and the wake observer, `refresh()` for the popover's Refresh, `share(_:)` for *Share my rank*, `turnOff()`/`turnOn()` and the one-time `offNotice`), `MonitorTokenLogs`, popover tab, `RankCardImage` (the image, in the member's theme) and `RankShareOverlay`, `TurnOffMenu` (the tab's *Turn off ▾*, drawn in the popover's top layer so the scroll view never clips it), `LeaderboardPane` (with its *Devices* list, *Add a device*, and the notices for a device added, a copied key and a refused day), the join form's *Already a member? Add this Mac* | `Sources/App/` | | Server and board page | Private repo `tddworks/claudebar-server` | ## 8 · Build sequence -Test-first slices, each green on its own. Slices 1–12 and 16 are built, 17 is next: 11 and 16 on the server (`tddworks/claudebar-server` #1, and 16's PR). 13–15, §2a's devices in the app, are not built yet. +Test-first slices, each green on its own. Slices 1–13, 16 and 17 are built: 11 and 16 on the server (`tddworks/claudebar-server` #1, and 16's PR). 14 is next; 14 and 15, the rest of §2a's devices in the app, are not built yet. 1. **`Username` and `DailyTokens`.** Pins the name rule against the shared vectors, and that a `DailyUsageStat` becomes four counts and nothing else. 2. **`LeaderboardMembership` sharing.** Pins: an unticked provider never appears in `dailyTokens`; a provider without usage history can't be shared; two logins of one provider sum into one day. From fbb034145d141c295790027806b09c5dde999e37 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 11 Oct 2026 11:06:01 +0800 Subject: [PATCH 2/6] refactor(leaderboard): device rules on MemberDevices, typed labels, ISO times (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review on #540: - DeviceLabel counts 1–40 in UTF-16 units, as the server's readLabel does, so a label with emoji isn't refused as 400 badLabel. - ServerTime reads only ISO 8601, the server's toISOString(); SQLite and Unix-time forms are refused rather than guessed. - MemberDevices owns which device joined, the first week and what was added since a device; Device.removed(by:at:) marks a removal. - Joining's steps carry their key, interval and summary, so the parallel optionals are gone; the public Joining is unchanged. - Device, DeviceRef and PendingDevice labels are DeviceLabel; removedBy and LeaderboardError.removed(by:) keep the DeviceRef. - design.md: §2 tree and a MemberDevices section, §2a's flow and joining states, §4 owners, §5 times and labels, §7 MachineIdentity. Co-Authored-By: Claude Opus 5.5 --- Modules/Leaderboard/Sources/Device.swift | 63 +++++++------ .../Internal/LeaderboardHTTPClient.swift | 2 +- .../Leaderboard/Sources/LeaderboardAPI.swift | 4 +- .../Sources/LeaderboardMembership.swift | 88 ++++++++----------- .../Sources/LeaderboardUploader.swift | 4 +- .../Leaderboard/Sources/MemberDevices.swift | 51 +++++++++++ Modules/Leaderboard/Tests/DeviceServer.swift | 4 +- Modules/Leaderboard/Tests/DeviceTests.swift | 36 +++++--- .../Tests/LeaderboardDevicesTests.swift | 4 +- .../Tests/LeaderboardHTTPClientTests.swift | 8 +- .../Tests/LeaderboardUploaderTests.swift | 4 +- .../Tests/MemberDevicesTests.swift | 79 +++++++++++++++++ docs/features/leaderboard/design.md | 76 ++++++++++++++-- 13 files changed, 313 insertions(+), 110 deletions(-) create mode 100644 Modules/Leaderboard/Sources/MemberDevices.swift create mode 100644 Modules/Leaderboard/Tests/MemberDevicesTests.swift diff --git a/Modules/Leaderboard/Sources/Device.swift b/Modules/Leaderboard/Sources/Device.swift index 364650001..3533d81db 100644 --- a/Modules/Leaderboard/Sources/Device.swift +++ b/Modules/Leaderboard/Sources/Device.swift @@ -5,13 +5,13 @@ import Foundation public struct Device: Sendable, Equatable, Codable { /// The device's public key, base64url, as `X-Key` names it. public let publicKey: String - public let label: String + public let label: DeviceLabel public let addedAt: Date public let removedAt: Date? /// The device that removed it, `nil` while it's in use. public let removedBy: DeviceRef? - public init(publicKey: String, label: String, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) { + public init(publicKey: String, label: DeviceLabel, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) { self.publicKey = publicKey self.label = label self.addedAt = addedAt @@ -21,18 +21,23 @@ public struct Device: Sendable, Equatable, Codable { public var isRemoved: Bool { removedAt != nil } + /// This device once `by` removed it at `time`, as `/me` will list it. + public func removed(by: DeviceRef, at time: Date) -> Device { + Device(publicKey: publicKey, label: label, addedAt: addedAt, removedAt: time, removedBy: by) + } + private enum CodingKeys: String, CodingKey { case publicKey, label, addedAt, removedAt, removedBy } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) publicKey = try container.decode(String.self, forKey: .publicKey) - label = try container.decode(String.self, forKey: .label) + label = try container.decode(DeviceLabel.self, forKey: .label) addedAt = try ServerTime.decode(from: container, forKey: .addedAt) removedAt = try ServerTime.decodeIfPresent(from: container, forKey: .removedAt) removedBy = try container.decodeIfPresent(DeviceRef.self, forKey: .removedBy) } - /// Written as `ServerTime` reads it, so an exported `/me` answer reads back + /// Written as the server writes it, so an exported `/me` answer reads back /// as the same instants; `JSONEncoder`'s own form counts from 2001. public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -47,9 +52,9 @@ public struct Device: Sendable, Equatable, Codable { /// A device named in passing: the one that removed another. public struct DeviceRef: Sendable, Equatable, Codable { public let publicKey: String - public let label: String + public let label: DeviceLabel - public init(publicKey: String, label: String) { + public init(publicKey: String, label: DeviceLabel) { self.publicKey = publicKey self.label = label } @@ -58,17 +63,32 @@ public struct DeviceRef: Sendable, Equatable, Codable { /// What a device is called on the member's list: the label the person typed /// when it joined or was added, filled in with the machine's model and never /// its computer name, which is often a person's (design §6). The server takes -/// 1–40 characters without control characters. -public struct DeviceLabel: Sendable, Hashable, CustomStringConvertible { +/// 1–40 characters without control characters, counting them as JavaScript +/// does, in UTF-16 units: an emoji can count as several. +public struct DeviceLabel: Sendable, Hashable, Codable, CustomStringConvertible { public let value: String public init?(_ text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard (1...40).contains(trimmed.count), + guard (1...40).contains(trimmed.utf16.count), !trimmed.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { return nil } value = trimmed } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let text = try container.decode(String.self) + guard let label = DeviceLabel(text) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Not a device's label: \(text)") + } + self = label + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } + public var description: String { value } } @@ -93,10 +113,10 @@ public struct DeviceCode: Sendable, Hashable, CustomStringConvertible { /// when, shown above *Approve* so a code read out to a stranger isn't /// approved blind (RFC 8628 §5.4). public struct PendingDevice: Sendable, Equatable, Decodable { - public let label: String + public let label: DeviceLabel public let requestedAt: Date - public init(label: String, requestedAt: Date) { + public init(label: DeviceLabel, requestedAt: Date) { self.label = label self.requestedAt = requestedAt } @@ -105,7 +125,7 @@ public struct PendingDevice: Sendable, Equatable, Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - label = try container.decode(String.self, forKey: .label) + label = try container.decode(DeviceLabel.self, forKey: .label) requestedAt = try ServerTime.decode(from: container, forKey: .requestedAt) } } @@ -132,16 +152,11 @@ public enum DeviceApproval: Sendable, Equatable { case approved(MemberSummary) } -/// A time as the server writes it. §5 doesn't fix the form, so the ones a -/// Worker on D1 is likely to send are read: ISO 8601 with or without -/// fractional seconds, SQLite's `YYYY-MM-DD HH:MM:SS` in UTC, and Unix time in -/// seconds or milliseconds. Anything else fails the answer rather than guess. -/// Written back as ISO 8601 in UTC with milliseconds. +/// A time as the server writes it: JavaScript's `toISOString()`, UTC with +/// milliseconds (§5). Read without the milliseconds too; anything else fails +/// the answer rather than guess. enum ServerTime { static func decode(from container: KeyedDecodingContainer, forKey key: Key) throws -> Date { - if let seconds = try? container.decode(Double.self, forKey: key) { - return Date(timeIntervalSince1970: seconds > 100_000_000_000 ? seconds / 1000 : seconds) - } let text = try container.decode(String.self, forKey: key) guard let date = date(text) else { throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Not a time: \(text)") @@ -162,13 +177,9 @@ enum ServerTime { private static func date(_ text: String) -> Date? { let iso = ISO8601DateFormatter() - if let date = iso.date(from: text) { return date } iso.formatOptions.insert(.withFractionalSeconds) if let date = iso.date(from: text) { return date } - let sqlite = DateFormatter() - sqlite.locale = Locale(identifier: "en_US_POSIX") - sqlite.timeZone = TimeZone(identifier: "UTC") - sqlite.dateFormat = "yyyy-MM-dd HH:mm:ss" - return sqlite.date(from: text) + iso.formatOptions.remove(.withFractionalSeconds) + return iso.date(from: text) } } diff --git a/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift b/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift index eeda3ff4f..f90ce0e90 100644 --- a/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift +++ b/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift @@ -230,7 +230,7 @@ struct LeaderboardHTTPClient: LeaderboardAPI { private static func error(status: Int, data: Data) -> LeaderboardError { let failure = try? JSONDecoder().decode(Failure.self, from: data) switch (status, failure?.error) { - case (401, "unauthorized"), (401, nil): return failure?.removedBy.map { .removed(by: $0.label) } ?? .unauthorized + case (401, "unauthorized"), (401, nil): return failure?.removedBy.map { .removed(by: $0) } ?? .unauthorized case (409, "usernameTaken"), (409, nil): return .usernameTaken case (409, "deviceLimit"): return .deviceLimit case (409, "lastDevice"): return .lastDevice diff --git a/Modules/Leaderboard/Sources/LeaderboardAPI.swift b/Modules/Leaderboard/Sources/LeaderboardAPI.swift index 9798ea212..a1785604d 100644 --- a/Modules/Leaderboard/Sources/LeaderboardAPI.swift +++ b/Modules/Leaderboard/Sources/LeaderboardAPI.swift @@ -198,7 +198,7 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { /// The server refused the signature: the key no longer matches the name. case unauthorized /// Another device of the member removed this one: its key is revoked. - case removed(by: String) + case removed(by: DeviceRef) /// A new device's code expired before a device of the member approved it. case codeExpired /// No code like that is waiting: unknown, expired or used. @@ -219,7 +219,7 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case .nothingShared: "Pick at least one provider to share." case .notJoined: "You haven't joined the leaderboard." case .unauthorized: "The leaderboard didn't accept this Mac's key for your username." - case .removed(let label): "\(label) removed this Mac from your leaderboard devices." + case .removed(let device): "\(device.label) removed this Mac from your leaderboard devices." case .codeExpired: "The code expired before it was approved. Ask for a new one." case .unknownCode: "No device is waiting with that code. Check it, or ask for a new one." case .deviceLimit: "You already have five devices. Remove one to add another." diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index d09cac40e..6851242d9 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -28,20 +28,15 @@ public final class LeaderboardMembership { /// upload until the server takes them or they are 30 days old. public private(set) var refused: [RefusedDay] = [] /// The member's devices as `/me` last listed them: a copy, like the name. - public private(set) var devices: [Device] = [] - /// A new device on its way in: the code it shows, then the member a device - /// of theirs added it to, until the person agrees. `nil` otherwise. - public private(set) var joining: Joining? + public private(set) var devices = MemberDevices() /// The device that removed this one, once its key was refused for that: /// the membership is forgotten, and this says why. - public private(set) var removedBy: String? + public private(set) var removedBy: DeviceRef? private var key: SigningKey? /// Devices already shown as added, by key: each is shown once. private var shownDevices: Set = [] - /// A new device's key and what the server said, while it is joining. - private var joiningKey: SigningKey? - private var joiningInterval: TimeInterval = 5 - private var approvedSummary: MemberSummary? + /// A new device on its way in, with the key it asked with; `nil` otherwise. + private var joiningState: JoiningState? /// Changes made here, counted, so a `/me` answer that crossed one is /// not taken over it. private var changesMadeHere = 0 @@ -54,8 +49,22 @@ public final class LeaderboardMembership { case approved(member: Username) } - /// A device added less than this long ago can't yet act for the member. - static let firstWeek: TimeInterval = 7 * 24 * 60 * 60 + /// `Joining`, with what each step holds: the key the new device asked + /// with, and the server's interval, then the member's `/me` answer. + private enum JoiningState { + case waiting(code: DeviceCode, key: SigningKey, interval: TimeInterval) + case approved(member: Username, key: SigningKey, summary: MemberSummary) + } + + /// A new device on its way in: the code it shows, then the member a device + /// of theirs added it to, until the person agrees. `nil` otherwise. + public var joining: Joining? { + switch joiningState { + case .waiting(let code, _, _): .waiting(code: code) + case .approved(let member, _, _): .approved(member: member) + case nil: nil + } + } @ObservationIgnored private let api: any LeaderboardAPI @ObservationIgnored private let keys: any SigningKeyStore @@ -129,10 +138,7 @@ public final class LeaderboardMembership { public func requestToJoin(label: DeviceLabel? = nil) async throws -> DeviceCode { let key = SigningKey.generate() let authorization = try await api.requestDevice(publicKey: key.publicKey, label: (label ?? suggestedLabel).value) - joiningKey = key - joiningInterval = authorization.interval - approvedSummary = nil - joining = .waiting(code: authorization.code) + joiningState = .waiting(code: authorization.code, key: key, interval: authorization.interval) return authorization.code } @@ -141,7 +147,7 @@ public final class LeaderboardMembership { /// sent until the person agrees (`confirmJoining`): a code that leaked /// could have been approved by someone else. public func waitForApproval() async throws -> Username { - guard case .waiting = joining, let key = joiningKey else { throw LeaderboardError.notJoined } + guard case .waiting(_, let key, let interval) = joiningState else { throw LeaderboardError.notJoined } while true { let approval: DeviceApproval do { @@ -152,13 +158,12 @@ public final class LeaderboardMembership { } switch approval { case .waiting: - try await Task.sleep(for: .seconds(joiningInterval)) + try await Task.sleep(for: .seconds(interval)) case .approved(let summary): guard let member = summary.username.flatMap(Username.init) else { throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") } - approvedSummary = summary - joining = .approved(member: member) + joiningState = .approved(member: member, key: key, summary: summary) return member } } @@ -168,9 +173,7 @@ public final class LeaderboardMembership { /// member, sharing `providers`, with nothing uploaded yet, so its first /// upload sends the last thirty days. public func confirmJoining(sharing providers: Set) throws { - guard case .approved(let member) = joining, let key = joiningKey, let summary = approvedSummary else { - throw LeaderboardError.notJoined - } + guard case .approved(let member, let key, let summary) = joiningState else { throw LeaderboardError.notJoined } guard !providers.isEmpty else { throw LeaderboardError.nothingShared } try requireShareable(providers) keys.save(key.rawRepresentation) @@ -191,21 +194,19 @@ public final class LeaderboardMembership { /// The person doesn't know the member this device was added to: it removes /// itself from that member, and keeps nothing. public func declineJoining() async throws { - guard case .approved(let member) = joining, let key = joiningKey else { throw LeaderboardError.notJoined } + guard case .approved(let member, let key, _) = joiningState else { throw LeaderboardError.notJoined } try await api.removeDevice(key.publicKey, as: MemberCredentials(username: member, key: key)) endJoining() } private func endJoining() { - joining = nil - joiningKey = nil - approvedSummary = nil + joiningState = nil } /// A membership this device starts: no devices known or shown yet, and no /// word of an earlier one's removal. private func startFresh() { - devices = [] + devices = MemberDevices() shownDevices = [] removedBy = nil } @@ -228,9 +229,9 @@ public final class LeaderboardMembership { /// Another device of the member removed this one: its key is revoked, so /// the membership is forgotten here, and `removedBy` says which device did it. - func forgetRemoved(by label: String) { + func forgetRemoved(by device: DeviceRef) { forget() - removedBy = label + removedBy = device } private func forget() { @@ -244,7 +245,7 @@ public final class LeaderboardMembership { sharesCountry = false globeHintDismissed = false link = nil - devices = [] + devices = MemberDevices() shownDevices = [] settings.saveLeaderboardRecord(nil) } @@ -360,7 +361,7 @@ public final class LeaderboardMembership { country = summary.country } if summary.reportsLink { link = summary.link } - devices = summary.devices + devices = MemberDevices(summary.devices) save() } @@ -368,7 +369,7 @@ public final class LeaderboardMembership { /// This device as `/me` lists it, once read. public var thisDevice: Device? { - key.flatMap { key in devices.first { $0.publicKey == key.publicKey } } + key.flatMap { devices.device(withKey: $0.publicKey) } } /// What approving `code` would add, shown above *Approve* so a code read @@ -384,7 +385,7 @@ public final class LeaderboardMembership { guard let credentials else { throw LeaderboardError.notJoined } let device = try await api.approveDevice(code: code, as: credentials) shownDevices.insert(device.publicKey) - if !devices.contains(where: { $0.publicKey == device.publicKey }) { devices.append(device) } + devices = devices.adding(device) save() } @@ -395,9 +396,7 @@ public final class LeaderboardMembership { /// aren't news. public var addedDevices: [Device] { guard isOn, let me = thisDevice else { return [] } - return devices.filter { - !$0.isRemoved && $0.publicKey != me.publicKey && $0.addedAt > me.addedAt && !shownDevices.contains($0.publicKey) - } + return devices.added(since: me).filter { !shownDevices.contains($0.publicKey) } } /// `device` was shown as added, so it isn't again. @@ -410,7 +409,7 @@ public final class LeaderboardMembership { /// *Remove*: only for another device still in its first week, which a /// stranger's device approved by mistake would be. public func offersDeletingDays(whenRemoving device: Device) -> Bool { - device.publicKey != thisDevice?.publicKey && isInFirstWeek(device) + device.publicKey != thisDevice?.publicKey && devices.isInFirstWeek(device, at: now()) } /// Removes `device`: its key is revoked, and its days stay unless @@ -424,12 +423,8 @@ public final class LeaderboardMembership { forget() return } - let by = DeviceRef(publicKey: credentials.key.publicKey, label: thisDevice?.label ?? suggestedLabel.value) - devices = devices.map { - $0.publicKey == device.publicKey - ? Device(publicKey: $0.publicKey, label: $0.label, addedAt: $0.addedAt, removedAt: now(), removedBy: by) - : $0 - } + let by = DeviceRef(publicKey: credentials.key.publicKey, label: thisDevice?.label ?? suggestedLabel) + devices = devices.removing(device, by: by, at: now()) changesMadeHere += 1 if deletingDays { try await api.deleteDays(of: device.publicKey, provider: nil, day: nil, as: credentials) @@ -443,13 +438,6 @@ public final class LeaderboardMembership { try await api.deleteDays(of: device.publicKey, provider: provider, day: day, as: credentials) } - /// Added less than a week ago, and not the device that joined, which is - /// never held to a first week: that one is the member's earliest. - private func isInFirstWeek(_ device: Device) -> Bool { - let joined = devices.min { $0.addedAt < $1.addedAt } - return device.publicKey != joined?.publicKey && now().timeIntervalSince(device.addedAt) < Self.firstWeek - } - // MARK: - Private private func requireShareable(_ providers: Set) throws { diff --git a/Modules/Leaderboard/Sources/LeaderboardUploader.swift b/Modules/Leaderboard/Sources/LeaderboardUploader.swift index 643c4d3f5..1ff019529 100644 --- a/Modules/Leaderboard/Sources/LeaderboardUploader.swift +++ b/Modules/Leaderboard/Sources/LeaderboardUploader.swift @@ -84,8 +84,8 @@ public final class LeaderboardUploader { membership.forgetUnknownMember() lastError = nil AppLog.network.info("Leaderboard no longer knows this member; forgot the membership here") - } catch LeaderboardError.removed(let label) { - membership.forgetRemoved(by: label) + } catch LeaderboardError.removed(let device) { + membership.forgetRemoved(by: device) lastError = nil AppLog.network.info("Another device of this member removed this one; forgot the membership here") } catch { diff --git a/Modules/Leaderboard/Sources/MemberDevices.swift b/Modules/Leaderboard/Sources/MemberDevices.swift new file mode 100644 index 000000000..c2633c81e --- /dev/null +++ b/Modules/Leaderboard/Sources/MemberDevices.swift @@ -0,0 +1,51 @@ +import Foundation + +/// *DEVICES* (design §2a): a member's devices as `/me` last listed them, +/// removed ones included. It owns the rules about them: which one joined, who +/// is still in their first week, and which were added since a device. A device +/// in its own first week reads only itself, so it asks these about others only. +public struct MemberDevices: Sendable, Equatable, RandomAccessCollection { + /// A device added less than this long ago can't yet act for the member. + static let firstWeek: TimeInterval = 7 * 24 * 60 * 60 + + private let devices: [Device] + + public init(_ devices: [Device] = []) { + self.devices = devices + } + + public var startIndex: Int { devices.startIndex } + public var endIndex: Int { devices.endIndex } + public subscript(position: Int) -> Device { devices[position] } + + /// The device that joined: the member's earliest, since every other one + /// was added after it. Removed or not, `/me` still lists it. + public var joined: Device? { + devices.min { $0.addedAt < $1.addedAt } + } + + public func device(withKey publicKey: String) -> Device? { + devices.first { $0.publicKey == publicKey } + } + + /// Added less than a week before `now`. The device that joined is the + /// member, so it is never held to a first week. + public func isInFirstWeek(_ device: Device, at now: Date) -> Bool { + device.publicKey != joined?.publicKey && now.timeIntervalSince(device.addedAt) < Self.firstWeek + } + + /// The devices still in use that were added after `device`: news to it. + public func added(since device: Device) -> [Device] { + devices.filter { !$0.isRemoved && $0.publicKey != device.publicKey && $0.addedAt > device.addedAt } + } + + /// These devices once `by` removed `device` at `time`. + public func removing(_ device: Device, by: DeviceRef, at time: Date) -> MemberDevices { + MemberDevices(devices.map { $0.publicKey == device.publicKey ? $0.removed(by: by, at: time) : $0 }) + } + + /// These devices with `device`, unless it is already listed. + public func adding(_ device: Device) -> MemberDevices { + self.device(withKey: device.publicKey) == nil ? MemberDevices(devices + [device]) : self + } +} diff --git a/Modules/Leaderboard/Tests/DeviceServer.swift b/Modules/Leaderboard/Tests/DeviceServer.swift index 590315d19..5145d7d1f 100644 --- a/Modules/Leaderboard/Tests/DeviceServer.swift +++ b/Modules/Leaderboard/Tests/DeviceServer.swift @@ -15,8 +15,8 @@ final class DeviceServer: LeaderboardAPI, @unchecked Sendable { var authorization = DeviceAuthorization(code: DeviceCode("WDJBMJHT")!, expiresIn: 600, interval: 0) /// `GET /me` from a waiting key, answered in turn; the last answer repeats. var approvals: [Result] = [] - var pending = PendingDevice(label: "Mac mini", requestedAt: Date(timeIntervalSince1970: 1_791_106_200)) - var approved = Device(publicKey: "newkey", label: "Mac mini", addedAt: Date(timeIntervalSince1970: 1_791_106_260)) + var pending = PendingDevice(label: DeviceLabel("Mac mini")!, requestedAt: Date(timeIntervalSince1970: 1_791_106_200)) + var approved = Device(publicKey: "newkey", label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260)) var removeFailure: LeaderboardError? var deleteDaysFailure: LeaderboardError? diff --git a/Modules/Leaderboard/Tests/DeviceTests.swift b/Modules/Leaderboard/Tests/DeviceTests.swift index 83ce3a83d..68a6a52e9 100644 --- a/Modules/Leaderboard/Tests/DeviceTests.swift +++ b/Modules/Leaderboard/Tests/DeviceTests.swift @@ -20,6 +20,15 @@ struct DeviceTests { #expect(DeviceLabel(String(repeating: "a", count: 40)) != nil) } + /// The server counts a label's characters as JavaScript does, in UTF-16 + /// units: a family emoji is one character to the person and 11 to it. + @Test func `should count a label's characters as the server does, so an emoji counts as more than one`() { + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}" + + #expect(DeviceLabel(String(repeating: "a", count: 29) + family) != nil) + #expect(DeviceLabel(String(repeating: "a", count: 30) + family) == nil) + } + // MARK: - The code @Test(arguments: ["WDJB-MJHT", "wdjbmjht", "wdjb mjht", " WDJB-mjht "]) @@ -48,19 +57,17 @@ struct DeviceTests { let devices = try JSONDecoder().decode([Device].self, from: Data(json.utf8)) #expect(devices[0].publicKey == "k1") - #expect(devices[0].label == "MacBook Pro") + #expect(devices[0].label == DeviceLabel("MacBook Pro")) #expect(devices[0].addedAt == Date(timeIntervalSince1970: 1_790_841_600)) #expect(devices[0].removedAt == nil && devices[0].removedBy == nil) #expect(devices[1].addedAt == Date(timeIntervalSince1970: 1_791_014_400.25)) #expect(devices[1].removedAt == Date(timeIntervalSince1970: 1_791_106_200)) - #expect(devices[1].removedBy == DeviceRef(publicKey: "k1", label: "MacBook Pro")) + #expect(devices[1].removedBy == DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!)) } - /// §5 doesn't say how the server writes a time, so the forms a Worker - /// on D1 is likely to send all read as the same instant. - @Test(arguments: [#""2026-10-01T08:00:00Z""#, #""2026-10-01T08:00:00.000Z""#, #""2026-10-01 08:00:00""#, - "1790841600", "1790841600000"]) - func `should read a time however the server writes it`(written: String) throws { + /// The server writes every time as JavaScript's `toISOString()` (§5). + @Test(arguments: [#""2026-10-01T08:00:00.000Z""#, #""2026-10-01T08:00:00Z""#]) + func `should read a time as the server writes it`(written: String) throws { let json = #"{"publicKey":"k1","label":"Mac","addedAt":"# + written + "}" let device = try JSONDecoder().decode(Device.self, from: Data(json.utf8)) @@ -68,19 +75,26 @@ struct DeviceTests { #expect(device.addedAt == Date(timeIntervalSince1970: 1_790_841_600)) } - @Test func `should refuse a device whose time it can't read, rather than guess one`() { - let json = #"{"publicKey":"k1","label":"Mac","addedAt":"last Tuesday"}"# + @Test(arguments: [#""last Tuesday""#, #""2026-10-01 08:00:00""#, "1790841600", "1790841600000"]) + func `should refuse a time the server never writes, rather than guess one`(written: String) { + let json = #"{"publicKey":"k1","label":"Mac","addedAt":"# + written + "}" + + #expect(throws: DecodingError.self) { try JSONDecoder().decode(Device.self, from: Data(json.utf8)) } + } + + @Test func `should refuse a device whose label the server would never send`() { + let json = #"{"publicKey":"k1","label":"","addedAt":"2026-10-01T08:00:00.000Z"}"# #expect(throws: DecodingError.self) { try JSONDecoder().decode(Device.self, from: Data(json.utf8)) } } /// Settings' *Export…* writes `/me`'s answer with a plain `JSONEncoder`; read /// back, every device keeps the times the server gave it. - @Test func `should write a member's devices so their times read back as the same instants`() throws { + @Test func `should write a member's devices so they read back as the server gave them`() throws { let json = #""" {"username":"tokenwhale","visible":true,"standing":null,"days":[],"devices":[ {"publicKey":"k1","label":"MacBook Pro","addedAt":"2026-10-01T08:00:00Z","removedAt":null,"removedBy":null}, - {"publicKey":"k2","label":"Mac mini","addedAt":1791014400123,"removedAt":"2026-10-04 09:30:00", + {"publicKey":"k2","label":"Mac mini","addedAt":"2026-10-03T08:00:00.123Z","removedAt":"2026-10-04T09:30:00.000Z", "removedBy":{"publicKey":"k1","label":"MacBook Pro"}}]} """# let summary = try JSONDecoder().decode(MemberSummary.self, from: Data(json.utf8)) diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift index 95b62d832..b63e3c6ae 100644 --- a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -33,7 +33,7 @@ struct LeaderboardDevicesTests { /// This device, as `/me` lists it once it reads it. private func thisDevice(_ membership: LeaderboardMembership, addedOn day: Int) throws -> Device { let key = try SigningKey(rawRepresentation: #require(keys.stored)).publicKey - return Device(publicKey: key, label: "MacBook Pro", addedAt: LeaderboardFixtures.date(day)) + return Device(publicKey: key, label: DeviceLabel("MacBook Pro")!, addedAt: LeaderboardFixtures.date(day)) } /// `/me` lists `devices`, and this device reads it. @@ -43,7 +43,7 @@ struct LeaderboardDevicesTests { } private func device(_ key: String, addedOn day: Int, removedOn removed: Int? = nil) -> Device { - Device(publicKey: key, label: "Mac \(key)", addedAt: LeaderboardFixtures.date(day), + Device(publicKey: key, label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), removedAt: removed.map { LeaderboardFixtures.date($0) }) } diff --git a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift index b8e811b19..acd68c51f 100644 --- a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift @@ -307,7 +307,7 @@ struct LeaderboardHTTPClientTests { #expect(request.url?.path == "/me/devices/pending/WDJBMJHT") #expect(request.httpMethod == "GET") #expect(try isSigned(request, by: key)) - #expect(pending == PendingDevice(label: "Mac mini", requestedAt: Date(timeIntervalSince1970: 1_791_106_200))) + #expect(pending == PendingDevice(label: DeviceLabel("Mac mini")!, requestedAt: Date(timeIntervalSince1970: 1_791_106_200))) } @Test func `should approve a code, signed, and read the device it added`() async throws { @@ -323,7 +323,7 @@ struct LeaderboardHTTPClientTests { #expect(request.httpMethod == "POST") #expect(sentBody == ["code": "WDJBMJHT"]) #expect(try isSigned(request, by: key)) - #expect(device == Device(publicKey: "newkey", label: "Mac mini", addedAt: Date(timeIntervalSince1970: 1_791_106_260))) + #expect(device == Device(publicKey: "newkey", label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260))) } @Test func `should remove a device by its key, signed`() async throws { @@ -365,7 +365,7 @@ struct LeaderboardHTTPClientTests { let summary = try await client(body: body).me(period: .sevenDays, provider: nil, as: member) - #expect(summary.devices == [Device(publicKey: "k1", label: "MacBook Pro", addedAt: Date(timeIntervalSince1970: 1_790_841_600))]) + #expect(summary.devices == [Device(publicKey: "k1", label: DeviceLabel("MacBook Pro")!, addedAt: Date(timeIntervalSince1970: 1_790_841_600))]) } @Test(arguments: [ @@ -382,7 +382,7 @@ struct LeaderboardHTTPClientTests { @Test func `should say which device removed this one when the server refuses its key`() async { let body = #"{"error":"unauthorized","message":"This device was removed.","removedBy":{"publicKey":"k1","label":"MacBook Pro"}}"# - await #expect(throws: LeaderboardError.removed(by: "MacBook Pro")) { + await #expect(throws: LeaderboardError.removed(by: DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!))) { _ = try await client(status: 401, body: body).upload([], as: member) } } diff --git a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift index f68d09a76..c9e708786 100644 --- a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift @@ -105,12 +105,12 @@ struct LeaderboardUploaderTests { let membership = try await joined() logs.logins = [LoginDays(providerId: "claude", days: [LeaderboardFixtures.stat(day: 4, input: 10)])] api.reset([.given]) - given(api).upload(.any, as: .any).willThrow(LeaderboardError.removed(by: "Mac mini")) + given(api).upload(.any, as: .any).willThrow(LeaderboardError.removed(by: DeviceRef(publicKey: "k2", label: DeviceLabel("Mac mini")!))) await uploader(membership).uploadDue() #expect(!membership.isJoined) - #expect(membership.removedBy == "Mac mini") + #expect(membership.removedBy == DeviceRef(publicKey: "k2", label: DeviceLabel("Mac mini")!)) #expect(keys.stored == nil) #expect(settings.record == nil) } diff --git a/Modules/Leaderboard/Tests/MemberDevicesTests.swift b/Modules/Leaderboard/Tests/MemberDevicesTests.swift new file mode 100644 index 000000000..2ae2180b2 --- /dev/null +++ b/Modules/Leaderboard/Tests/MemberDevicesTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import Leaderboard + +/// A member's devices as `/me` lists them (design §2a): the one that joined, +/// who is in their first week, which were added since a device, and removing one. +@Suite +struct MemberDevicesTests { + /// Day 20: devices added on day 13 or before are past their first week. + private let today = LeaderboardFixtures.date(20) + + private func device(_ key: String, addedOn day: Int, removedOn removed: Int? = nil) -> Device { + Device(publicKey: key, label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), + removedAt: removed.map { LeaderboardFixtures.date($0) }) + } + + // MARK: - The one that joined + + @Test func `should take the earliest device as the one that joined, though it was removed`() { + let devices = MemberDevices([device("k2", addedOn: 5), device("k1", addedOn: 1, removedOn: 3)]) + + #expect(devices.joined?.publicKey == "k1") + } + + // MARK: - The first week + + @Test func `should hold a device added less than a week ago to its first week`() { + let recent = device("k2", addedOn: 17) + let devices = MemberDevices([device("k1", addedOn: 1), recent]) + + #expect(devices.isInFirstWeek(recent, at: today)) + } + + @Test func `should let a device out of its first week once a week has passed`() { + let older = device("k2", addedOn: 13) + let devices = MemberDevices([device("k1", addedOn: 1), older]) + + #expect(!devices.isInFirstWeek(older, at: today)) + } + + @Test func `should never hold the device that joined to a first week`() { + let joined = device("k1", addedOn: 18) + let devices = MemberDevices([joined, device("k2", addedOn: 19)]) + + #expect(!devices.isInFirstWeek(joined, at: today)) + } + + // MARK: - Added since a device + + @Test func `should list the devices in use added after a device`() { + let me = device("k2", addedOn: 3) + let added = device("k3", addedOn: 5) + let devices = MemberDevices([device("k1", addedOn: 1), me, added, device("k4", addedOn: 6, removedOn: 7)]) + + #expect(devices.added(since: me) == [added]) + } + + // MARK: - Changing the list + + @Test func `should say which device removed one, and when`() { + let older = device("k2", addedOn: 5) + let by = DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!) + + let devices = MemberDevices([device("k1", addedOn: 1), older]).removing(older, by: by, at: today) + + let removed = devices.device(withKey: "k2") + #expect(removed?.removedAt == today) + #expect(removed?.removedBy == by) + #expect(devices.device(withKey: "k1")?.isRemoved == false) + } + + @Test func `should add a device once, however often it is added`() { + let new = device("k2", addedOn: 19) + + let devices = MemberDevices([device("k1", addedOn: 1)]).adding(new).adding(new) + + #expect(devices.map(\.publicKey) == ["k1", "k2"]) + } +} diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index d0cc5cafc..9d8fea184 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -87,7 +87,8 @@ Leaderboard (app) ClaudeBar's side of the leaderboard ( │ │ ├─ isVisible : Bool hidden members still see their own place │ │ ├─ sharesCountry : Bool country : String? where the globe puts you, when you opted in │ │ ├─ link : ProfileLink? - │ │ └─ devices : [Device] IN ITS FIRST WEEK ONLY THIS ONE. NOT BUILT IN THE APP YET + │ │ └─ devices : MemberDevices IN ITS FIRST WEEK ONLY THIS ONE. OWNS: WHICH JOINED, WHO IS IN THEIR FIRST WEEK, WHICH WERE ADDED SINCE A DEVICE + │ ├─ joining : Joining? A NEW DEVICE ON ITS WAY IN: waiting(code) → approved(member); NOTHING KEPT UNTIL THE PERSON AGREES │ └─ this device's own NEVER SENT, NEVER FOLLOWS /me │ ├─ key : SigningKey PRIVATE HALF NEVER LEAVES THE DEVICE; ITS PUBLIC HALF IS THIS DEVICE ON THE SERVER │ ├─ sharing : Set ONLY PROVIDERS WITH USAGE HISTORY @@ -166,10 +167,22 @@ The person thinks of the board as something their app has: what they saw a minut | **Owns: only shareable providers can be shared** | `share(_:)` refuses a provider with no usage history; the ability is absent, not ignored | | **Owns: a turned-off Leaderboard uploads nothing** | `uploadCredentials` is `nil` while off, so the uploader has nothing to sign with and `lastUpload` stays where uploads stopped | | **Owns: a copied key it detects never uploads unasked** | at launch, a key whose `machine` hash isn't this Mac's uploads nothing until the member answers: this Mac gets its own key, or keeps this one and records its hash ([§2a](#2a--devices-one-member-several-machines)); every key made here is recorded with this Mac's hash, and forgetting a key forgets its hash | -| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` · `pendingDevice(code:)` · `approve(code:)` · `remove(device:deletingDays:)` · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | -| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | +| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` · `waitForApproval()` · `confirmJoining(sharing:)` · `declineJoining()` · `pendingDevice(code:)` · `approve(code:)` · `markShown(_:)` · `remove(device:deletingDays:)` · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | +| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `joining` · `addedDevices` · `offersDeletingDays(whenRemoving:)` · `removedBy` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | | **Never** | holds a ranking · sends a provider it was not told to share · forgets its key before the server confirmed the leave or the removal · keeps a member setting the server has changed since | +### `MemberDevices`: the member's devices, as `/me` last listed them + +> **Pointable as:** the *Devices* list in Settings: each Mac, when it was added, and who removed it. + +| | | +|---|---| +| **Owns: the device that joined is never held to a first week** | `joined` is the earliest device, removed or not: every other one was added after it, and `/me` lists removed devices too | +| **Owns: an added device is in its first week for 7 days** | `isInFirstWeek(_:at:)`; a device in its own first week reads only itself, so it asks this about others only | +| **Owns: what is news to a device** | `added(since:)`: the devices still in use added after it | +| **Tell it** | `removing(_:by:at:)` (a device's removal, as `/me` will list it) · `adding(_:)` (a device just approved, once) | +| **Never** | decides what was already shown: that is this device's own, `LeaderboardMembership.shownDevices` | + ### `DailyTokens`: one provider's day > **Pointable as:** a line on a tally sheet: provider, date, four counts. @@ -217,6 +230,49 @@ This is RFC 8628's device flow, the one GitHub's and Microsoft's sign-ins use. K - Once approved, it names the member it joined (`@name`) and asks before anything is sent: a code that leaked, in a screenshot say, could have been approved by someone else. Then it ticks what it shares and uploads its last 30 days, as a join does. 4. **Every other device in use past its first week** shows each device added since it last looked, once, with its label and **Remove** beside it. It looks on `/me`, which it reads after each upload and when the Leaderboard tab opens. A device that's turned off sees it when turned back on, and one in its own first week sees it once that week ends: until then `/me` shows it only itself. Telegram asks a member's other sessions the same about a new login ([core.telegram.org/api/auth](https://core.telegram.org/api/auth)). +Each call, as the server answers it: + +``` + New Mac (no member yet) Server Member's Mac + ─────────────────────── ────── ──────────── + requestToJoin(label) + │ POST /devices {publicKey, label} + ├─────────────────────────────────▶ label: 1–40 UTF-16 units, no control characters + │◀── 201 {code, expiresIn: 600, interval: 5} + │ + waitForApproval() every 5 s + │ GET /me X-Key, no X-Member + ├─────────────────────────────────▶ + │◀── 202 {waiting, expiresIn} pendingDevice(code) + │ ◀──────────── GET /me/devices/pending/WDJB-MJHT + │ ────────────▶ 200 {label, requestedAt} + │ approve(code) + │ ◀──────────── POST /me/devices {code} + │ ────────────▶ 201 {publicKey, label, addedAt} + │ GET /me + ├─────────────────────────────────▶ + │◀── 200 the member (only itself: its first week) + │ + ├─ confirmJoining(sharing:) ─▶ keeps the key; first upload sends 30 days + └─ declineJoining() ─────────▶ DELETE /me/devices/; keeps nothing + + code expired ─▶ 401 ─▶ codeExpired +``` + +On the new machine, `LeaderboardMembership.joining` is where it stands. Each step holds what the next needs, so a step without it can't be reached: + +``` + requestToJoin 200 approved confirmJoining + nil ─────────────────▶ waiting(code, ─────────────────▶ approved(member, ─────────────▶ joined + key, interval) key, summary) + │ ▲ │ + └───┘ 202: wait `interval` └─ declineJoining ─▶ nil + │ + └─ 401 ─▶ codeExpired ─▶ nil +``` + +The key lives only in `joining` until the person agrees; nothing is written to the Keychain or `settings.json` before. + **Limits.** - Approving, and reading a pending code, are limited to 10 a minute per member (Cloudflare's limiter counts only 10 s or 60 s periods; at that rate a live code can't be found among 20⁸ in its 10 minutes); asking for codes, per IP, as `POST /join` is. - A member has at most 5 devices that aren't removed (Signal links 5, WhatsApp 4). A sixth approve is refused (`409 deviceLimit`). @@ -410,14 +466,14 @@ let days = membership.dailyTokens(from: usageHistories, in: range) | A member has at most 5 devices that aren't removed | Server | | A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device (`LeaderboardMembership.waitForApproval` and `confirmJoining`) | | For its first 7 days an added device changes and reads only its own days and settings, with the member's settings and place: `/me` leaves out the other devices and their rows, and `/me/export` is refused; it can't rename or hide the member, change its globe or link, approve a device, remove another, delete a removed device's days, or leave. After that, and from the start for the device that joined, it may do what the member may | Server | -| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.offersDeletingDays(whenRemoving:)` for the dialog | +| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.offersDeletingDays(whenRemoving:)` for the dialog, from `MemberDevices.isInFirstWeek(_:at:)` | | Only a removed device's days can be deleted, by another device of the member past its first week, for one provider, one day or all | Server | -| Every device in use past its first week shows, once, each device added since it last read `/me`, with **Remove**; a device in its first week sees only itself | `LeaderboardMembership.addedDevices`, with the devices already shown kept as `shownDevices` | -| A device whose key the server no longer knows forgets its membership; one another device removed also says which | `LeaderboardUploader`, as for a member the server forgot; `LeaderboardMembership.removedBy` | +| Every device in use past its first week shows, once, each device added since it last read `/me`, with **Remove**; a device in its first week sees only itself | `LeaderboardMembership.addedDevices`: `MemberDevices.added(since:)` this device, less the ones kept as `shownDevices` | +| A device whose key the server no longer knows forgets its membership; one another device removed also says which | `LeaderboardUploader`, as for a member the server forgot; `LeaderboardMembership.removedBy`, the `DeviceRef` the server named | | The member's name, visibility, globe, country and link come from the server; a device's copy follows every `/me` | `LeaderboardMembership` | | A day the server refused is sent again with each upload until it is taken or 30 days old | `LeaderboardMembership.refused`, read by `LeaderboardUploader` | | A key whose `machine` hash isn't this Mac's uploads nothing, and asks until answered: *Make this Mac its own device* or *Keep the key here* | `LeaderboardMembership.holdsCopiedKey` | -| A new device's label is filled in with the Mac's model, never its computer name | `MachineIdentity` (`IOKitMachineIdentity` on the Mac, "Mac" when the model can't be read); `DeviceLabel` keeps it, or what the person typed, to the server's 1–40 characters | +| A new device's label is filled in with the Mac's model, never its computer name | `MachineIdentity` (`IOKitMachineIdentity` on the Mac, "Mac" when the model can't be read); `DeviceLabel` keeps it, or what the person typed, to the server's 1–40 characters, counted in UTF-16 units as the server counts them | | Every request names its client in `X-Client`, signed or not; the macOS app as `claudebar-macos/` | `LeaderboardHTTPClient` | | Until a board and you on it have come back once, the tab says it is loading; it never says *0 tokens* or *No one is on the board* for an answer it doesn't have yet | `Board.members`, `nil` until then | | Reading a board again (opening the popover, Refresh, an upload, picking it back) keeps its last answer on screen until the new one replaces it; a failed read keeps it and says it couldn't update | `Board.read()` | @@ -444,6 +500,8 @@ Host: `https://claudebar-api.tddworks.com`; the public board page is `https://cl | `GET /globe?period=30d` | none | every country opted-in members share; members and tokens (`countries`) only where at least 3 are, the rest named without a number (`present`, A–Z) | | `GET /board?period=7d&provider=claude` | none | standings of visible members, up to 100, a member's days summed over their devices, each in its own periods, cached at the edge for two minutes, dropped from the uploader's data centre by each `PUT /usage`; the app reads it without its local HTTP cache | +**Times and labels.** Every time the server sends (`addedAt`, `removedAt`, `requestedAt`) is JavaScript's `toISOString()`: UTC with milliseconds, `2026-10-10T12:34:56.789Z`. The app reads that form, and without the milliseconds; it refuses any other rather than guess. A label is trimmed, then 1–40 characters counted as JavaScript counts them, in UTF-16 units (a family emoji is 11), with no control character. + **Signing.** On join, or when it asks to be added, a device makes a `Curve25519.Signing.PrivateKey` (`Crypto`, which is CryptoKit on Apple platforms) and sends its public half. Every signed request carries: ``` @@ -525,10 +583,12 @@ Its code is the `Leaderboard` module of the package that also builds on Windows, │ ├─ @Mockable ports ──────────────┤ │ │ │ LeaderboardAPI │ │ │ │ SigningKeyStore │ │ +│ │ MachineIdentity │ │ │ │ LeaderboardSettingsRepository │ │ │ └───────────────┬────────────────┘ │ │ Modules/Leaderboard/Sources/Internal │ │ │ LeaderboardHTTPClient (NetworkClient) · KeychainSigningKeyStore (+ UserDefaults fallback) │ +│ macOS/IOKitMachineIdentity: the Mac's model from the I/O Registry │ │ JSONSettingsRepository: leaderboard.* keys in ~/.claudebar/settings.json │ └───────────────────────────────────────────────────────────────┼──────────────────────────────────────────┘ HTTPS, Ed25519-signed │ @@ -551,7 +611,7 @@ Its code is the `Leaderboard` module of the package that also builds on Windows, |---|---| | `LeaderboardMembership`, `Board` and `Board.Member`, `DailyTokens`, `Username`, `RankCard`, `LeaderboardUploader` | `Modules/Leaderboard/Sources/` | | `@Mockable` ports `LeaderboardAPI`, `SigningKeyStore` and `MachineIdentity` (this Mac's model, and with slice 14 its hardware UUID, faked in tests to stand for another Mac); plain `LeaderboardSettingsRepository` (like Notify!'s, now also keeping `refused`, the devices already shown and, with slice 14, the `machine` hash) and `@MainActor` `TokenLogs`, faked in tests | `Modules/Leaderboard/Sources/` | -| `Device`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | +| `Device`, `MemberDevices`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | | The factory, `Leaderboard.makeAPI(client:)`, `makeKeyStore()` and `makeMachineIdentity()`: the server, and the key store and machine of the platform the module is built for | `Modules/Leaderboard/Sources/` | | `LeaderboardHTTPClient`; `FallbackSigningKeyStore`, the key in the Keychain first and in UserDefaults when the Keychain refuses it (`DefaultsSigningKeyStore`) | `Modules/Leaderboard/Sources/Internal/` | | `KeychainSigningKeyStore`, the same Keychain item as before the module | `Modules/Leaderboard/Sources/Internal/macOS/` | From 4067a48591a81add3929430a757b48a9e353e287 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 11 Oct 2026 11:15:16 +0800 Subject: [PATCH 3/6] refactor(leaderboard): DeviceKey for a device's identity, and an injected wait (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the #540 review's point 3 and its small things: - DeviceKey names a device by the key it signs with: Device, DeviceRef, MemberDevices and shownDevices use it. Text only on the wire (the LeaderboardAPI calls, the JSON) and in settings.json, which is unchanged. - LeaderboardMembership takes the wait between a new device's asks as `sleep`, like `now`; a test pins that it waits the server's interval. - design.md: shownDevices as Set; DeviceKey in §7's pieces. Co-Authored-By: Claude Opus 5.5 --- Modules/Leaderboard/Sources/Device.swift | 39 ++++++++++++++++--- .../Sources/LeaderboardMembership.swift | 26 +++++++------ .../Leaderboard/Sources/MemberDevices.swift | 2 +- Modules/Leaderboard/Tests/DeviceServer.swift | 2 +- Modules/Leaderboard/Tests/DeviceTests.swift | 4 +- .../Tests/LeaderboardDevicesTests.swift | 32 ++++++++++++--- .../Tests/LeaderboardHTTPClientTests.swift | 6 +-- .../Tests/LeaderboardUploaderTests.swift | 4 +- .../Tests/MemberDevicesTests.swift | 12 +++--- docs/features/leaderboard/design.md | 4 +- 10 files changed, 91 insertions(+), 40 deletions(-) diff --git a/Modules/Leaderboard/Sources/Device.swift b/Modules/Leaderboard/Sources/Device.swift index 3533d81db..396956ea4 100644 --- a/Modules/Leaderboard/Sources/Device.swift +++ b/Modules/Leaderboard/Sources/Device.swift @@ -3,15 +3,15 @@ import Foundation /// *DEVICE* (design §2a): one of a member's machines, which is one key. `/me` /// lists them, removed ones included; a removed device's days stay and count. public struct Device: Sendable, Equatable, Codable { - /// The device's public key, base64url, as `X-Key` names it. - public let publicKey: String + /// The key it signs with: the device, as the server knows it. + public let publicKey: DeviceKey public let label: DeviceLabel public let addedAt: Date public let removedAt: Date? /// The device that removed it, `nil` while it's in use. public let removedBy: DeviceRef? - public init(publicKey: String, label: DeviceLabel, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) { + public init(publicKey: DeviceKey, label: DeviceLabel, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) { self.publicKey = publicKey self.label = label self.addedAt = addedAt @@ -30,7 +30,7 @@ public struct Device: Sendable, Equatable, Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - publicKey = try container.decode(String.self, forKey: .publicKey) + publicKey = try container.decode(DeviceKey.self, forKey: .publicKey) label = try container.decode(DeviceLabel.self, forKey: .label) addedAt = try ServerTime.decode(from: container, forKey: .addedAt) removedAt = try ServerTime.decodeIfPresent(from: container, forKey: .removedAt) @@ -51,15 +51,42 @@ public struct Device: Sendable, Equatable, Codable { /// A device named in passing: the one that removed another. public struct DeviceRef: Sendable, Equatable, Codable { - public let publicKey: String + public let publicKey: DeviceKey public let label: DeviceLabel - public init(publicKey: String, label: DeviceLabel) { + public init(publicKey: DeviceKey, label: DeviceLabel) { self.publicKey = publicKey self.label = label } } +/// A device, as the server knows it: the public half of the key it signs +/// with, base64url, as `X-Key` names it. Only the wire and the stored record +/// hold it as text. +public struct DeviceKey: Sendable, Hashable, Codable, CustomStringConvertible { + public let value: String + + public init(_ value: String) { + self.value = value + } + + public init(from decoder: Decoder) throws { + value = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } + + public var description: String { value } +} + +extension SigningKey { + /// The device this key signs as. + public var deviceKey: DeviceKey { DeviceKey(publicKey) } +} + /// What a device is called on the member's list: the label the person typed /// when it joined or was added, filled in with the machine's model and never /// its computer name, which is often a person's (design §6). The server takes diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index 6851242d9..8f0ead0a8 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -34,7 +34,7 @@ public final class LeaderboardMembership { public private(set) var removedBy: DeviceRef? private var key: SigningKey? /// Devices already shown as added, by key: each is shown once. - private var shownDevices: Set = [] + private var shownDevices: Set = [] /// A new device on its way in, with the key it asked with; `nil` otherwise. private var joiningState: JoiningState? /// Changes made here, counted, so a `/me` answer that crossed one is @@ -73,10 +73,13 @@ public final class LeaderboardMembership { @ObservationIgnored private let machine: any MachineIdentity @ObservationIgnored private let calendar: Calendar @ObservationIgnored private let now: () -> Date + /// Waits this long: between a new device's asks whether it was approved. + @ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void public init(api: any LeaderboardAPI, keys: any SigningKeyStore, settings: any LeaderboardSettingsRepository, logs: any TokenLogs, machine: any MachineIdentity, calendar: Calendar = .current, - now: @escaping () -> Date = Date.init) { + now: @escaping () -> Date = Date.init, + sleep: @escaping @MainActor (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) { self.api = api self.keys = keys self.settings = settings @@ -84,6 +87,7 @@ public final class LeaderboardMembership { self.machine = machine self.calendar = calendar self.now = now + self.sleep = sleep restore() } @@ -158,7 +162,7 @@ public final class LeaderboardMembership { } switch approval { case .waiting: - try await Task.sleep(for: .seconds(interval)) + try await sleep(interval) case .approved(let summary): guard let member = summary.username.flatMap(Username.init) else { throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") @@ -369,7 +373,7 @@ public final class LeaderboardMembership { /// This device as `/me` lists it, once read. public var thisDevice: Device? { - key.flatMap { devices.device(withKey: $0.publicKey) } + key.flatMap { devices.device(withKey: $0.deviceKey) } } /// What approving `code` would add, shown above *Approve* so a code read @@ -418,16 +422,16 @@ public final class LeaderboardMembership { /// Removing this device forgets the membership once the server confirmed. public func remove(device: Device, deletingDays: Bool = false) async throws { guard let credentials else { throw LeaderboardError.notJoined } - try await api.removeDevice(device.publicKey, as: credentials) - if device.publicKey == credentials.key.publicKey { + try await api.removeDevice(device.publicKey.value, as: credentials) + if device.publicKey == credentials.key.deviceKey { forget() return } - let by = DeviceRef(publicKey: credentials.key.publicKey, label: thisDevice?.label ?? suggestedLabel) + let by = DeviceRef(publicKey: credentials.key.deviceKey, label: thisDevice?.label ?? suggestedLabel) devices = devices.removing(device, by: by, at: now()) changesMadeHere += 1 if deletingDays { - try await api.deleteDays(of: device.publicKey, provider: nil, day: nil, as: credentials) + try await api.deleteDays(of: device.publicKey.value, provider: nil, day: nil, as: credentials) } } @@ -435,7 +439,7 @@ public final class LeaderboardMembership { /// one day's. Never a side effect of removing it. public func deleteDays(of device: Device, provider: String? = nil, day: String? = nil) async throws { guard let credentials else { throw LeaderboardError.notJoined } - try await api.deleteDays(of: device.publicKey, provider: provider, day: day, as: credentials) + try await api.deleteDays(of: device.publicKey.value, provider: provider, day: day, as: credentials) } // MARK: - Private @@ -459,7 +463,7 @@ public final class LeaderboardMembership { globeHintDismissed = record.globeHintDismissed link = record.link refused = record.refused - shownDevices = Set(record.shownDevices) + shownDevices = Set(record.shownDevices.map(DeviceKey.init)) } private func save() { @@ -467,6 +471,6 @@ public final class LeaderboardMembership { settings.saveLeaderboardRecord(LeaderboardRecord(username: username.value, sharing: Array(sharing), visible: isVisible, lastUpload: lastUpload, sharesCountry: sharesCountry, globeHintDismissed: globeHintDismissed, - link: link, refused: refused, shownDevices: Array(shownDevices))) + link: link, refused: refused, shownDevices: shownDevices.map(\.value))) } } diff --git a/Modules/Leaderboard/Sources/MemberDevices.swift b/Modules/Leaderboard/Sources/MemberDevices.swift index c2633c81e..9dac997e5 100644 --- a/Modules/Leaderboard/Sources/MemberDevices.swift +++ b/Modules/Leaderboard/Sources/MemberDevices.swift @@ -24,7 +24,7 @@ public struct MemberDevices: Sendable, Equatable, RandomAccessCollection { devices.min { $0.addedAt < $1.addedAt } } - public func device(withKey publicKey: String) -> Device? { + public func device(withKey publicKey: DeviceKey) -> Device? { devices.first { $0.publicKey == publicKey } } diff --git a/Modules/Leaderboard/Tests/DeviceServer.swift b/Modules/Leaderboard/Tests/DeviceServer.swift index 5145d7d1f..bf599ebbe 100644 --- a/Modules/Leaderboard/Tests/DeviceServer.swift +++ b/Modules/Leaderboard/Tests/DeviceServer.swift @@ -16,7 +16,7 @@ final class DeviceServer: LeaderboardAPI, @unchecked Sendable { /// `GET /me` from a waiting key, answered in turn; the last answer repeats. var approvals: [Result] = [] var pending = PendingDevice(label: DeviceLabel("Mac mini")!, requestedAt: Date(timeIntervalSince1970: 1_791_106_200)) - var approved = Device(publicKey: "newkey", label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260)) + var approved = Device(publicKey: DeviceKey("newkey"), label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260)) var removeFailure: LeaderboardError? var deleteDaysFailure: LeaderboardError? diff --git a/Modules/Leaderboard/Tests/DeviceTests.swift b/Modules/Leaderboard/Tests/DeviceTests.swift index 68a6a52e9..59a34168f 100644 --- a/Modules/Leaderboard/Tests/DeviceTests.swift +++ b/Modules/Leaderboard/Tests/DeviceTests.swift @@ -56,13 +56,13 @@ struct DeviceTests { let devices = try JSONDecoder().decode([Device].self, from: Data(json.utf8)) - #expect(devices[0].publicKey == "k1") + #expect(devices[0].publicKey == DeviceKey("k1")) #expect(devices[0].label == DeviceLabel("MacBook Pro")) #expect(devices[0].addedAt == Date(timeIntervalSince1970: 1_790_841_600)) #expect(devices[0].removedAt == nil && devices[0].removedBy == nil) #expect(devices[1].addedAt == Date(timeIntervalSince1970: 1_791_014_400.25)) #expect(devices[1].removedAt == Date(timeIntervalSince1970: 1_791_106_200)) - #expect(devices[1].removedBy == DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!)) + #expect(devices[1].removedBy == DeviceRef(publicKey: DeviceKey("k1"), label: DeviceLabel("MacBook Pro")!)) } /// The server writes every time as JavaScript's `toISOString()` (§5). diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift index b63e3c6ae..41bac19e3 100644 --- a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -17,11 +17,14 @@ struct LeaderboardDevicesTests { private let logs = FakeTokenLogs(providersWithLogs: ["claude", "codex"]) /// Day 20: devices added on day 13 or before are past their first week. private let today = LeaderboardFixtures.date(20) + /// Each wait a new device took between asking whether it was approved. + private let waits = Waits() private func membership(machine: String = "MacBook Pro") -> LeaderboardMembership { let today = today + let waits = waits return LeaderboardMembership(api: server, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named(machine), - calendar: LeaderboardFixtures.calendar, now: { today }) + calendar: LeaderboardFixtures.calendar, now: { today }, sleep: { waits.taken.append($0) }) } private func joined() async throws -> LeaderboardMembership { @@ -33,7 +36,7 @@ struct LeaderboardDevicesTests { /// This device, as `/me` lists it once it reads it. private func thisDevice(_ membership: LeaderboardMembership, addedOn day: Int) throws -> Device { let key = try SigningKey(rawRepresentation: #require(keys.stored)).publicKey - return Device(publicKey: key, label: DeviceLabel("MacBook Pro")!, addedAt: LeaderboardFixtures.date(day)) + return Device(publicKey: DeviceKey(key), label: DeviceLabel("MacBook Pro")!, addedAt: LeaderboardFixtures.date(day)) } /// `/me` lists `devices`, and this device reads it. @@ -43,7 +46,7 @@ struct LeaderboardDevicesTests { } private func device(_ key: String, addedOn day: Int, removedOn removed: Int? = nil) -> Device { - Device(publicKey: key, label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), + Device(publicKey: DeviceKey(key), label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), removedAt: removed.map { LeaderboardFixtures.date($0) }) } @@ -90,6 +93,17 @@ struct LeaderboardDevicesTests { #expect(keys.stored == nil && settings.record == nil) } + @Test func `should wait the interval the server gave before asking again whether it was approved`() async throws { + let membership = membership() + server.authorization = DeviceAuthorization(code: server.authorization.code, expiresIn: 600, interval: 5) + server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] + _ = try await membership.requestToJoin() + + _ = try await membership.waitForApproval() + + #expect(waits.taken == [5, 5]) + } + @Test func `should join as the member it was added to once the person agrees, with nothing uploaded yet`() async throws { let membership = membership() server.approvals = [.success(.approved(MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: false)))] @@ -239,7 +253,7 @@ struct LeaderboardDevicesTests { #expect(server.removed == ["k2"]) #expect(server.deletedDays.isEmpty) - #expect(membership.devices.first { $0.publicKey == "k2" }?.removedBy == DeviceRef(publicKey: me.publicKey, label: me.label)) + #expect(membership.devices.first { $0.publicKey == DeviceKey("k2") }?.removedBy == DeviceRef(publicKey: me.publicKey, label: me.label)) #expect(membership.isJoined) } @@ -263,7 +277,7 @@ struct LeaderboardDevicesTests { await #expect(throws: LeaderboardError.unreachable) { try await membership.remove(device: recent, deletingDays: true) } #expect(server.removed == ["k3"]) - #expect(membership.devices.first { $0.publicKey == "k3" }?.isRemoved == true) + #expect(membership.devices.first { $0.publicKey == DeviceKey("k3") }?.isRemoved == true) } @Test func `should forget this device's key only once the server removed it`() async throws { @@ -273,7 +287,7 @@ struct LeaderboardDevicesTests { try await membership.remove(device: me) - #expect(server.removed == [me.publicKey]) + #expect(server.removed == [me.publicKey.value]) #expect(!membership.isJoined) #expect(keys.stored == nil && settings.record == nil) } @@ -300,3 +314,9 @@ struct LeaderboardDevicesTests { #expect(server.deletedDays == [.init(publicKey: "k2", provider: "claude", day: "2026-10-06")]) } } + +/// The waits a membership took, in order. +@MainActor +final class Waits { + var taken: [TimeInterval] = [] +} diff --git a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift index acd68c51f..89b7991b1 100644 --- a/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardHTTPClientTests.swift @@ -323,7 +323,7 @@ struct LeaderboardHTTPClientTests { #expect(request.httpMethod == "POST") #expect(sentBody == ["code": "WDJBMJHT"]) #expect(try isSigned(request, by: key)) - #expect(device == Device(publicKey: "newkey", label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260))) + #expect(device == Device(publicKey: DeviceKey("newkey"), label: DeviceLabel("Mac mini")!, addedAt: Date(timeIntervalSince1970: 1_791_106_260))) } @Test func `should remove a device by its key, signed`() async throws { @@ -365,7 +365,7 @@ struct LeaderboardHTTPClientTests { let summary = try await client(body: body).me(period: .sevenDays, provider: nil, as: member) - #expect(summary.devices == [Device(publicKey: "k1", label: DeviceLabel("MacBook Pro")!, addedAt: Date(timeIntervalSince1970: 1_790_841_600))]) + #expect(summary.devices == [Device(publicKey: DeviceKey("k1"), label: DeviceLabel("MacBook Pro")!, addedAt: Date(timeIntervalSince1970: 1_790_841_600))]) } @Test(arguments: [ @@ -382,7 +382,7 @@ struct LeaderboardHTTPClientTests { @Test func `should say which device removed this one when the server refuses its key`() async { let body = #"{"error":"unauthorized","message":"This device was removed.","removedBy":{"publicKey":"k1","label":"MacBook Pro"}}"# - await #expect(throws: LeaderboardError.removed(by: DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!))) { + await #expect(throws: LeaderboardError.removed(by: DeviceRef(publicKey: DeviceKey("k1"), label: DeviceLabel("MacBook Pro")!))) { _ = try await client(status: 401, body: body).upload([], as: member) } } diff --git a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift index c9e708786..2800310cc 100644 --- a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift @@ -105,12 +105,12 @@ struct LeaderboardUploaderTests { let membership = try await joined() logs.logins = [LoginDays(providerId: "claude", days: [LeaderboardFixtures.stat(day: 4, input: 10)])] api.reset([.given]) - given(api).upload(.any, as: .any).willThrow(LeaderboardError.removed(by: DeviceRef(publicKey: "k2", label: DeviceLabel("Mac mini")!))) + given(api).upload(.any, as: .any).willThrow(LeaderboardError.removed(by: DeviceRef(publicKey: DeviceKey("k2"), label: DeviceLabel("Mac mini")!))) await uploader(membership).uploadDue() #expect(!membership.isJoined) - #expect(membership.removedBy == DeviceRef(publicKey: "k2", label: DeviceLabel("Mac mini")!)) + #expect(membership.removedBy == DeviceRef(publicKey: DeviceKey("k2"), label: DeviceLabel("Mac mini")!)) #expect(keys.stored == nil) #expect(settings.record == nil) } diff --git a/Modules/Leaderboard/Tests/MemberDevicesTests.swift b/Modules/Leaderboard/Tests/MemberDevicesTests.swift index 2ae2180b2..c5e0c5392 100644 --- a/Modules/Leaderboard/Tests/MemberDevicesTests.swift +++ b/Modules/Leaderboard/Tests/MemberDevicesTests.swift @@ -10,7 +10,7 @@ struct MemberDevicesTests { private let today = LeaderboardFixtures.date(20) private func device(_ key: String, addedOn day: Int, removedOn removed: Int? = nil) -> Device { - Device(publicKey: key, label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), + Device(publicKey: DeviceKey(key), label: DeviceLabel("Mac \(key)")!, addedAt: LeaderboardFixtures.date(day), removedAt: removed.map { LeaderboardFixtures.date($0) }) } @@ -19,7 +19,7 @@ struct MemberDevicesTests { @Test func `should take the earliest device as the one that joined, though it was removed`() { let devices = MemberDevices([device("k2", addedOn: 5), device("k1", addedOn: 1, removedOn: 3)]) - #expect(devices.joined?.publicKey == "k1") + #expect(devices.joined?.publicKey == DeviceKey("k1")) } // MARK: - The first week @@ -59,14 +59,14 @@ struct MemberDevicesTests { @Test func `should say which device removed one, and when`() { let older = device("k2", addedOn: 5) - let by = DeviceRef(publicKey: "k1", label: DeviceLabel("MacBook Pro")!) + let by = DeviceRef(publicKey: DeviceKey("k1"), label: DeviceLabel("MacBook Pro")!) let devices = MemberDevices([device("k1", addedOn: 1), older]).removing(older, by: by, at: today) - let removed = devices.device(withKey: "k2") + let removed = devices.device(withKey: DeviceKey("k2")) #expect(removed?.removedAt == today) #expect(removed?.removedBy == by) - #expect(devices.device(withKey: "k1")?.isRemoved == false) + #expect(devices.device(withKey: DeviceKey("k1"))?.isRemoved == false) } @Test func `should add a device once, however often it is added`() { @@ -74,6 +74,6 @@ struct MemberDevicesTests { let devices = MemberDevices([device("k1", addedOn: 1)]).adding(new).adding(new) - #expect(devices.map(\.publicKey) == ["k1", "k2"]) + #expect(devices.map(\.publicKey) == [DeviceKey("k1"), DeviceKey("k2")]) } } diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index 9d8fea184..6974da519 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -96,7 +96,7 @@ Leaderboard (app) ClaudeBar's side of the leaderboard ( │ ├─ lastUpload : Date? the last good upload: v1 resumes there, devices time the hour by it │ ├─ refused : Set DAYS THE SERVER REFUSED, LAST 30 DAYS: SENT AGAIN WITH EACH UPLOAD │ ├─ machine : hash? THIS MAC'S HARDWARE UUID, HASHED WITH THE KEY'S PUBLIC HALF - │ └─ shownDevices : Set devices added that this one has already shown, once + │ └─ shownDevices : Set devices added that this one has already shown, once ├─ board : Board the one you're looking at; picking changes it └─ board(period, provider) → Board THE SAME BOARD EACH TIME. MIRRORS THE SERVER'S board(period, provider) ├─ period : BoardPeriod Today · 7 days · 30 days @@ -611,7 +611,7 @@ Its code is the `Leaderboard` module of the package that also builds on Windows, |---|---| | `LeaderboardMembership`, `Board` and `Board.Member`, `DailyTokens`, `Username`, `RankCard`, `LeaderboardUploader` | `Modules/Leaderboard/Sources/` | | `@Mockable` ports `LeaderboardAPI`, `SigningKeyStore` and `MachineIdentity` (this Mac's model, and with slice 14 its hardware UUID, faked in tests to stand for another Mac); plain `LeaderboardSettingsRepository` (like Notify!'s, now also keeping `refused`, the devices already shown and, with slice 14, the `machine` hash) and `@MainActor` `TokenLogs`, faked in tests | `Modules/Leaderboard/Sources/` | -| `Device`, `MemberDevices`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | +| `Device`, `DeviceKey` (the key a device signs with: text only on the wire and in `settings.json`), `MemberDevices`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | | The factory, `Leaderboard.makeAPI(client:)`, `makeKeyStore()` and `makeMachineIdentity()`: the server, and the key store and machine of the platform the module is built for | `Modules/Leaderboard/Sources/` | | `LeaderboardHTTPClient`; `FallbackSigningKeyStore`, the key in the Keychain first and in UserDefaults when the Keychain refuses it (`DefaultsSigningKeyStore`) | `Modules/Leaderboard/Sources/Internal/` | | `KeychainSigningKeyStore`, the same Keychain item as before the module | `Modules/Leaderboard/Sources/Internal/macOS/` | From 2226ec4984b69c1ab54b65357c9268e08cc44408 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 11 Oct 2026 11:33:02 +0800 Subject: [PATCH 4/6] refactor(leaderboard): a new Mac's way in is a DeviceRequest (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mac is either asking to be added to a member or a member's device, never both, so the request is its own aggregate, as device_requests is on the server, not a state of the membership (the #540 review's point 4). - DeviceRequest owns the code, the unsaved key, the wait at the server's interval, expiry and decline(); its stage is waiting, approved(member), joined, expired or declined. - LeaderboardMembership.requestToJoin(label:) answers the request, and is refused while this Mac is a member (alreadyJoined); join(through:sharing:) replaces confirmJoining. Joining and its JoiningState leave the membership. - design.md: the request in §2's tree and its own section, §2a's stages, §3's tells, §4's owner, §7's pieces; CANONICAL_MODEL's Leaderboard row. Co-Authored-By: Claude Opus 5.5 --- .../Leaderboard/Sources/DeviceRequest.swift | 109 ++++++++++++++++++ .../Leaderboard/Sources/LeaderboardAPI.swift | 3 + .../Sources/LeaderboardMembership.swift | 89 ++------------ .../Tests/LeaderboardDevicesTests.swift | 68 ++++++++--- .../Tests/LeaderboardUploaderTests.swift | 6 +- docs/architecture/CANONICAL_MODEL.md | 2 +- docs/features/leaderboard/design.md | 58 ++++++---- 7 files changed, 214 insertions(+), 121 deletions(-) create mode 100644 Modules/Leaderboard/Sources/DeviceRequest.swift diff --git a/Modules/Leaderboard/Sources/DeviceRequest.swift b/Modules/Leaderboard/Sources/DeviceRequest.swift new file mode 100644 index 000000000..0dd9ac611 --- /dev/null +++ b/Modules/Leaderboard/Sources/DeviceRequest.swift @@ -0,0 +1,109 @@ +import Foundation +import Observation + +/// *DEVICE REQUEST* (design §2a): this Mac asking to be added to a member, +/// with the code it shows until a Mac the member has approves it. A Mac is +/// either asking or a member's device, never both, so the request is its own +/// aggregate, as `device_requests` is on the server. Its key lives only here, +/// never saved, until the person agrees to the member it was added to and +/// `LeaderboardMembership.join(through:sharing:)` takes it. +@MainActor +@Observable +public final class DeviceRequest { + /// Where the request stands. + public enum Stage: Sendable, Equatable { + /// It shows `code` until a device of the member approves it. + case waiting + /// A device of `member` approved it; nothing is kept or sent until the person agrees. + case approved(member: Username) + /// The person agreed: this Mac is that member's device now. + case joined + /// The code expired before anyone approved it. + case expired + /// The person didn't know the member, and this Mac removed itself. + case declined + } + + /// `Stage`, with what an approved request holds: the member's `/me` answer. + private enum State { + case waiting + case approved(member: Username, summary: MemberSummary) + case joined, expired, declined + } + + /// The code to type on a Mac the member has. + public let code: DeviceCode + /// The key this Mac asked with; saved only once it joins. + let key: SigningKey + private var state = State.waiting + + @ObservationIgnored private let api: any LeaderboardAPI + @ObservationIgnored private let interval: TimeInterval + @ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void + + init(code: DeviceCode, key: SigningKey, interval: TimeInterval, api: any LeaderboardAPI, + sleep: @escaping @MainActor (TimeInterval) async throws -> Void) { + self.code = code + self.key = key + self.interval = interval + self.api = api + self.sleep = sleep + } + + public var stage: Stage { + switch state { + case .waiting: .waiting + case .approved(let member, _): .approved(member: member) + case .joined: .joined + case .expired: .expired + case .declined: .declined + } + } + + /// Asks, at the interval the server gave, until a device of the member + /// approves the code; answers the member it joined. Nothing is kept or + /// sent until the person agrees: a code that leaked could have been + /// approved by someone else. + public func waitForApproval() async throws -> Username { + guard case .waiting = state else { throw LeaderboardError.notJoined } + while true { + let approval: DeviceApproval + do { + approval = try await api.approval(of: key) + } catch LeaderboardError.codeExpired { + state = .expired + throw LeaderboardError.codeExpired + } + switch approval { + case .waiting: + try await sleep(interval) + case .approved(let summary): + guard let member = summary.username.flatMap(Username.init) else { + throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") + } + state = .approved(member: member, summary: summary) + return member + } + } + } + + /// The person doesn't know the member this Mac was added to: it removes + /// itself from that member, and keeps nothing. + public func decline() async throws { + guard case .approved(let member, _) = state else { throw LeaderboardError.notJoined } + try await api.removeDevice(key.publicKey, as: MemberCredentials(username: member, key: key)) + state = .declined + } + + /// What joining takes from an approved request: the member, the key and + /// the member's `/me` answer. `nil` until approved, and once taken. + var approval: (member: Username, key: SigningKey, summary: MemberSummary)? { + guard case .approved(let member, let summary) = state else { return nil } + return (member, key, summary) + } + + /// The membership took it: this Mac is the member's device now. + func markJoined() { + state = .joined + } +} diff --git a/Modules/Leaderboard/Sources/LeaderboardAPI.swift b/Modules/Leaderboard/Sources/LeaderboardAPI.swift index a1785604d..7955a3405 100644 --- a/Modules/Leaderboard/Sources/LeaderboardAPI.swift +++ b/Modules/Leaderboard/Sources/LeaderboardAPI.swift @@ -195,6 +195,8 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case notShareable(String) case nothingShared case notJoined + /// This Mac is already a member's device, so it doesn't ask to be added. + case alreadyJoined /// The server refused the signature: the key no longer matches the name. case unauthorized /// Another device of the member removed this one: its key is revoked. @@ -218,6 +220,7 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case .notShareable(let provider): "\(provider) has no token logs on this Mac, so it can't be shared." case .nothingShared: "Pick at least one provider to share." case .notJoined: "You haven't joined the leaderboard." + case .alreadyJoined: "This Mac is already on the leaderboard." case .unauthorized: "The leaderboard didn't accept this Mac's key for your username." case .removed(let device): "\(device.label) removed this Mac from your leaderboard devices." case .codeExpired: "The code expired before it was approved. Ask for a new one." diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index 8f0ead0a8..2176b6814 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -35,37 +35,10 @@ public final class LeaderboardMembership { private var key: SigningKey? /// Devices already shown as added, by key: each is shown once. private var shownDevices: Set = [] - /// A new device on its way in, with the key it asked with; `nil` otherwise. - private var joiningState: JoiningState? /// Changes made here, counted, so a `/me` answer that crossed one is /// not taken over it. private var changesMadeHere = 0 - /// Where a new device stands on its way in (design §2a). - public enum Joining: Sendable, Equatable { - /// It shows `code` until a device of the member approves it. - case waiting(code: DeviceCode) - /// A device of `member` approved it; nothing is sent until the person agrees. - case approved(member: Username) - } - - /// `Joining`, with what each step holds: the key the new device asked - /// with, and the server's interval, then the member's `/me` answer. - private enum JoiningState { - case waiting(code: DeviceCode, key: SigningKey, interval: TimeInterval) - case approved(member: Username, key: SigningKey, summary: MemberSummary) - } - - /// A new device on its way in: the code it shows, then the member a device - /// of theirs added it to, until the person agrees. `nil` otherwise. - public var joining: Joining? { - switch joiningState { - case .waiting(let code, _, _): .waiting(code: code) - case .approved(let member, _, _): .approved(member: member) - case nil: nil - } - } - @ObservationIgnored private let api: any LeaderboardAPI @ObservationIgnored private let keys: any SigningKeyStore @ObservationIgnored private let settings: any LeaderboardSettingsRepository @@ -73,7 +46,7 @@ public final class LeaderboardMembership { @ObservationIgnored private let machine: any MachineIdentity @ObservationIgnored private let calendar: Calendar @ObservationIgnored private let now: () -> Date - /// Waits this long: between a new device's asks whether it was approved. + /// Waits this long: between a device request's asks whether it was approved. @ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void public init(api: any LeaderboardAPI, keys: any SigningKeyStore, settings: any LeaderboardSettingsRepository, @@ -138,46 +111,20 @@ public final class LeaderboardMembership { // MARK: - Joining as a new device of a member (design §2a) /// *Already a member? Add this Mac*: makes this device's key and asks the - /// server to add it; answers the code to type on a device the member has. - public func requestToJoin(label: DeviceLabel? = nil) async throws -> DeviceCode { + /// server to add it; answers the request, with the code to type on a + /// device the member has. Never while this Mac is a member. + public func requestToJoin(label: DeviceLabel? = nil) async throws -> DeviceRequest { + guard !isJoined else { throw LeaderboardError.alreadyJoined } let key = SigningKey.generate() let authorization = try await api.requestDevice(publicKey: key.publicKey, label: (label ?? suggestedLabel).value) - joiningState = .waiting(code: authorization.code, key: key, interval: authorization.interval) - return authorization.code - } - - /// Asks, at the interval the server gave, until a device of the member - /// approves the code; answers the member it joined. Nothing is kept or - /// sent until the person agrees (`confirmJoining`): a code that leaked - /// could have been approved by someone else. - public func waitForApproval() async throws -> Username { - guard case .waiting(_, let key, let interval) = joiningState else { throw LeaderboardError.notJoined } - while true { - let approval: DeviceApproval - do { - approval = try await api.approval(of: key) - } catch LeaderboardError.codeExpired { - endJoining() - throw LeaderboardError.codeExpired - } - switch approval { - case .waiting: - try await sleep(interval) - case .approved(let summary): - guard let member = summary.username.flatMap(Username.init) else { - throw LeaderboardError.rejected("The leaderboard answered with something unreadable.") - } - joiningState = .approved(member: member, key: key, summary: summary) - return member - } - } + return DeviceRequest(code: authorization.code, key: key, interval: authorization.interval, api: api, sleep: sleep) } - /// The person knows the member this device was added to: it joins as that - /// member, sharing `providers`, with nothing uploaded yet, so its first - /// upload sends the last thirty days. - public func confirmJoining(sharing providers: Set) throws { - guard case .approved(let member, let key, let summary) = joiningState else { throw LeaderboardError.notJoined } + /// The person knows the member `request` was added to: this Mac joins as + /// that member's device, sharing `providers`, with nothing uploaded yet, + /// so its first upload sends the last thirty days. + public func join(through request: DeviceRequest, sharing providers: Set) throws { + guard let (member, key, summary) = request.approval else { throw LeaderboardError.notJoined } guard !providers.isEmpty else { throw LeaderboardError.nothingShared } try requireShareable(providers) keys.save(key.rawRepresentation) @@ -192,19 +139,7 @@ public final class LeaderboardMembership { link = nil startFresh() follow(summary) - endJoining() - } - - /// The person doesn't know the member this device was added to: it removes - /// itself from that member, and keeps nothing. - public func declineJoining() async throws { - guard case .approved(let member, let key, _) = joiningState else { throw LeaderboardError.notJoined } - try await api.removeDevice(key.publicKey, as: MemberCredentials(username: member, key: key)) - endJoining() - } - - private func endJoining() { - joiningState = nil + request.markJoined() } /// A membership this device starts: no devices known or shown yet, and no diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift index 41bac19e3..f0b7fb7a3 100644 --- a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -69,11 +69,11 @@ struct LeaderboardDevicesTests { @Test func `should show the code the server gave, and stay outside while it waits`() async throws { let membership = membership(machine: "Mac mini") - let code = try await membership.requestToJoin() + let request = try await membership.requestToJoin() - #expect(code == server.authorization.code) + #expect(request.code == server.authorization.code) #expect(server.requested?.label == "Mac mini") - #expect(membership.joining == .waiting(code: code)) + #expect(request.stage == .waiting) #expect(!membership.isJoined) #expect(keys.stored == nil) } @@ -81,13 +81,13 @@ struct LeaderboardDevicesTests { @Test func `should name the member it joined once approved, and send nothing before the person agrees`() async throws { let membership = membership() server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] - _ = try await membership.requestToJoin() + let request = try await membership.requestToJoin() - let member = try await membership.waitForApproval() + let member = try await request.waitForApproval() #expect(member.value == "tokenwhale") #expect(server.approvalsAsked == 3) - #expect(membership.joining == .approved(member: member)) + #expect(request.stage == .approved(member: member)) #expect(!membership.isJoined) #expect(membership.uploadCredentials == nil) #expect(keys.stored == nil && settings.record == nil) @@ -97,9 +97,9 @@ struct LeaderboardDevicesTests { let membership = membership() server.authorization = DeviceAuthorization(code: server.authorization.code, expiresIn: 600, interval: 5) server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] - _ = try await membership.requestToJoin() + let request = try await membership.requestToJoin() - _ = try await membership.waitForApproval() + _ = try await request.waitForApproval() #expect(waits.taken == [5, 5]) } @@ -107,13 +107,13 @@ struct LeaderboardDevicesTests { @Test func `should join as the member it was added to once the person agrees, with nothing uploaded yet`() async throws { let membership = membership() server.approvals = [.success(.approved(MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: false)))] - _ = try await membership.requestToJoin() - _ = try await membership.waitForApproval() + let request = try await membership.requestToJoin() + _ = try await request.waitForApproval() - try membership.confirmJoining(sharing: ["codex"]) + try membership.join(through: request, sharing: ["codex"]) #expect(membership.isJoined) - #expect(membership.joining == nil) + #expect(request.stage == .joined) #expect(membership.username?.value == "tokenwhale") #expect(membership.sharing == ["codex"]) #expect(!membership.isVisible) @@ -122,26 +122,56 @@ struct LeaderboardDevicesTests { #expect(settings.record?.username == "tokenwhale") } + @Test func `should not join through a request no one has approved yet`() async throws { + let membership = membership() + let request = try await membership.requestToJoin() + + #expect(throws: LeaderboardError.notJoined) { try membership.join(through: request, sharing: ["claude"]) } + + #expect(!membership.isJoined) + #expect(request.stage == .waiting) + } + + @Test func `should stay approved when what the person picked can't be shared, so they can pick again`() async throws { + let membership = membership() + server.approvals = [.success(.approved(server.summary))] + let request = try await membership.requestToJoin() + let member = try await request.waitForApproval() + + #expect(throws: LeaderboardError.nothingShared) { try membership.join(through: request, sharing: []) } + + #expect(request.stage == .approved(member: member)) + #expect(!membership.isJoined) + } + + @Test func `should not ask to be added to a member while this Mac is already one`() async throws { + let membership = try await joined() + + await #expect(throws: LeaderboardError.alreadyJoined) { _ = try await membership.requestToJoin() } + + #expect(server.requested == nil) + } + @Test func `should forget the code when it expires before anyone approves it`() async throws { let membership = membership() server.approvals = [.success(.waiting), .failure(.codeExpired)] - _ = try await membership.requestToJoin() + let request = try await membership.requestToJoin() - await #expect(throws: LeaderboardError.codeExpired) { _ = try await membership.waitForApproval() } - #expect(membership.joining == nil) + await #expect(throws: LeaderboardError.codeExpired) { _ = try await request.waitForApproval() } + #expect(request.stage == .expired) #expect(!membership.isJoined) } @Test func `should remove itself and keep nothing when the person doesn't know the member it was added to`() async throws { let membership = membership() server.approvals = [.success(.approved(server.summary))] - _ = try await membership.requestToJoin() - _ = try await membership.waitForApproval() + let request = try await membership.requestToJoin() + _ = try await request.waitForApproval() - try await membership.declineJoining() + try await request.decline() #expect(server.removed == [server.requested?.publicKey]) - #expect(membership.joining == nil) + #expect(request.stage == .declined) #expect(!membership.isJoined) #expect(keys.stored == nil && settings.record == nil) } diff --git a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift index 2800310cc..5f6beab7d 100644 --- a/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardUploaderTests.swift @@ -120,9 +120,9 @@ struct LeaderboardUploaderTests { server.approvals = [.success(.approved(server.summary))] let membership = LeaderboardMembership(api: server, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named("Mac mini"), calendar: calendar) - _ = try await membership.requestToJoin() - _ = try await membership.waitForApproval() - try membership.confirmJoining(sharing: ["claude"]) + let request = try await membership.requestToJoin() + _ = try await request.waitForApproval() + try membership.join(through: request, sharing: ["claude"]) logs.logins = [LoginDays(providerId: "claude", days: [LeaderboardFixtures.stat(day: 4, input: 10)])] let now = now diff --git a/docs/architecture/CANONICAL_MODEL.md b/docs/architecture/CANONICAL_MODEL.md index 9650d04b5..c5a06b03a 100644 --- a/docs/architecture/CANONICAL_MODEL.md +++ b/docs/architecture/CANONICAL_MODEL.md @@ -492,7 +492,7 @@ enforces it. Across a fence the same word may mean something else, as long as | **Activity** | supporting | *what is Claude Code doing right now?* — hooks, sessions, the notch | | **Usage History** | supporting | *what did I use, day by day?* | | **In use** | supporting | *which login does my next terminal session start with?* | -| **Leaderboard** | supporting | *how do my days compare with everyone else's?* — membership, devices, the signed upload; [its design](../features/leaderboard/design.md) | +| **Leaderboard** | supporting | *how do my days compare with everyone else's?* — membership, devices, a new device's request to be added, the signed upload; [its design](../features/leaderboard/design.md) | | **Vault & Settings** | generic | *where is it kept?* — `settings.json`, secrets | | SDK clients | — (anti-corruption layers) | *what does this SDK say?* — a client that needs a heavy SDK gets its own module, behind a port, so only it links the SDK | diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index 6974da519..e4ab44aa4 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -88,7 +88,6 @@ Leaderboard (app) ClaudeBar's side of the leaderboard ( │ │ ├─ sharesCountry : Bool country : String? where the globe puts you, when you opted in │ │ ├─ link : ProfileLink? │ │ └─ devices : MemberDevices IN ITS FIRST WEEK ONLY THIS ONE. OWNS: WHICH JOINED, WHO IS IN THEIR FIRST WEEK, WHICH WERE ADDED SINCE A DEVICE - │ ├─ joining : Joining? A NEW DEVICE ON ITS WAY IN: waiting(code) → approved(member); NOTHING KEPT UNTIL THE PERSON AGREES │ └─ this device's own NEVER SENT, NEVER FOLLOWS /me │ ├─ key : SigningKey PRIVATE HALF NEVER LEAVES THE DEVICE; ITS PUBLIC HALF IS THIS DEVICE ON THE SERVER │ ├─ sharing : Set ONLY PROVIDERS WITH USAGE HISTORY @@ -97,6 +96,9 @@ Leaderboard (app) ClaudeBar's side of the leaderboard ( │ ├─ refused : Set DAYS THE SERVER REFUSED, LAST 30 DAYS: SENT AGAIN WITH EACH UPLOAD │ ├─ machine : hash? THIS MAC'S HARDWARE UUID, HASHED WITH THE KEY'S PUBLIC HALF │ └─ shownDevices : Set devices added that this one has already shown, once + ├─ request : DeviceRequest? THIS MAC ASKING TO BE ADDED TO A MEMBER. NEVER WHILE A MEMBER; IN MEMORY ONLY + │ ├─ code : DeviceCode · key THE KEY IS MADE HERE AND NEVER SAVED UNTIL THE PERSON AGREES + │ └─ stage waiting → approved(member) → joined; or expired, or declined ├─ board : Board the one you're looking at; picking changes it └─ board(period, provider) → Board THE SAME BOARD EACH TIME. MIRRORS THE SERVER'S board(period, provider) ├─ period : BoardPeriod Today · 7 days · 30 days @@ -167,8 +169,8 @@ The person thinks of the board as something their app has: what they saw a minut | **Owns: only shareable providers can be shared** | `share(_:)` refuses a provider with no usage history; the ability is absent, not ignored | | **Owns: a turned-off Leaderboard uploads nothing** | `uploadCredentials` is `nil` while off, so the uploader has nothing to sign with and `lastUpload` stays where uploads stopped | | **Owns: a copied key it detects never uploads unasked** | at launch, a key whose `machine` hash isn't this Mac's uploads nothing until the member answers: this Mac gets its own key, or keeps this one and records its hash ([§2a](#2a--devices-one-member-several-machines)); every key made here is recorded with this Mac's hash, and forgetting a key forgets its hash | -| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` · `waitForApproval()` · `confirmJoining(sharing:)` · `declineJoining()` · `pendingDevice(code:)` · `approve(code:)` · `markShown(_:)` · `remove(device:deletingDays:)` · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | -| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `joining` · `addedDevices` · `offersDeletingDays(whenRemoving:)` · `removedBy` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | +| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` (answers a `DeviceRequest`) · `join(through:sharing:)` · `pendingDevice(code:)` · `approve(code:)` · `markShown(_:)` · `remove(device:deletingDays:)` · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | +| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `addedDevices` · `offersDeletingDays(whenRemoving:)` · `removedBy` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | | **Never** | holds a ranking · sends a provider it was not told to share · forgets its key before the server confirmed the leave or the removal · keeps a member setting the server has changed since | ### `MemberDevices`: the member's devices, as `/me` last listed them @@ -183,6 +185,20 @@ The person thinks of the board as something their app has: what they saw a minut | **Tell it** | `removing(_:by:at:)` (a device's removal, as `/me` will list it) · `adding(_:)` (a device just approved, once) | | **Never** | decides what was already shown: that is this device's own, `LeaderboardMembership.shownDevices` | +### `DeviceRequest`: this Mac asking to be added to a member + +> **Pointable as:** the code on the new Mac's screen, and the wait until a Mac you have approves it. + +A Mac is either asking to be added or a member's device, never both: before approval it has a key it hasn't saved, a code and a wait; after, a membership, and the request is gone. So the request is its own aggregate, as it is on the server (`device_requests`, `POST /devices`, a waiting key answered `202`), not a state of the membership. + +| | | +|---|---| +| **Owns: nothing is kept or sent until the person agrees** | its key lives only in the request; `stage` is `approved(member)` and nothing more until `LeaderboardMembership.join(through:sharing:)` takes it, or `decline()` removes it | +| **Owns: it asks at the server's pace, until the code expires** | `waitForApproval()` asks `/me` every `interval` the server gave; `401` ends it as `expired` | +| **Tell it** | `waitForApproval()` · `decline()` | +| **It answers** | `code` · `stage` | +| **Never** | saves its key · uploads · is made while this Mac is a member | + ### `DailyTokens`: one provider's day > **Pointable as:** a line on a tally sheet: provider, date, four counts. @@ -253,25 +269,24 @@ Each call, as the server answers it: ├─────────────────────────────────▶ │◀── 200 the member (only itself: its first week) │ - ├─ confirmJoining(sharing:) ─▶ keeps the key; first upload sends 30 days - └─ declineJoining() ─────────▶ DELETE /me/devices/; keeps nothing + ├─ membership.join(through:) ─▶ keeps the key; first upload sends 30 days + └─ request.decline() ─────────▶ DELETE /me/devices/; keeps nothing code expired ─▶ 401 ─▶ codeExpired ``` -On the new machine, `LeaderboardMembership.joining` is where it stands. Each step holds what the next needs, so a step without it can't be reached: +On the new machine, the `DeviceRequest` is where it stands. Each stage holds what the next needs, so a stage without it can't be reached: ``` - requestToJoin 200 approved confirmJoining - nil ─────────────────▶ waiting(code, ─────────────────▶ approved(member, ─────────────▶ joined - key, interval) key, summary) - │ ▲ │ - └───┘ 202: wait `interval` └─ declineJoining ─▶ nil + membership.requestToJoin 200 approved membership.join(through:) + ─────────────────────▶ waiting ─────────────────────▶ approved(member) ──────────────────────────▶ joined + │ ▲ │ + └───┘ 202: wait `interval` └─ decline() ─▶ declined │ - └─ 401 ─▶ codeExpired ─▶ nil + └─ 401 ─▶ expired ``` -The key lives only in `joining` until the person agrees; nothing is written to the Keychain or `settings.json` before. +The key lives only in the request until the person agrees; nothing is written to the Keychain or `settings.json` before. **Limits.** - Approving, and reading a pending code, are limited to 10 a minute per member (Cloudflare's limiter counts only 10 s or 60 s periods; at that rate a live code can't be found among 20⁸ in its 10 minutes); asking for codes, per IP, as `POST /join` is. @@ -399,12 +414,13 @@ leaderboard.turnOff() // tab gone, uploads stop; says leaderboard.turnOn() // tab back; uploads now, from where they stopped // Devices (§2a). The new device shows a code; one the member already has approves it. -let code = try await membership.requestToJoin(label: DeviceLabel("Mac mini")) // on the new machine; the label defaults to its model -let member = try await membership.waitForApproval() // asks /me every 5 seconds until approved; the member it joined -try membership.confirmJoining(sharing: ["claude"]) // the person knows that member: join, then upload 30 days -try await membership.declineJoining() // or not: remove this device, keep nothing -let pending = try await membership.pendingDevice(code: code) // on a joined device: the label and when it asked -try await membership.approve(code: code) // from a device past its first week +let request = try await membership.requestToJoin(label: DeviceLabel("Mac mini")) // on the new machine; the label defaults to its model +request.code // WDJB-MJHT, to type on a Mac the member has +let member = try await request.waitForApproval() // asks /me every 5 seconds until approved; the member it joined +try membership.join(through: request, sharing: ["claude"]) // the person knows that member: join, then upload 30 days +try await request.decline() // or not: remove this device, keep nothing +let pending = try await membership.pendingDevice(code: request.code) // on a joined device: the label and when it asked +try await membership.approve(code: request.code) // from a device past its first week membership.addedDevices // each device added since this one last looked, once try await membership.remove(device: oldMac, deletingDays: false) // revokes its key; its days stay try await membership.remove(device: stranger, deletingDays: true) // another device in its first week: removes, then deletes its days @@ -464,7 +480,7 @@ let days = membership.dailyTokens(from: usageHistories, in: range) | Turning it back on catches up the days missed, up to 30 | `LeaderboardUploader`'s range from `lastUpload`, unchanged; `Leaderboard.turnOn()` asks for it at once | | While off there is no Leaderboard tab, and the popover falls back to its provider | The popover reads `membership.isOn` | | A member has at most 5 devices that aren't removed | Server | -| A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device (`LeaderboardMembership.waitForApproval` and `confirmJoining`) | +| A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device: `DeviceRequest` (waits, names the member, keeps nothing), then `LeaderboardMembership.join(through:sharing:)` | | For its first 7 days an added device changes and reads only its own days and settings, with the member's settings and place: `/me` leaves out the other devices and their rows, and `/me/export` is refused; it can't rename or hide the member, change its globe or link, approve a device, remove another, delete a removed device's days, or leave. After that, and from the start for the device that joined, it may do what the member may | Server | | Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.offersDeletingDays(whenRemoving:)` for the dialog, from `MemberDevices.isInFirstWeek(_:at:)` | | Only a removed device's days can be deleted, by another device of the member past its first week, for one provider, one day or all | Server | @@ -611,7 +627,7 @@ Its code is the `Leaderboard` module of the package that also builds on Windows, |---|---| | `LeaderboardMembership`, `Board` and `Board.Member`, `DailyTokens`, `Username`, `RankCard`, `LeaderboardUploader` | `Modules/Leaderboard/Sources/` | | `@Mockable` ports `LeaderboardAPI`, `SigningKeyStore` and `MachineIdentity` (this Mac's model, and with slice 14 its hardware UUID, faked in tests to stand for another Mac); plain `LeaderboardSettingsRepository` (like Notify!'s, now also keeping `refused`, the devices already shown and, with slice 14, the `machine` hash) and `@MainActor` `TokenLogs`, faked in tests | `Modules/Leaderboard/Sources/` | -| `Device`, `DeviceKey` (the key a device signs with: text only on the wire and in `settings.json`), `MemberDevices`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | +| `DeviceRequest`; `Device`, `DeviceKey` (the key a device signs with: text only on the wire and in `settings.json`), `MemberDevices`, `DeviceCode`, `DeviceLabel` | `Modules/Leaderboard/Sources/` | | The factory, `Leaderboard.makeAPI(client:)`, `makeKeyStore()` and `makeMachineIdentity()`: the server, and the key store and machine of the platform the module is built for | `Modules/Leaderboard/Sources/` | | `LeaderboardHTTPClient`; `FallbackSigningKeyStore`, the key in the Keychain first and in UserDefaults when the Keychain refuses it (`DefaultsSigningKeyStore`) | `Modules/Leaderboard/Sources/Internal/` | | `KeychainSigningKeyStore`, the same Keychain item as before the module | `Modules/Leaderboard/Sources/Internal/macOS/` | From e3268bc4aa6a657f1af31d2611bc3bbab1463c94 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 11 Oct 2026 11:38:50 +0800 Subject: [PATCH 5/6] refactor(leaderboard): the request hands itself over; it owns its wait (#540) - DeviceRequest.handOver() answers the member, the key and the member's /me answer and moves itself to joined, once; refused before approval. LeaderboardMembership.join(through:sharing:) checks its own rules, then tells it, instead of reading `approval` and setting `markJoined()`. - DeviceRequest's wait defaults to Task.sleep; the membership no longer carries a `sleep` it never used. - DeviceRequestTests builds the request directly: the wait, the member, expiry, decline and the hand-over. - design.md: DeviceRequest's laws and tells. Co-Authored-By: Claude Opus 5.5 --- .../Leaderboard/Sources/DeviceRequest.swift | 25 +++-- .../Sources/LeaderboardMembership.swift | 19 ++-- .../Tests/DeviceRequestTests.swift | 92 +++++++++++++++++++ .../Tests/LeaderboardDevicesTests.swift | 31 +------ docs/features/leaderboard/design.md | 7 +- 5 files changed, 119 insertions(+), 55 deletions(-) create mode 100644 Modules/Leaderboard/Tests/DeviceRequestTests.swift diff --git a/Modules/Leaderboard/Sources/DeviceRequest.swift b/Modules/Leaderboard/Sources/DeviceRequest.swift index 0dd9ac611..d60a8c3dd 100644 --- a/Modules/Leaderboard/Sources/DeviceRequest.swift +++ b/Modules/Leaderboard/Sources/DeviceRequest.swift @@ -24,6 +24,14 @@ public final class DeviceRequest { case declined } + /// What an approved request hands to the membership it joins: the member, + /// the key it asked with, and the member's `/me` answer. + struct Approval { + let member: Username + let key: SigningKey + let summary: MemberSummary + } + /// `Stage`, with what an approved request holds: the member's `/me` answer. private enum State { case waiting @@ -42,7 +50,7 @@ public final class DeviceRequest { @ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void init(code: DeviceCode, key: SigningKey, interval: TimeInterval, api: any LeaderboardAPI, - sleep: @escaping @MainActor (TimeInterval) async throws -> Void) { + sleep: @escaping @MainActor (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) { self.code = code self.key = key self.interval = interval @@ -95,15 +103,12 @@ public final class DeviceRequest { state = .declined } - /// What joining takes from an approved request: the member, the key and - /// the member's `/me` answer. `nil` until approved, and once taken. - var approval: (member: Username, key: SigningKey, summary: MemberSummary)? { - guard case .approved(let member, let summary) = state else { return nil } - return (member, key, summary) - } - - /// The membership took it: this Mac is the member's device now. - func markJoined() { + /// The person agreed, and the membership joins through it: hands over + /// what joining takes, once, and this Mac is the member's device from + /// here. Refused until approved, and once handed over. + func handOver() throws -> Approval { + guard case .approved(let member, let summary) = state else { throw LeaderboardError.notJoined } state = .joined + return Approval(member: member, key: key, summary: summary) } } diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index 2176b6814..41c889902 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -46,13 +46,10 @@ public final class LeaderboardMembership { @ObservationIgnored private let machine: any MachineIdentity @ObservationIgnored private let calendar: Calendar @ObservationIgnored private let now: () -> Date - /// Waits this long: between a device request's asks whether it was approved. - @ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void public init(api: any LeaderboardAPI, keys: any SigningKeyStore, settings: any LeaderboardSettingsRepository, logs: any TokenLogs, machine: any MachineIdentity, calendar: Calendar = .current, - now: @escaping () -> Date = Date.init, - sleep: @escaping @MainActor (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) { + now: @escaping () -> Date = Date.init) { self.api = api self.keys = keys self.settings = settings @@ -60,7 +57,6 @@ public final class LeaderboardMembership { self.machine = machine self.calendar = calendar self.now = now - self.sleep = sleep restore() } @@ -117,19 +113,19 @@ public final class LeaderboardMembership { guard !isJoined else { throw LeaderboardError.alreadyJoined } let key = SigningKey.generate() let authorization = try await api.requestDevice(publicKey: key.publicKey, label: (label ?? suggestedLabel).value) - return DeviceRequest(code: authorization.code, key: key, interval: authorization.interval, api: api, sleep: sleep) + return DeviceRequest(code: authorization.code, key: key, interval: authorization.interval, api: api) } /// The person knows the member `request` was added to: this Mac joins as /// that member's device, sharing `providers`, with nothing uploaded yet, /// so its first upload sends the last thirty days. public func join(through request: DeviceRequest, sharing providers: Set) throws { - guard let (member, key, summary) = request.approval else { throw LeaderboardError.notJoined } guard !providers.isEmpty else { throw LeaderboardError.nothingShared } try requireShareable(providers) - keys.save(key.rawRepresentation) - self.key = key - username = member + let approval = try request.handOver() + keys.save(approval.key.rawRepresentation) + key = approval.key + username = approval.member sharing = providers isVisible = true lastUpload = nil @@ -138,8 +134,7 @@ public final class LeaderboardMembership { globeHintDismissed = false link = nil startFresh() - follow(summary) - request.markJoined() + follow(approval.summary) } /// A membership this device starts: no devices known or shown yet, and no diff --git a/Modules/Leaderboard/Tests/DeviceRequestTests.swift b/Modules/Leaderboard/Tests/DeviceRequestTests.swift new file mode 100644 index 000000000..3afb4f82c --- /dev/null +++ b/Modules/Leaderboard/Tests/DeviceRequestTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import Leaderboard + +/// A new Mac asking to be added to a member (design §2a): it waits at the +/// server's pace, names the member once approved, and keeps nothing until the +/// person agrees; it expires, or removes itself when they don't know the member. +@MainActor +@Suite +struct DeviceRequestTests { + private let server = DeviceServer() + /// Each wait the request took between asking whether it was approved. + private let waits = Waits() + + private func request(interval: TimeInterval = 5) -> DeviceRequest { + let waits = waits + return DeviceRequest(code: server.authorization.code, key: SigningKey.generate(), interval: interval, api: server, + sleep: { waits.taken.append($0) }) + } + + // MARK: - Waiting + + @Test func `should wait the interval the server gave before asking again whether it was approved`() async throws { + server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] + let request = request(interval: 5) + + _ = try await request.waitForApproval() + + #expect(waits.taken == [5, 5]) + #expect(server.approvalsAsked == 3) + } + + @Test func `should name the member it was added to once approved`() async throws { + server.approvals = [.success(.approved(server.summary))] + let request = request() + + let member = try await request.waitForApproval() + + #expect(member.value == "tokenwhale") + #expect(request.stage == .approved(member: member)) + } + + @Test func `should end as expired when the code expires before anyone approves it`() async throws { + server.approvals = [.success(.waiting), .failure(.codeExpired)] + let request = request() + + await #expect(throws: LeaderboardError.codeExpired) { _ = try await request.waitForApproval() } + + #expect(request.stage == .expired) + } + + // MARK: - The person's answer + + @Test func `should remove itself from the member when the person doesn't know them`() async throws { + server.approvals = [.success(.approved(server.summary))] + let request = request() + _ = try await request.waitForApproval() + + try await request.decline() + + #expect(server.removed == [request.key.publicKey]) + #expect(request.stage == .declined) + } + + @Test func `should hand over the member and its key once, when approved`() async throws { + server.approvals = [.success(.approved(server.summary))] + let request = request() + let member = try await request.waitForApproval() + + let approval = try request.handOver() + + #expect(approval.member == member) + #expect(approval.key.publicKey == request.key.publicKey) + #expect(approval.summary == server.summary) + #expect(request.stage == .joined) + #expect(throws: LeaderboardError.notJoined) { try request.handOver() } + } + + @Test func `should hand nothing over while it still waits`() { + let request = request() + + #expect(throws: LeaderboardError.notJoined) { try request.handOver() } + + #expect(request.stage == .waiting) + } +} + +/// The waits a request took, in order. +@MainActor +final class Waits { + var taken: [TimeInterval] = [] +} diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift index f0b7fb7a3..d7df35232 100644 --- a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -17,14 +17,11 @@ struct LeaderboardDevicesTests { private let logs = FakeTokenLogs(providersWithLogs: ["claude", "codex"]) /// Day 20: devices added on day 13 or before are past their first week. private let today = LeaderboardFixtures.date(20) - /// Each wait a new device took between asking whether it was approved. - private let waits = Waits() private func membership(machine: String = "MacBook Pro") -> LeaderboardMembership { let today = today - let waits = waits return LeaderboardMembership(api: server, keys: keys, settings: settings, logs: logs, machine: MockMachineIdentity.named(machine), - calendar: LeaderboardFixtures.calendar, now: { today }, sleep: { waits.taken.append($0) }) + calendar: LeaderboardFixtures.calendar, now: { today }) } private func joined() async throws -> LeaderboardMembership { @@ -93,17 +90,6 @@ struct LeaderboardDevicesTests { #expect(keys.stored == nil && settings.record == nil) } - @Test func `should wait the interval the server gave before asking again whether it was approved`() async throws { - let membership = membership() - server.authorization = DeviceAuthorization(code: server.authorization.code, expiresIn: 600, interval: 5) - server.approvals = [.success(.waiting), .success(.waiting), .success(.approved(server.summary))] - let request = try await membership.requestToJoin() - - _ = try await request.waitForApproval() - - #expect(waits.taken == [5, 5]) - } - @Test func `should join as the member it was added to once the person agrees, with nothing uploaded yet`() async throws { let membership = membership() server.approvals = [.success(.approved(MemberSummary(username: "tokenwhale", onBoard: nil, days: [], visible: false)))] @@ -152,16 +138,6 @@ struct LeaderboardDevicesTests { #expect(server.requested == nil) } - @Test func `should forget the code when it expires before anyone approves it`() async throws { - let membership = membership() - server.approvals = [.success(.waiting), .failure(.codeExpired)] - let request = try await membership.requestToJoin() - - await #expect(throws: LeaderboardError.codeExpired) { _ = try await request.waitForApproval() } - #expect(request.stage == .expired) - #expect(!membership.isJoined) - } - @Test func `should remove itself and keep nothing when the person doesn't know the member it was added to`() async throws { let membership = membership() server.approvals = [.success(.approved(server.summary))] @@ -345,8 +321,3 @@ struct LeaderboardDevicesTests { } } -/// The waits a membership took, in order. -@MainActor -final class Waits { - var taken: [TimeInterval] = [] -} diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index e4ab44aa4..bb8c9947e 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -193,9 +193,10 @@ A Mac is either asking to be added or a member's device, never both: before appr | | | |---|---| -| **Owns: nothing is kept or sent until the person agrees** | its key lives only in the request; `stage` is `approved(member)` and nothing more until `LeaderboardMembership.join(through:sharing:)` takes it, or `decline()` removes it | -| **Owns: it asks at the server's pace, until the code expires** | `waitForApproval()` asks `/me` every `interval` the server gave; `401` ends it as `expired` | -| **Tell it** | `waitForApproval()` · `decline()` | +| **Owns: nothing is kept or sent until the person agrees** | its key lives only in the request; `stage` is `approved(member)` and nothing more until `decline()` removes it, or `LeaderboardMembership.join(through:sharing:)`, once its own checks pass, tells it `handOver()` | +| **Owns: it leaves `approved` once** | `handOver()` answers the member, the key and the member's `/me` answer, and moves itself to `joined`; refused before approval and after | +| **Owns: it asks at the server's pace, until the code expires** | `waitForApproval()` asks `/me` every `interval` the server gave, with its own wait (faked in its tests); `401` ends it as `expired` | +| **Tell it** | `waitForApproval()` · `decline()` · `handOver()` (only `LeaderboardMembership.join(through:sharing:)` does) | | **It answers** | `code` · `stage` | | **Never** | saves its key · uploads · is made while this Mac is a member | From 9d7df8c4ec4469e7d243a89833e7e3801a4e29af Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 11 Oct 2026 11:45:09 +0800 Subject: [PATCH 6/6] refactor(leaderboard): expose abilities, not rules: removals(of:) and internal device rules (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LeaderboardMembership.removals(of:) answers the dialog's choices (Removal: keepingItsDays, deletingItsDays; none for a removed device), and remove(_:_:) takes one, refusing what it didn't offer (notOffered). It replaces offersDeletingDays(whenRemoving:) and the deletingDays flag. - MemberDevices' rules (joined, isInFirstWeek, added(since:), removing, adding, device(withKey:)) and Device.removed(by:at:) are internal: a view lists the devices and asks the membership, never the rules. - design.md: §2's tells and answers, §3's tells, §4's owner. Co-Authored-By: Claude Opus 5.5 --- Modules/Leaderboard/Sources/Device.swift | 11 +++++- .../Leaderboard/Sources/LeaderboardAPI.swift | 3 ++ .../Sources/LeaderboardMembership.swift | 31 ++++++++------- .../Leaderboard/Sources/MemberDevices.swift | 14 ++++--- .../Tests/LeaderboardDevicesTests.swift | 39 ++++++++++++++----- docs/features/leaderboard/design.md | 14 ++++--- 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/Modules/Leaderboard/Sources/Device.swift b/Modules/Leaderboard/Sources/Device.swift index 396956ea4..83a4ac9bd 100644 --- a/Modules/Leaderboard/Sources/Device.swift +++ b/Modules/Leaderboard/Sources/Device.swift @@ -22,7 +22,7 @@ public struct Device: Sendable, Equatable, Codable { public var isRemoved: Bool { removedAt != nil } /// This device once `by` removed it at `time`, as `/me` will list it. - public func removed(by: DeviceRef, at time: Date) -> Device { + func removed(by: DeviceRef, at time: Date) -> Device { Device(publicKey: publicKey, label: label, addedAt: addedAt, removedAt: time, removedBy: by) } @@ -210,3 +210,12 @@ enum ServerTime { return iso.date(from: text) } } + +/// How a device is removed, as the dialog offers it (design §2a): its key is +/// revoked either way; its days stay, or are deleted once it is removed. +public enum Removal: Sendable, Equatable, CaseIterable { + /// *Remove*: its days stay and count until deleted. + case keepingItsDays + /// *Remove and delete its days*: offered only for another device in its first week. + case deletingItsDays +} diff --git a/Modules/Leaderboard/Sources/LeaderboardAPI.swift b/Modules/Leaderboard/Sources/LeaderboardAPI.swift index 7955a3405..b9b1cf436 100644 --- a/Modules/Leaderboard/Sources/LeaderboardAPI.swift +++ b/Modules/Leaderboard/Sources/LeaderboardAPI.swift @@ -197,6 +197,8 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case notJoined /// This Mac is already a member's device, so it doesn't ask to be added. case alreadyJoined + /// A removal the dialog didn't offer for that device. + case notOffered /// The server refused the signature: the key no longer matches the name. case unauthorized /// Another device of the member removed this one: its key is revoked. @@ -221,6 +223,7 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError { case .nothingShared: "Pick at least one provider to share." case .notJoined: "You haven't joined the leaderboard." case .alreadyJoined: "This Mac is already on the leaderboard." + case .notOffered: "That isn't offered for this device." case .unauthorized: "The leaderboard didn't accept this Mac's key for your username." case .removed(let device): "\(device.label) removed this Mac from your leaderboard devices." case .codeExpired: "The code expired before it was approved. Ask for a new one." diff --git a/Modules/Leaderboard/Sources/LeaderboardMembership.swift b/Modules/Leaderboard/Sources/LeaderboardMembership.swift index 41c889902..7a3e5c8aa 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -339,19 +339,24 @@ public final class LeaderboardMembership { save() } - /// Whether removing `device` offers *Remove and delete its days* beside - /// *Remove*: only for another device still in its first week, which a - /// stranger's device approved by mistake would be. - public func offersDeletingDays(whenRemoving device: Device) -> Bool { - device.publicKey != thisDevice?.publicKey && devices.isInFirstWeek(device, at: now()) - } - - /// Removes `device`: its key is revoked, and its days stay unless - /// `deletingDays`, which deletes them once it is removed. If deleting - /// fails, the device is removed all the same and the error says why. - /// Removing this device forgets the membership once the server confirmed. - public func remove(device: Device, deletingDays: Bool = false) async throws { + /// How removing `device` may go, as the dialog offers it: *Remove*, and + /// beside it *Remove and delete its days* only for another device still + /// in its first week, which a stranger's device approved by mistake would + /// be. Nothing for a device already removed. + public func removals(of device: Device) -> [Removal] { + guard !device.isRemoved else { return [] } + let isAnother = device.publicKey != thisDevice?.publicKey + return isAnother && devices.isInFirstWeek(device, at: now()) ? [.keepingItsDays, .deletingItsDays] : [.keepingItsDays] + } + + /// Removes `device` as `removal` says, one of its `removals(of:)`: its key + /// is revoked, and its days stay unless `deletingItsDays`, which deletes + /// them once it is removed. If deleting fails, the device is removed all + /// the same and the error says why. Removing this device forgets the + /// membership once the server confirmed. + public func remove(_ device: Device, _ removal: Removal = .keepingItsDays) async throws { guard let credentials else { throw LeaderboardError.notJoined } + guard removals(of: device).contains(removal) else { throw LeaderboardError.notOffered } try await api.removeDevice(device.publicKey.value, as: credentials) if device.publicKey == credentials.key.deviceKey { forget() @@ -360,7 +365,7 @@ public final class LeaderboardMembership { let by = DeviceRef(publicKey: credentials.key.deviceKey, label: thisDevice?.label ?? suggestedLabel) devices = devices.removing(device, by: by, at: now()) changesMadeHere += 1 - if deletingDays { + if removal == .deletingItsDays { try await api.deleteDays(of: device.publicKey.value, provider: nil, day: nil, as: credentials) } } diff --git a/Modules/Leaderboard/Sources/MemberDevices.swift b/Modules/Leaderboard/Sources/MemberDevices.swift index 9dac997e5..965c007e7 100644 --- a/Modules/Leaderboard/Sources/MemberDevices.swift +++ b/Modules/Leaderboard/Sources/MemberDevices.swift @@ -4,6 +4,8 @@ import Foundation /// removed ones included. It owns the rules about them: which one joined, who /// is still in their first week, and which were added since a device. A device /// in its own first week reads only itself, so it asks these about others only. +/// A view only lists the devices: the rules are the membership's to ask, so +/// they are internal to the module, and the view asks the membership. public struct MemberDevices: Sendable, Equatable, RandomAccessCollection { /// A device added less than this long ago can't yet act for the member. static let firstWeek: TimeInterval = 7 * 24 * 60 * 60 @@ -20,32 +22,32 @@ public struct MemberDevices: Sendable, Equatable, RandomAccessCollection { /// The device that joined: the member's earliest, since every other one /// was added after it. Removed or not, `/me` still lists it. - public var joined: Device? { + var joined: Device? { devices.min { $0.addedAt < $1.addedAt } } - public func device(withKey publicKey: DeviceKey) -> Device? { + func device(withKey publicKey: DeviceKey) -> Device? { devices.first { $0.publicKey == publicKey } } /// Added less than a week before `now`. The device that joined is the /// member, so it is never held to a first week. - public func isInFirstWeek(_ device: Device, at now: Date) -> Bool { + func isInFirstWeek(_ device: Device, at now: Date) -> Bool { device.publicKey != joined?.publicKey && now.timeIntervalSince(device.addedAt) < Self.firstWeek } /// The devices still in use that were added after `device`: news to it. - public func added(since device: Device) -> [Device] { + func added(since device: Device) -> [Device] { devices.filter { !$0.isRemoved && $0.publicKey != device.publicKey && $0.addedAt > device.addedAt } } /// These devices once `by` removed `device` at `time`. - public func removing(_ device: Device, by: DeviceRef, at time: Date) -> MemberDevices { + func removing(_ device: Device, by: DeviceRef, at time: Date) -> MemberDevices { MemberDevices(devices.map { $0.publicKey == device.publicKey ? $0.removed(by: by, at: time) : $0 }) } /// These devices with `device`, unless it is already listed. - public func adding(_ device: Device) -> MemberDevices { + func adding(_ device: Device) -> MemberDevices { self.device(withKey: device.publicKey) == nil ? MemberDevices(devices + [device]) : self } } diff --git a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift index d7df35232..9d20abe1e 100644 --- a/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -227,16 +227,16 @@ struct LeaderboardDevicesTests { // MARK: - Removing a device - @Test func `should offer to delete the days only of another device still in its first week`() async throws { + @Test func `should offer deleting its days only when removing another device still in its first week`() async throws { let membership = try await joined() let me = try thisDevice(membership, addedOn: 1) let older = device("k2", addedOn: 5) let recent = device("k3", addedOn: 17) try await read([me, older, recent], into: membership) - #expect(membership.offersDeletingDays(whenRemoving: recent)) - #expect(!membership.offersDeletingDays(whenRemoving: older)) - #expect(!membership.offersDeletingDays(whenRemoving: me)) + #expect(membership.removals(of: recent) == [.keepingItsDays, .deletingItsDays]) + #expect(membership.removals(of: older) == [.keepingItsDays]) + #expect(membership.removals(of: me) == [.keepingItsDays]) } @Test func `should never count the device that joined as in its first week`() async throws { @@ -246,7 +246,26 @@ struct LeaderboardDevicesTests { // This device was added a day ago, so it is the one in its first week; the one that joined isn't. try await read([joinedDevice, me], into: membership) - #expect(!membership.offersDeletingDays(whenRemoving: joinedDevice)) + #expect(membership.removals(of: joinedDevice) == [.keepingItsDays]) + } + + @Test func `should offer nothing for a device already removed`() async throws { + let membership = try await joined() + let removed = device("k2", addedOn: 5, removedOn: 9) + try await read([thisDevice(membership, addedOn: 1), removed], into: membership) + + #expect(membership.removals(of: removed).isEmpty) + } + + @Test func `should refuse to delete the days of a device it didn't offer that for, and keep it in use`() async throws { + let membership = try await joined() + let older = device("k2", addedOn: 5) + try await read([thisDevice(membership, addedOn: 1), older], into: membership) + + await #expect(throws: LeaderboardError.notOffered) { try await membership.remove(older, .deletingItsDays) } + + #expect(server.removed.isEmpty && server.deletedDays.isEmpty) + #expect(membership.devices.first { $0.publicKey == DeviceKey("k2") }?.isRemoved == false) } @Test func `should remove another device and keep its days`() async throws { @@ -255,7 +274,7 @@ struct LeaderboardDevicesTests { let older = device("k2", addedOn: 5) try await read([me, older], into: membership) - try await membership.remove(device: older, deletingDays: false) + try await membership.remove(older, .keepingItsDays) #expect(server.removed == ["k2"]) #expect(server.deletedDays.isEmpty) @@ -268,7 +287,7 @@ struct LeaderboardDevicesTests { let recent = device("k3", addedOn: 17) try await read([thisDevice(membership, addedOn: 1), recent], into: membership) - try await membership.remove(device: recent, deletingDays: true) + try await membership.remove(recent, .deletingItsDays) #expect(server.removed == ["k3"]) #expect(server.deletedDays == [.init(publicKey: "k3", provider: nil, day: nil)]) @@ -280,7 +299,7 @@ struct LeaderboardDevicesTests { try await read([thisDevice(membership, addedOn: 1), recent], into: membership) server.deleteDaysFailure = .unreachable - await #expect(throws: LeaderboardError.unreachable) { try await membership.remove(device: recent, deletingDays: true) } + await #expect(throws: LeaderboardError.unreachable) { try await membership.remove(recent, .deletingItsDays) } #expect(server.removed == ["k3"]) #expect(membership.devices.first { $0.publicKey == DeviceKey("k3") }?.isRemoved == true) @@ -291,7 +310,7 @@ struct LeaderboardDevicesTests { let me = try thisDevice(membership, addedOn: 1) try await read([me, device("k2", addedOn: 5)], into: membership) - try await membership.remove(device: me) + try await membership.remove(me) #expect(server.removed == [me.publicKey.value]) #expect(!membership.isJoined) @@ -304,7 +323,7 @@ struct LeaderboardDevicesTests { try await read([me], into: membership) server.removeFailure = .lastDevice - await #expect(throws: LeaderboardError.lastDevice) { try await membership.remove(device: me) } + await #expect(throws: LeaderboardError.lastDevice) { try await membership.remove(me) } #expect(membership.isJoined) #expect(keys.stored != nil) diff --git a/docs/features/leaderboard/design.md b/docs/features/leaderboard/design.md index bb8c9947e..33cff2309 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -169,8 +169,8 @@ The person thinks of the board as something their app has: what they saw a minut | **Owns: only shareable providers can be shared** | `share(_:)` refuses a provider with no usage history; the ability is absent, not ignored | | **Owns: a turned-off Leaderboard uploads nothing** | `uploadCredentials` is `nil` while off, so the uploader has nothing to sign with and `lastUpload` stays where uploads stopped | | **Owns: a copied key it detects never uploads unasked** | at launch, a key whose `machine` hash isn't this Mac's uploads nothing until the member answers: this Mac gets its own key, or keeps this one and records its hash ([§2a](#2a--devices-one-member-several-machines)); every key made here is recorded with this Mac's hash, and forgetting a key forgets its hash | -| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` (answers a `DeviceRequest`) · `join(through:sharing:)` · `pendingDevice(code:)` · `approve(code:)` · `markShown(_:)` · `remove(device:deletingDays:)` · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | -| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `addedDevices` · `offersDeletingDays(whenRemoving:)` · `removedBy` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | +| **Tell it** | `join(as:sharing:)` · `share(_:)` · `stopSharing(_:)` · `setVisible(_:)` · `rename(to:)` · `leave()` · `turnOff()` · `turnOn()` · `requestToJoin(label:)` (answers a `DeviceRequest`) · `join(through:sharing:)` · `pendingDevice(code:)` · `approve(code:)` · `markShown(_:)` · `remove(_:_:)` (one of its `removals(of:)`) · `deleteDays(of:provider:day:)` · `becomeOwnDevice()` · `keepKeyHere()` | +| **It answers** | `isJoined` · `isOn` · `sharing` · `country` · `devices` · `addedDevices` · `removals(of:)` (the dialog's choices for a device) · `removedBy` · `summary(period:provider:)` (the signed `/me` read, which it follows; `Board.read()` and Settings' export use it, and the tab never calls it) · `refused` (days the server refused, tried again) · `holdsCopiedKey` (this Mac's key came from another Mac) | | **Never** | holds a ranking · sends a provider it was not told to share · forgets its key before the server confirmed the leave or the removal · keeps a member setting the server has changed since | ### `MemberDevices`: the member's devices, as `/me` last listed them @@ -182,7 +182,7 @@ The person thinks of the board as something their app has: what they saw a minut | **Owns: the device that joined is never held to a first week** | `joined` is the earliest device, removed or not: every other one was added after it, and `/me` lists removed devices too | | **Owns: an added device is in its first week for 7 days** | `isInFirstWeek(_:at:)`; a device in its own first week reads only itself, so it asks this about others only | | **Owns: what is news to a device** | `added(since:)`: the devices still in use added after it | -| **Tell it** | `removing(_:by:at:)` (a device's removal, as `/me` will list it) · `adding(_:)` (a device just approved, once) | +| **Tell it** (only `LeaderboardMembership` does: its rules are the module's, never a view's) | `removing(_:by:at:)` (a device's removal, as `/me` will list it) · `adding(_:)` (a device just approved, once) | | **Never** | decides what was already shown: that is this device's own, `LeaderboardMembership.shownDevices` | ### `DeviceRequest`: this Mac asking to be added to a member @@ -423,8 +423,10 @@ try await request.decline() // or not: let pending = try await membership.pendingDevice(code: request.code) // on a joined device: the label and when it asked try await membership.approve(code: request.code) // from a device past its first week membership.addedDevices // each device added since this one last looked, once -try await membership.remove(device: oldMac, deletingDays: false) // revokes its key; its days stay -try await membership.remove(device: stranger, deletingDays: true) // another device in its first week: removes, then deletes its days +membership.removals(of: stranger) // [.keepingItsDays, .deletingItsDays]: another device in its first week +membership.removals(of: oldMac) // [.keepingItsDays]; none for a device already removed +try await membership.remove(oldMac, .keepingItsDays) // revokes its key; its days stay +try await membership.remove(stranger, .deletingItsDays) // removes, then deletes its days; refused when not offered try await membership.deleteDays(of: oldMac, provider: "claude", day: nil) // a removed device's; never a side effect try await membership.becomeOwnDevice() // the key's machine hash isn't this Mac's: Make this Mac its own device try await membership.keepKeyHere() // or: the other Mac is gone, or this is the same Mac after a repair @@ -483,7 +485,7 @@ let days = membership.dailyTokens(from: usageHistories, in: range) | A member has at most 5 devices that aren't removed | Server | | A device is added only when another device of the member, past its first week, approves the code it shows, within 10 minutes, once; the new device names the member and asks before it uploads | Server, and the new device: `DeviceRequest` (waits, names the member, keeps nothing), then `LeaderboardMembership.join(through:sharing:)` | | For its first 7 days an added device changes and reads only its own days and settings, with the member's settings and place: `/me` leaves out the other devices and their rows, and `/me/export` is refused; it can't rename or hide the member, change its globe or link, approve a device, remove another, delete a removed device's days, or leave. After that, and from the start for the device that joined, it may do what the member may | Server | -| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.offersDeletingDays(whenRemoving:)` for the dialog, from `MemberDevices.isInFirstWeek(_:at:)` | +| Removing a device revokes its key at once and says which device removed it; its days stay and count until deleted; removing another device in its first week, the app offers *Remove and delete its days* beside *Remove*; the last device can't be removed | Server, and `LeaderboardMembership.removals(of:)` for the dialog's choices, from `MemberDevices.isInFirstWeek(_:at:)`; `remove(_:_:)` refuses a removal it didn't offer | | Only a removed device's days can be deleted, by another device of the member past its first week, for one provider, one day or all | Server | | Every device in use past its first week shows, once, each device added since it last read `/me`, with **Remove**; a device in its first week sees only itself | `LeaderboardMembership.addedDevices`: `MemberDevices.added(since:)` this device, less the ones kept as `shownDevices` | | A device whose key the server no longer knows forgets its membership; one another device removed also says which | `LeaderboardUploader`, as for a member the server forgot; `LeaderboardMembership.removedBy`, the `DeviceRef` the server named |