From 7d1d375888b3694177b38a2162721eaa6b66abf8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:19:57 -0400 Subject: [PATCH] fix(username): accept handles up to the contract's 30 characters common.v1.Username allows ^[a-z0-9_]{2,30}$ since the flipcash2 0.22.0 contract, but Username still matched {2,15}. The server now assigns handles like ted_livingston_2 (16 chars) to new accounts, so the welcome chat's share-profile widget failed to parse and drew as "This Message Needs a Newer Version of Flipcash", and the handle could not be looked up, mentioned, or opened from a flipcash.com link. Username and the mention pattern now take 2-30. Claiming stays capped at 15: UsernameValidator enforces the cap itself instead of leaning on Username, and the lookup screen validates against Username.maximumLength so assigned handles past the cap can still be found. --- .../Main/Username/UsernameLookupScreen.swift | 3 +- .../Models/Chat/MentionDetector.swift | 2 +- .../FlipcashCore/Models/Username.swift | 7 +++-- .../Validation/UsernameValidator.swift | 31 +++++++++++++------ .../ConversationModelMappingTests.swift | 8 +++++ .../MentionDetectorTests.swift | 7 +++-- .../FlipcashCoreTests/UsernameTests.swift | 5 +-- .../UsernameValidatorTests.swift | 17 ++++++++-- FlipcashTests/RouteTests.swift | 7 +++-- 9 files changed, 64 insertions(+), 23 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift index 5f652b000..ddd0a4abf 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift @@ -37,7 +37,8 @@ struct UsernameLookupScreen: View { @Environment(Session.self) private var session @Environment(AppRouter.self) private var router - private static let validator = UsernameValidator() + // Any handle the server can hold, including assigned ones past the claim cap. + private static let validator = UsernameValidator(maximumLength: Username.maximumLength) @FocusState private var isFocused: Bool @State private var input: String = "" diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift index 50d073030..51dbacd47 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift @@ -51,5 +51,5 @@ public enum MentionDetector { // Swift Regex has no lookbehind, so the preceding character is matched (and excluded from // the mention's range) instead. Case folds after the match; `Username` stores lowercase. - private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])/ + private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,30}))(?![A-Za-z0-9_@])/ } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Username.swift b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift index b0aab76f1..b6a9fe260 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Username.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift @@ -14,7 +14,7 @@ import FlipcashAPI /// not just the caller. public struct Username: Codable, Equatable, Hashable, Sendable { - /// The handle as the server stores it: lowercase, 2–15 characters. + /// The handle as the server stores it: lowercase, 2–30 characters. public let value: String /// Returns the handle `string` names, or `nil` when it isn't well formed. @@ -39,9 +39,12 @@ public struct Username: Codable, Equatable, Hashable, Sendable { URL(string: "https://flipcash.com/\(value)")! } + /// The longest handle the contract allows; `pattern`'s upper bound. + public static let maximumLength = 30 + // Mirrors the `validate.rules.string.pattern` on `common.v1.Username`; // keep the two in step when the contract moves. - private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,15}$/ + private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,30}$/ } // MARK: - Codable - diff --git a/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift b/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift index ab7812a06..1c92ca6f1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift +++ b/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift @@ -21,25 +21,31 @@ public struct UsernameValidator: Validator { case invalidCharacters } - /// Mirrors `Username`'s own bounds, which come from the `validate.rules` - /// on `common.v1.Username`. Public because the rejection copy names them. - /// `UsernameValidatorTests` pins them to what `Username` actually accepts, - /// so the two can't drift apart unnoticed. + /// The bounds a claimed handle must fit. The minimum is `Username`'s own; the + /// maximum is a product cap below the 30 that `common.v1.Username` allows, so + /// handles the server assigns can be longer than any a user may claim. + /// Public because the rejection copy names them. public static let minimumLength = 2 public static let maximumLength = 15 - public init() {} + private let lengthCap: Int + + /// Holds input to `maximumLength`, or to `Username.maximumLength` for a field that + /// looks up an existing handle rather than claiming one. + public init(maximumLength: Int = Self.maximumLength) { + self.lengthCap = maximumLength + } /// The handle `input` names once normalised, or `nil` when it isn't well /// formed. This exact value is what gets submitted; the raw input stays on /// screen. public func validate(_ input: String) -> Username? { - Username(normalized(input)) + claimable(normalized(input)) } /// Which rule `input` breaks, or `nil` when it breaks none. /// - /// Asks ``validate(_:)`` first, so a `nil` here and a non-nil there can + /// Runs the same check ``validate(_:)`` does first, so a `nil` here and a non-nil there can /// never disagree. Past that, length is reported ahead of the character /// set: "too short" explains a one-letter input better than "invalid /// characters" would, and an over-long input is over-long whatever it is @@ -47,13 +53,20 @@ public struct UsernameValidator: Validator { public func failure(for input: String) -> Failure? { let candidate = normalized(input) - guard Username(candidate) == nil else { return nil } + guard claimable(candidate) == nil else { return nil } if candidate.count < Self.minimumLength { return .tooShort } - if candidate.count > Self.maximumLength { return .tooLong } + if candidate.count > lengthCap { return .tooLong } return .invalidCharacters } + /// The handle `candidate` names within this validator's cap. `Username` also accepts the + /// contract's longer server-assigned handles, so a claim's lower cap is held here. + private func claimable(_ candidate: String) -> Username? { + guard candidate.count <= lengthCap else { return nil } + return Username(candidate) + } + /// Lowercased, stripped of surrounding whitespace, and of one leading `@`. /// A paste carries all three — a handle copied out of a profile arrives as /// `@taylor` — and none is worth rejecting someone over. Only one `@` goes: diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 389ed3015..35075984f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -1010,6 +1010,14 @@ struct ConversationMessageContentEncodingTests { #expect(Flipcash_Messaging_V1_WidgetContent().shareProfile == nil) } + @Test("A share-profile widget keeps a handle longer than the claim cap") + func shareProfileWidgetKeepsLongHandle() throws { + let proto = Flipcash_Messaging_V1_WidgetContent.with { + $0.shareProfile = .with { $0.username = .with { $0.value = "ted_livingston_2" } } + } + #expect(ConversationMessage.Widget(proto) == .shareProfile(ShareProfileWidget(username: try #require(Username("ted_livingston_2"))))) + } + @Test("A share-profile widget message is kept as a widget") func widgetMessageIsKept() throws { let proto = Flipcash_Messaging_V1_Message.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift index 30e5849e0..0268cd0de 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift @@ -49,11 +49,12 @@ struct MentionDetectorTests { #expect(handles("@jeff@") == []) } - @Test("Handles outside 2–15 characters are not mentions") + @Test("Handles outside 2–30 characters are not mentions") func lengthBounds() { #expect(handles("@a") == []) - #expect(handles("@abcdefghijklmnop") == []) - #expect(handles("@abcdefghijklmno") == ["abcdefghijklmno"]) + #expect(handles("@abcdefghijklmnopqrstuvwxyz12345") == []) + #expect(handles("@abcdefghijklmnopqrstuvwxyz1234") == ["abcdefghijklmnopqrstuvwxyz1234"]) + #expect(handles("@ted_livingston_2") == ["ted_livingston_2"]) } @Test("Characters a handle cannot hold end it rather than extend it") diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift index 4bb4693e1..c4369fb47 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift @@ -17,7 +17,8 @@ struct UsernameTests { "ted", "ted_1", "t_9", - "abcdefghijklmno", // longest allowed, 15 chars + "ted_livingston_2", // 16 chars, past the claim cap but a valid server handle + "abcdefghijklmnopqrstuvwxyz1234", // longest allowed, 30 chars ]) func acceptsWellFormedHandles(input: String) { #expect(Username(input)?.value == input) @@ -27,7 +28,7 @@ struct UsernameTests { arguments: [ "", // unclaimed, carried as an empty proto value "a", // too short - "abcdefghijklmnop", // 16 chars, too long + "abcdefghijklmnopqrstuvwxyz12345", // 31 chars, too long "Ted", // uppercase — the server stores lowercase only "ted bart", // space "ted-bart", // hyphen is outside the X character set diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift index 47559bf3f..cd7e4f323 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift @@ -97,12 +97,25 @@ struct UsernameValidatorTests { #expect(Username(belowMinimum) == nil) } - @Test("The maximum length agrees with what Username itself accepts") - func maximumLength_matchesUsername_boundary() { + @Test("The claim cap sits within what Username accepts, and the validator enforces it") + func maximumLength_withinUsername_boundary() { let atMaximum = String(repeating: "a", count: UsernameValidator.maximumLength) let aboveMaximum = String(repeating: "a", count: UsernameValidator.maximumLength + 1) #expect(Username(atMaximum) != nil) + #expect(Username(aboveMaximum) != nil) + #expect(UsernameValidator().failure(for: aboveMaximum) == .tooLong) + } + + @Test("A lookup validator accepts a handle past the claim cap, up to Username's own maximum") + func lookupValidator_acceptsServerLengthHandles() { + let lookup = UsernameValidator(maximumLength: Username.maximumLength) + let atMaximum = String(repeating: "a", count: Username.maximumLength) + let aboveMaximum = String(repeating: "a", count: Username.maximumLength + 1) + + #expect(lookup.validate("@Ted_Livingston_2")?.value == "ted_livingston_2") + #expect(lookup.validate(atMaximum) != nil) + #expect(lookup.failure(for: aboveMaximum) == .tooLong) #expect(Username(aboveMaximum) == nil) } diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index f678918da..c9f65ba3e 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -438,7 +438,8 @@ struct RouteTests { @Test("The handle bounds hold at both ends", arguments: [ ("https://app.flipcash.com/te", "te"), - ("https://app.flipcash.com/abcdefghijklmno", "abcdefghijklmno"), + ("https://app.flipcash.com/ted_livingston_2", "ted_livingston_2"), + ("https://app.flipcash.com/abcdefghijklmnopqrstuvwxyz1234", "abcdefghijklmnopqrstuvwxyz1234"), ("https://app.flipcash.com/a_1", "a_1"), ]) func usernameRoute_bounds(urlString: String, handle: String) throws { @@ -451,10 +452,10 @@ struct RouteTests { } @Test("A segment that can't be a handle stays unknown", arguments: [ - // One character, sixteen characters, and a hyphen — the three ways the + // One character, thirty-one characters, and a hyphen — the three ways the // handle pattern is missed. "https://app.flipcash.com/t", - "https://app.flipcash.com/abcdefghijklmnop", + "https://app.flipcash.com/abcdefghijklmnopqrstuvwxyz12345", "https://app.flipcash.com/not-a-handle", // A handle is the whole path or it isn't a handle. "https://app.flipcash.com/taylor/photos",