diff --git a/Modules/Leaderboard/Sources/Device.swift b/Modules/Leaderboard/Sources/Device.swift new file mode 100644 index 000000000..83a4ac9bd --- /dev/null +++ b/Modules/Leaderboard/Sources/Device.swift @@ -0,0 +1,221 @@ +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 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: DeviceKey, label: DeviceLabel, 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 } + + /// This device once `by` removed it at `time`, as `/me` will list it. + 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(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) + removedBy = try container.decodeIfPresent(DeviceRef.self, forKey: .removedBy) + } + + /// 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) + 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: DeviceKey + public let 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 +/// 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.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 } +} + +/// 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: DeviceLabel + public let requestedAt: Date + + public init(label: DeviceLabel, 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(DeviceLabel.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: 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 { + 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() + iso.formatOptions.insert(.withFractionalSeconds) + if let date = iso.date(from: text) { return date } + iso.formatOptions.remove(.withFractionalSeconds) + 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/DeviceRequest.swift b/Modules/Leaderboard/Sources/DeviceRequest.swift new file mode 100644 index 000000000..d60a8c3dd --- /dev/null +++ b/Modules/Leaderboard/Sources/DeviceRequest.swift @@ -0,0 +1,114 @@ +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 + } + + /// 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 + 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 = { try await Task.sleep(for: .seconds($0)) }) { + 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 + } + + /// 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/Internal/LeaderboardHTTPClient.swift b/Modules/Leaderboard/Sources/Internal/LeaderboardHTTPClient.swift index 247ded4ed..f90ce0e90 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) } ?? .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..b9b1cf436 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 @@ -190,8 +195,24 @@ 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 + /// 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. + 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. + 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 @@ -201,25 +222,50 @@ 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 .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." + 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..7a3e5c8aa 100644 --- a/Modules/Leaderboard/Sources/LeaderboardMembership.swift +++ b/Modules/Leaderboard/Sources/LeaderboardMembership.swift @@ -27,7 +27,14 @@ 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 = 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: DeviceRef? private var key: SigningKey? + /// Devices already shown as added, by key: each is shown once. + private var shownDevices: Set = [] /// Changes made here, counted, so a `/me` answer that crossed one is /// not taken over it. private var changesMadeHere = 0 @@ -36,15 +43,20 @@ public final class LeaderboardMembership { @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 +78,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 +98,53 @@ 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 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) + 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 !providers.isEmpty else { throw LeaderboardError.nothingShared } + try requireShareable(providers) + let approval = try request.handOver() + keys.save(approval.key.rawRepresentation) + key = approval.key + username = approval.member + sharing = providers + isVisible = true + lastUpload = nil + refused = [] + sharesCountry = false + globeHintDismissed = false + link = nil + startFresh() + follow(approval.summary) + } + + /// A membership this device starts: no devices known or shown yet, and no + /// word of an earlier one's removal. + private func startFresh() { + devices = MemberDevices() + 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 +161,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 device: DeviceRef) { + forget() + removedBy = device + } + private func forget() { keys.delete() key = nil @@ -114,6 +179,8 @@ public final class LeaderboardMembership { sharesCountry = false globeHintDismissed = false link = nil + devices = MemberDevices() + shownDevices = [] settings.saveLeaderboardRecord(nil) } @@ -228,9 +295,88 @@ public final class LeaderboardMembership { country = summary.country } if summary.reportsLink { link = summary.link } + devices = MemberDevices(summary.devices) save() } + // MARK: - Devices (design §2a) + + /// This device as `/me` lists it, once read. + public var thisDevice: Device? { + key.flatMap { devices.device(withKey: $0.deviceKey) } + } + + /// 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) + devices = devices.adding(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.added(since: me).filter { !shownDevices.contains($0.publicKey) } + } + + /// `device` was shown as added, so it isn't again. + public func markShown(_ device: Device) { + shownDevices.insert(device.publicKey) + save() + } + + /// 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() + return + } + let by = DeviceRef(publicKey: credentials.key.deviceKey, label: thisDevice?.label ?? suggestedLabel) + devices = devices.removing(device, by: by, at: now()) + changesMadeHere += 1 + if removal == .deletingItsDays { + try await api.deleteDays(of: device.publicKey.value, 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.value, provider: provider, day: day, as: credentials) + } + // MARK: - Private private func requireShareable(_ providers: Set) throws { @@ -252,6 +398,7 @@ public final class LeaderboardMembership { globeHintDismissed = record.globeHintDismissed link = record.link refused = record.refused + shownDevices = Set(record.shownDevices.map(DeviceKey.init)) } private func save() { @@ -259,6 +406,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: shownDevices.map(\.value))) } } 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..1ff019529 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 device) { + membership.forgetRemoved(by: device) + 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/MemberDevices.swift b/Modules/Leaderboard/Sources/MemberDevices.swift new file mode 100644 index 000000000..965c007e7 --- /dev/null +++ b/Modules/Leaderboard/Sources/MemberDevices.swift @@ -0,0 +1,53 @@ +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. +/// 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 + + 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. + var joined: Device? { + devices.min { $0.addedAt < $1.addedAt } + } + + 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. + 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. + 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`. + 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. + func adding(_ device: Device) -> MemberDevices { + self.device(withKey: device.publicKey) == nil ? MemberDevices(devices + [device]) : self + } +} 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/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/DeviceServer.swift b/Modules/Leaderboard/Tests/DeviceServer.swift new file mode 100644 index 000000000..bf599ebbe --- /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: DeviceLabel("Mac mini")!, requestedAt: Date(timeIntervalSince1970: 1_791_106_200)) + var approved = Device(publicKey: DeviceKey("newkey"), label: DeviceLabel("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..59a34168f --- /dev/null +++ b/Modules/Leaderboard/Tests/DeviceTests.swift @@ -0,0 +1,109 @@ +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) + } + + /// 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 "]) + 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 == 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: DeviceKey("k1"), label: DeviceLabel("MacBook Pro")!)) + } + + /// 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)) + + #expect(device.addedAt == Date(timeIntervalSince1970: 1_790_841_600)) + } + + @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 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":"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)) + + 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..9d20abe1e --- /dev/null +++ b/Modules/Leaderboard/Tests/LeaderboardDevicesTests.swift @@ -0,0 +1,342 @@ +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: DeviceKey(key), label: DeviceLabel("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: DeviceKey(key), label: DeviceLabel("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 request = try await membership.requestToJoin() + + #expect(request.code == server.authorization.code) + #expect(server.requested?.label == "Mac mini") + #expect(request.stage == .waiting) + #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))] + let request = try await membership.requestToJoin() + + let member = try await request.waitForApproval() + + #expect(member.value == "tokenwhale") + #expect(server.approvalsAsked == 3) + #expect(request.stage == .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)))] + let request = try await membership.requestToJoin() + _ = try await request.waitForApproval() + + try membership.join(through: request, sharing: ["codex"]) + + #expect(membership.isJoined) + #expect(request.stage == .joined) + #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 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 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))] + let request = try await membership.requestToJoin() + _ = try await request.waitForApproval() + + try await request.decline() + + #expect(server.removed == [server.requested?.publicKey]) + #expect(request.stage == .declined) + #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 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.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 { + 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.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 { + 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(older, .keepingItsDays) + + #expect(server.removed == ["k2"]) + #expect(server.deletedDays.isEmpty) + #expect(membership.devices.first { $0.publicKey == DeviceKey("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(recent, .deletingItsDays) + + #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(recent, .deletingItsDays) } + + #expect(server.removed == ["k3"]) + #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 { + 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(me) + + #expect(server.removed == [me.publicKey.value]) + #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(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..89b7991b1 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: 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 { + 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: 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 { + 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: DeviceKey("k1"), label: DeviceLabel("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: DeviceRef(publicKey: DeviceKey("k1"), label: DeviceLabel("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..5f6beab7d 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: DeviceRef(publicKey: DeviceKey("k2"), label: DeviceLabel("Mac mini")!))) + + await uploader(membership).uploadDue() + + #expect(!membership.isJoined) + #expect(membership.removedBy == DeviceRef(publicKey: DeviceKey("k2"), label: DeviceLabel("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) + 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 + 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/MemberDevicesTests.swift b/Modules/Leaderboard/Tests/MemberDevicesTests.swift new file mode 100644 index 000000000..c5e0c5392 --- /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: DeviceKey(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 == DeviceKey("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: 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: DeviceKey("k2")) + #expect(removed?.removedAt == today) + #expect(removed?.removedBy == by) + #expect(devices.device(withKey: DeviceKey("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) == [DeviceKey("k1"), DeviceKey("k2")]) + } +} 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/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/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..33cff2309 100644 --- a/docs/features/leaderboard/design.md +++ b/docs/features/leaderboard/design.md @@ -87,7 +87,7 @@ 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 │ └─ 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 @@ -95,7 +95,10 @@ 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 + ├─ 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 @@ -166,10 +169,37 @@ 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:)` (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 + +> **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** (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 + +> **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 `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 | + ### `DailyTokens`: one provider's day > **Pointable as:** a line on a tally sheet: provider, date, four counts. @@ -188,7 +218,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). @@ -217,6 +247,48 @@ 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) + │ + ├─ 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, the `DeviceRequest` is where it stands. Each stage holds what the next needs, so a stage without it can't be reached: + +``` + membership.requestToJoin 200 approved membership.join(through:) + ─────────────────────▶ waiting ─────────────────────▶ approved(member) ──────────────────────────▶ joined + │ ▲ │ + └───┘ 202: wait `interval` └─ decline() ─▶ declined + │ + └─ 401 ─▶ expired +``` + +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. - A member has at most 5 devices that aren't removed (Signal links 5, WhatsApp 4). A sixth approve is refused (`409 deviceLimit`). @@ -343,11 +415,18 @@ 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 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 -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 +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 +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 @@ -404,16 +483,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: `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 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.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.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`: `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 | `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, 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()` | @@ -440,6 +519,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: ``` @@ -521,10 +602,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 │ @@ -546,19 +629,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/` | +| `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/` | -| `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.