From 6544015db47434b2c02ea1e14df0f809f056ccf6 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:05:22 -0700 Subject: [PATCH 1/2] Add OptableIdentifier.raw for pre-encoded identifiers .raw("type:value") lets apps send identifier types that have no case, like the Android SDK's Raw. A known type is checked and normalized like its case, so "e" and "p" values must already be SHA256 digests and a plaintext Email or Phone number is never sent. An unknown type is sent as given, and values missing a type or value are dropped. Targeting hids now skip identifiers that encode to an empty string, as ids already did, instead of sending an empty hid=. Adding a case to the public OptableIdentifier enum breaks exhaustive switches in apps, so this is for 2.0.0. Co-Authored-By: Claude Opus 5.5 --- Source/Core/EdgeAPI.swift | 3 +- Source/Core/OptableIdentifierEncoder.swift | 13 +++++++ Source/Public/OptableIdentifier.swift | 5 +++ Tests/Unit/EdgeAPITests.swift | 10 ++++++ .../Unit/OptableIdentifierEncoderTests.swift | 34 +++++++++++++++++++ docs/usage-swift.md | 12 +++++++ 6 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 5e61c85..f5f35e9 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -81,7 +81,8 @@ final class EdgeAPI: Sendable { .compactMap({ URLQueryItem(name: "id", value: $0) }) let hidQueryItems = hids - .compactMap({ $0.extendedIdentifier }) + .map({ $0.extendedIdentifier }) + .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "hid", value: $0) }) queryItems.append(contentsOf: hidQueryItems) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 8a65694..ced15d8 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -30,10 +30,23 @@ enum OptableIdentifierEncoder { case let .optableVID(value): vid(prefix, value) case let .hem(value): hem(prefix, value) case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value) + case let .raw(value): raw(value) } return eid } + /// Builds Extended Identifier from a pre-encoded "type:value" string. + /// A known type is encoded like its case, so an "e" or "p" value that is not a SHA256 digest is dropped. + /// An unknown type is sent as given. Returns an empty string when the type or value is missing. + static func raw(_ value: String) -> String { + if let known = OptableIdentifier(extendedIdentifier: value) { + return eid(known) + } + let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, parts[0].isEmpty == false, parts[1].isEmpty == false else { return "" } + return value + } + /// Builds Extended Identifier from an already-hashed Email address (HEM). /// Returns an empty string when the value is not a SHA256 digest, so that a /// plaintext Email is never sent over the wire. diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 2748120..fb5e49c 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -47,6 +47,10 @@ public enum OptableIdentifier: Sendable { case hem(String) // e, Hashed Email: SHA256 of the normalized Email address case hashedPhoneNumber(String) // p + // Pre-encoded "type:value" Extended Identifier, for types without a case above. + // A known type is checked and normalized like its case ("e" and "p" must already be SHA256 hashes); an unknown one is sent as given. + case raw(String) // type + public var prefix: String { switch self { case .emailAddress: return "e" @@ -67,6 +71,7 @@ public enum OptableIdentifier: Sendable { case .optableVID: return "v" case .hem: return "e" case .hashedPhoneNumber: return "p" + case let .raw(value): return value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "" } } diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 0894270..8f4561d 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -340,6 +340,16 @@ class EdgeAPITests: XCTestCase { } } + func test_targeting_request_raw_identifiers() throws { + let raw: OptableIdentifier = .raw("xyz:abc") + let plaintextEmail: OptableIdentifier = .raw("e:test@foobarbaz.com") + let urlRequest = try sdk.api.targeting(ids: [raw, plaintextEmail], hids: [raw, plaintextEmail]) + let queryItems = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)!.queryItems ?? [] + + XCTAssertEqual(queryItems.filter({ $0.name == "id" }).map(\.value), ["xyz:abc"]) + XCTAssertEqual(queryItems.filter({ $0.name == "hid" }).map(\.value), ["xyz:abc"]) + } + func test_targeting_request_percent_encodes_plus_in_query_values() throws { // The edge decodes a literal `+` in the query as a space, which would corrupt // base64 values such as the ID5 signature; it must go out as %2B on the wire. diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index ea44ed1..1a2931b 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -183,6 +183,40 @@ class OptableIdentifierEncoderTests: XCTestCase { ) } + func test_raw_unknownTypeIsSentAsGiven() { + XCTAssertEqual("xyz:Some Value", SUT.raw("xyz:Some Value")) + XCTAssertEqual("cookie:abc", SUT.raw("cookie:abc")) + XCTAssertEqual("xyz:a:b", SUT.raw("xyz:a:b")) + } + + func test_raw_knownTypeIsEncodedLikeItsCase() { + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + + XCTAssertEqual("e:\(hem)", SUT.raw("e:\(hem.uppercased())")) + XCTAssertEqual("p:\(hem)", SUT.raw("p: \(hem)")) + XCTAssertEqual(OptableIdentifier.ipv4Address(" 8.8.8.8 ").extendedIdentifier, SUT.raw("i4: 8.8.8.8 ")) + XCTAssertEqual(OptableIdentifier.custom(3, " abc ").extendedIdentifier, SUT.raw("c3: abc ")) + } + + func test_raw_rejectsPlaintextEmailAndPhoneNumber() { + // A plaintext Email or Phone number must never reach the wire. + XCTAssertEqual("", SUT.raw("e:test@foobarbaz.com")) + XCTAssertEqual("", SUT.raw("p:+15551234567")) + } + + func test_raw_rejectsMalformedValues() { + XCTAssertEqual("", SUT.raw("xyz")) + XCTAssertEqual("", SUT.raw("xyz:")) + XCTAssertEqual("", SUT.raw(":abc")) + XCTAssertEqual("", SUT.raw("")) + } + + func test_raw_prefix() { + XCTAssertEqual("xyz", OptableIdentifier.raw("xyz:abc").prefix) + XCTAssertEqual("e", OptableIdentifier.raw("e:abc").prefix) + XCTAssertEqual("", OptableIdentifier.raw(":abc").prefix) + } + // MARK: Legacy func test_eidFromURL_isCorrect() { let url = "http://some.domain.com/some/path?some=query&something=else&oeid=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3&foo=bar&baz" diff --git a/docs/usage-swift.md b/docs/usage-swift.md index e318bb9..dd0d7e2 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -157,6 +157,18 @@ The value must be a 64-character hexadecimal SHA-256 digest, computed with the s Do not pass a hash to `.emailAddress` or `.phoneNumber`: those cases always hash their value, so the DCN would receive a hash of the hash. Already-hashed identifiers work the same way with `targeting()`. +#### Pre-encoded identifiers + +To send an identifier type that has no case in `OptableIdentifier`, pass it already encoded as `"type:value"` with `.raw`: + +```swift +let ids: [OptableIdentifier] = [ + .raw("xyz:some-value") +] +``` + +If the type is one the SDK knows, the value is checked and normalized as its case would be: `e` and `p` values must already be SHA-256 digests, like `.hem` and `.hashedPhoneNumber`, so a plain-text email address or phone number is dropped and never sent. A value of an unknown type is sent as given. Values without both a type and a value are dropped. `.raw` works the same way with `identify()`, `targeting()` and its `hids`. + ### Profile API > :information_source: For more info check: From 6d9d1803eb3217f1c63641d47c8851806aaf4095 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:21:49 -0700 Subject: [PATCH 2/2] Drop raw identifiers whose type or value is only whitespace An unknown type with a whitespace-only value, like "xyz: ", was sent as given. It's now dropped, matching #99, which drops typed identifiers that are empty after normalization. Co-Authored-By: Claude Opus 5.5 --- Source/Core/OptableIdentifierEncoder.swift | 6 ++++-- Tests/Unit/OptableIdentifierEncoderTests.swift | 2 ++ docs/usage-swift.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index ced15d8..79920c8 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -37,13 +37,15 @@ enum OptableIdentifierEncoder { /// Builds Extended Identifier from a pre-encoded "type:value" string. /// A known type is encoded like its case, so an "e" or "p" value that is not a SHA256 digest is dropped. - /// An unknown type is sent as given. Returns an empty string when the type or value is missing. + /// An unknown type is sent as given. Returns an empty string when the type or value is missing or only whitespace. static func raw(_ value: String) -> String { if let known = OptableIdentifier(extendedIdentifier: value) { return eid(known) } let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2, parts[0].isEmpty == false, parts[1].isEmpty == false else { return "" } + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + parts[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return "" } return value } diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index 1a2931b..c0d2067 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -209,6 +209,8 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertEqual("", SUT.raw("xyz:")) XCTAssertEqual("", SUT.raw(":abc")) XCTAssertEqual("", SUT.raw("")) + XCTAssertEqual("", SUT.raw("xyz: ")) + XCTAssertEqual("", SUT.raw(" :abc")) } func test_raw_prefix() { diff --git a/docs/usage-swift.md b/docs/usage-swift.md index dd0d7e2..ac1c093 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -167,7 +167,7 @@ let ids: [OptableIdentifier] = [ ] ``` -If the type is one the SDK knows, the value is checked and normalized as its case would be: `e` and `p` values must already be SHA-256 digests, like `.hem` and `.hashedPhoneNumber`, so a plain-text email address or phone number is dropped and never sent. A value of an unknown type is sent as given. Values without both a type and a value are dropped. `.raw` works the same way with `identify()`, `targeting()` and its `hids`. +If the type is one the SDK knows, the value is checked and normalized as its case would be: `e` and `p` values must already be SHA-256 digests, like `.hem` and `.hashedPhoneNumber`, so a plain-text email address or phone number is dropped and never sent. A value of an unknown type is sent as given. Values without both a type and a value, or where either is only whitespace, are dropped. `.raw` works the same way with `identify()`, `targeting()` and its `hids`. ### Profile API