Repository navigation
feat(leaderboard): add and remove devices, in the app's domain (slice 13, #507) #540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4e14e21
feat(leaderboard): add and remove devices, in the app's domain (slice…
LunarECL fbb0341
refactor(leaderboard): device rules on MemberDevices, typed labels, I…
hanrw 4067a48
refactor(leaderboard): DeviceKey for a device's identity, and an inje…
hanrw 2226ec4
refactor(leaderboard): a new Mac's way in is a DeviceRequest (#540)
hanrw e3268bc
refactor(leaderboard): the request hands itself over; it owns its wai…
hanrw 9d7df8c
refactor(leaderboard): expose abilities, not rules: removals(of:) and…
hanrw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Key: CodingKey>(from container: KeyedDecodingContainer<Key>, 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<Key: CodingKey>(from container: KeyedDecodingContainer<Key>, 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.