From e5d9ca45b93caa1600f501f27a1f128e2a43ba09 Mon Sep 17 00:00:00 2001 From: Ahsan Ali Date: Sat, 3 Oct 2026 23:26:26 +0100 Subject: [PATCH] Add user identity (setUserId / logout / getUserId) --- CHANGELOG.md | 7 + README.md | 28 + .../linktrail/reactnative/LinkTrailModule.kt | 50 ++ ios/LinkTrailModule.mm | 20 + ios/LinkTrailModuleImpl.swift | 24 + package-lock.json | 508 +++++++++++++++++- package.json | 4 +- src/NativeLinkTrail.ts | 12 + src/identity.ts | 133 +++++ src/index.ts | 37 ++ test/identity.test.ts | 140 +++++ 11 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 src/identity.ts create mode 100644 test/identity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2690f8..e90540f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- **User identity**: `LinkTrail.setUserId(id)`, `LinkTrail.logout()`, `LinkTrail.getUserId()`. + The id is validated (`^[A-Za-z0-9_.:-]{1,128}$`, rejects with `invalid_user_id`), persisted across + launches, and never transmitted while consent is denied/undecided. Delivery needs a native SDK + release with identity support; the pinned native SDKs are unchanged (see README). + ## 0.0.4 - Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.11`** (from `0.0.10`). diff --git a/README.md b/README.md index 25b178f..baa1247 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,34 @@ LinkTrail.setConsent(storedGranted); Set `requireConsent: false` to attribute at init without a consent step. See the example app's [`src/consent.ts`](example/src/consent.ts) for the persist-and-replay pattern. +### User identity + +Link events to your own user with `setUserId` / `logout`: + +```ts +await LinkTrail.setUserId('u_8f3a91c2'); // after login +await LinkTrail.logout(); // after sign-out: clears the id locally, no network call +const current = await LinkTrail.getUserId(); // string | null +``` + +- **Never pass PII.** Use your app's own opaque internal id or a hash — not an email, phone number + or name. +- Valid ids are 1-128 characters from `A-Z a-z 0-9 _ - . :` (`^[A-Za-z0-9_.:-]{1,128}$`). Anything + else rejects with an error whose `code` is `invalid_user_id`; nothing is stored or sent. +- The id is persisted across launches. Calling `setUserId` with a different id replaces it. +- **Consent first:** the id is never transmitted while consent is denied or undecided (it is only + stored locally). Once consent is granted (`setConsent(true)`, or `requireConsent: false`) the id is + sent once to link the device's installs, then attached to later open/custom events. Revoking + consent stops sending it. +- Delivery is best-effort and never throws or blocks your app. + +> **Native SDK requirement.** The native SDKs perform the network calls, so sending the id needs a +> native SDK release with identity support. This wrapper still pins the earlier releases +> (iOS `LinkTrailSDK ~> 0.0.11`, Android `io.linktrail:sdk:0.0.5`); with those the id is validated and +> persisted but **not transmitted**. Android picks up identity support automatically once the pin +> moves to a release exposing `setUserId` / `logout`; iOS additionally needs a small wrapper change +> at that point. + ### Deferred attribution & the paste button (iOS) On iOS, deferred attribution recovers a **click token** the tapped link left on the clipboard. diff --git a/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt b/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt index 6f9673b..2627c83 100644 --- a/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt +++ b/android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt @@ -141,6 +141,56 @@ class LinkTrailModule(reactContext: ReactApplicationContext) : LinkTrail.shared?.setConsent(granted) } + // ── User identity ────────────────────────────────────────────────────────── + + private val identityPrefs + get() = reactApplicationContext.getSharedPreferences( + "io.linktrail.reactnative.identity", + android.content.Context.MODE_PRIVATE + ) + + override fun persistUserId(userId: String?, promise: Promise) { + identityPrefs.edit().apply { + if (userId != null) putString("userId", userId) else remove("userId") + }.apply() + promise.resolve(null) + } + + override fun loadUserId(promise: Promise) { + promise.resolve(identityPrefs.getString("userId", null)) + } + + /** + * The pinned SDK (io.linktrail:sdk:0.0.5) has no identity API. Look the methods + * up reflectively so this compiles today and starts delivering as soon as the + * dependency moves to a release that adds `setUserId(String)` / `logout()`. + */ + override fun forwardUserId(userId: String?, promise: Promise) { + val sdk = LinkTrail.shared + if (sdk == null) { + promise.resolve(false) + return + } + try { + val methods = sdk.javaClass.methods + val target = if (userId != null) { + methods.firstOrNull { it.name == "setUserId" && it.parameterTypes.size == 1 } + ?.let { it to arrayOf(userId) } + } else { + methods.firstOrNull { it.name == "logout" && it.parameterTypes.isEmpty() } + ?.let { it to arrayOf() } + } + if (target == null) { + promise.resolve(false) + } else { + target.first.invoke(sdk, *target.second) + promise.resolve(true) + } + } catch (t: Throwable) { + promise.resolve(false) // best-effort + } + } + // ── Testing ──────────────────────────────────────────────────────────────── override fun resetForTesting() { diff --git a/ios/LinkTrailModule.mm b/ios/LinkTrailModule.mm index c46bce2..4445bdf 100644 --- a/ios/LinkTrailModule.mm +++ b/ios/LinkTrailModule.mm @@ -131,6 +131,26 @@ - (void)setConsent:(BOOL)granted [_impl setConsent:granted]; } +- (void)persistUserId:(NSString *)userId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl persistUserId:userId resolve:resolve]; +} + +- (void)loadUserId:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl loadUserId:resolve]; +} + +- (void)forwardUserId:(NSString *)userId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [_impl forwardUserId:userId resolve:resolve]; +} + - (void)resetForTesting { [_impl resetForTesting]; diff --git a/ios/LinkTrailModuleImpl.swift b/ios/LinkTrailModuleImpl.swift index a9befed..33e42e1 100644 --- a/ios/LinkTrailModuleImpl.swift +++ b/ios/LinkTrailModuleImpl.swift @@ -159,6 +159,30 @@ public final class LinkTrailModuleImpl: NSObject { sdk?.setConsent(granted) } + // MARK: - User identity + + private static let userIdKey = "io.linktrail.reactnative.userId" + + @objc public func persistUserId(_ userId: String?, resolve: @escaping Resolve) { + if let userId { + UserDefaults.standard.set(userId, forKey: Self.userIdKey) + } else { + UserDefaults.standard.removeObject(forKey: Self.userIdKey) + } + resolve(nil) + } + + @objc public func loadUserId(_ resolve: @escaping Resolve) { + resolve(UserDefaults.standard.string(forKey: Self.userIdKey)) + } + + /// The pinned LinkTrailSDK (~> 0.0.11) has no identity API, and Swift offers no + /// way to probe for a missing method at runtime, so this reports `false` + /// ("not delivered") until the pod pin moves to a release that adds it. + @objc public func forwardUserId(_ userId: String?, resolve: @escaping Resolve) { + resolve(false) + } + // MARK: - Testing @objc public func resetForTesting() { diff --git a/package-lock.json b/package-lock.json index 2c1eed9..5f2f79d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linktrail-react-native", - "version": "0.0.1", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linktrail-react-native", - "version": "0.0.1", + "version": "0.0.4", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "0.80.3", @@ -17,6 +17,7 @@ "react": "19.1.0", "react-native": "^0.80.0", "react-native-builder-bob": "^0.40.6", + "tsx": "^4.23.15", "typescript": "^5.8.3" }, "peerDependencies": { @@ -2084,6 +2085,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -4755,6 +5198,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10065,6 +10550,25 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 41e3a33..f4d1b82 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "typecheck": "tsc", "lint": "eslint \"src/**/*.{ts,tsx}\"", "clean": "del-cli lib", - "prepare": "bob build" + "prepare": "bob build", + "test": "node --import tsx --test test/*.test.ts" }, "keywords": [ "react-native", @@ -60,6 +61,7 @@ "react": "19.1.0", "react-native": "^0.80.0", "react-native-builder-bob": "^0.40.6", + "tsx": "^4.23.15", "typescript": "^5.8.3" }, "peerDependencies": { diff --git a/src/NativeLinkTrail.ts b/src/NativeLinkTrail.ts index 8c72ed1..c49e658 100644 --- a/src/NativeLinkTrail.ts +++ b/src/NativeLinkTrail.ts @@ -56,6 +56,18 @@ export interface Spec extends TurboModule { /** Grants/revokes tracking consent (only meaningful with `requireConsent`). */ setConsent(granted: boolean): void; + /** Wrapper-owned persistence for the user id (UserDefaults / SharedPreferences). */ + persistUserId(userId?: string): Promise; + + /** Reads the persisted user id, or null. */ + loadUserId(): Promise; + + /** + * Hands the user id to the native SDK (null = clear). Resolves false when the + * pinned native SDK has no identity support or is not configured. + */ + forwardUserId(userId?: string): Promise; + /** Clears the install flag, cached attribution, queued events, device id. */ resetForTesting(): void; diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..ce4b9f1 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,133 @@ +/** + * User identity (`setUserId` / `logout`) state machine. + * + * Pure TypeScript with an injected native bridge so it can be unit-tested + * without React Native. The wrapper owns the id (validation, persistence via + * the native bridge, consent gating); delivery to the backend is delegated to + * the native SDK through `forwardUserId`, which resolves `false` when the + * pinned native SDK has no identity support yet. + */ + +/** Allowed characters/length for a user id (matches the backend contract). */ +export const USER_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; + +/** Rejection code for an id that fails validation. */ +export const INVALID_USER_ID = 'invalid_user_id'; + +export interface IdentityBridge { + /** Persists the id (or clears it with `null`). */ + persistUserId(userId: string | null): Promise; + /** Reads the persisted id. */ + loadUserId(): Promise; + /** + * Hands the id to the native SDK (`null` = stop attaching/clear). Resolves + * `true` if the native SDK accepted it, `false` if unsupported/unconfigured. + */ + forwardUserId(userId: string | null): Promise; +} + +function invalidUserId(): Error { + const error = new Error( + 'Invalid userId: must be 1-128 characters from A-Z a-z 0-9 _ - . : ' + + '(use an opaque internal id or a hash, never an email, phone or name).' + ) as Error & { code: string }; + error.code = INVALID_USER_ID; + return error; +} + +export class Identity { + private userId: string | null = null; + private consent = false; + /** The value last forwarded to the native SDK (avoids duplicate identify). */ + private forwarded: string | null = null; + private ready: Promise | null = null; + private queue: Promise = Promise.resolve(); + + constructor(private readonly bridge: IdentityBridge) {} + + /** + * Called from `configure`. `requireConsent: false` means tracking is allowed + * immediately; otherwise it stays denied until `setConsent(true)`. + */ + configure(requireConsent: boolean): Promise { + return this.run(async () => { + await this.load(); + this.consent = !requireConsent; + this.forwarded = null; // fresh SDK instance per launch + await this.sync(); + }); + } + + setConsent(granted: boolean): Promise { + return this.run(async () => { + this.consent = granted; + await this.load(); + await this.sync(); + }); + } + + setUserId(userId: string): Promise { + if (typeof userId !== 'string' || !USER_ID_PATTERN.test(userId)) { + return Promise.reject(invalidUserId()); + } + return this.run(async () => { + await this.load(); + this.userId = userId; + await this.bridge.persistUserId(userId); + await this.sync(); + }); + } + + logout(): Promise { + return this.run(async () => { + await this.load(); + this.userId = null; + await this.bridge.persistUserId(null); + await this.sync(); + }); + } + + getUserId(): Promise { + return this.run(async () => { + await this.load(); + }).then(() => this.userId); + } + + private load(): Promise { + this.ready ??= this.bridge + .loadUserId() + .then((stored) => { + if (this.userId === null && stored && USER_ID_PATTERN.test(stored)) { + this.userId = stored; + } + }) + .catch(() => { + this.ready = null; // retry on the next operation + }); + return this.ready; + } + + /** + * Reconciles the native SDK with the desired state: the id is forwarded only + * while consent is granted and cleared otherwise. Best-effort, never throws. + */ + private async sync(): Promise { + const desired = this.consent ? this.userId : null; + if (desired === this.forwarded) { + return; + } + try { + await this.bridge.forwardUserId(desired); + this.forwarded = desired; + } catch { + // Identity is best-effort: failures never reach the app. + } + } + + /** Serialises operations so persist/forward ordering is deterministic. */ + private run(op: () => Promise): Promise { + const next = this.queue.then(op, op); + this.queue = next.catch(() => {}); + return next; + } +} diff --git a/src/index.ts b/src/index.ts index a396586..aaa883f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { Linking } from 'react-native'; import NativeLinkTrail from './NativeLinkTrail'; +import { Identity } from './identity'; import type { LinkTrailAttribution, LinkTrailCoarseConversionValue, @@ -17,6 +18,12 @@ export { type LinkTrailPasteButtonProps, } from './LinkTrailPasteButton'; +const identity = new Identity({ + persistUserId: (userId) => NativeLinkTrail.persistUserId(userId ?? undefined), + loadUserId: () => NativeLinkTrail.loadUserId(), + forwardUserId: (userId) => NativeLinkTrail.forwardUserId(userId ?? undefined), +}); + let linkingSubscription: LinkTrailSubscription | null = null; let initialUrlForwarded = false; @@ -51,6 +58,7 @@ export const LinkTrail = { async configure(apiKey: string, options: LinkTrailOptions = {}): Promise { const { autoHandleLinks = true, ...nativeOptions } = options; await NativeLinkTrail.configure(apiKey, nativeOptions); + identity.configure(nativeOptions.requireConsent ?? true).catch(() => {}); if (autoHandleLinks) { startLinkForwarding(); } else { @@ -201,6 +209,35 @@ export const LinkTrail = { */ setConsent(granted: boolean): void { NativeLinkTrail.setConsent(granted); + identity.setConsent(granted).catch(() => {}); + }, + + /** + * Associates events with your app's own user id. Use an opaque internal id or + * a hash — **never** an email, phone number or name. 1-128 characters from + * `A-Z a-z 0-9 _ - . :`; anything else rejects with code `invalid_user_id` + * and nothing is stored or sent. + * + * The id is persisted across launches. It is only transmitted while consent + * is granted (see `setConsent`); calling a different id replaces the old one. + * Delivery is best-effort and never throws for network reasons. Requires a + * native SDK with identity support (see README). + */ + setUserId(userId: string): Promise { + return identity.setUserId(userId); + }, + + /** + * Clears the stored user id locally (no network call). Subsequent events + * carry no user id. + */ + logout(): Promise { + return identity.logout(); + }, + + /** The currently stored user id, or `null`. */ + getUserId(): Promise { + return identity.getUserId(); }, /** diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 0000000..8beaa27 --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { Identity, INVALID_USER_ID, type IdentityBridge } from '../src/identity'; + +function fakeBridge(initial: string | null = null) { + const state = { + stored: initial, + forwards: [] as Array, + failForward: false, + }; + const bridge: IdentityBridge = { + persistUserId: async (id) => { + state.stored = id; + }, + loadUserId: async () => state.stored, + forwardUserId: async (id) => { + if (state.failForward) throw new Error('boom'); + state.forwards.push(id); + return true; + }, + }; + return { state, bridge }; +} + +describe('validation', () => { + it('rejects invalid ids without storing or sending', async () => { + const { state, bridge } = fakeBridge(); + const identity = new Identity(bridge); + await identity.configure(false); + for (const bad of ['', 'a@b.com', 'x'.repeat(129), 'has space!', '名前']) { + await assert.rejects(identity.setUserId(bad), { code: INVALID_USER_ID }); + } + await assert.rejects(identity.setUserId(undefined as never), { + code: INVALID_USER_ID, + }); + assert.equal(state.stored, null); + assert.deepEqual(state.forwards, []); + }); + + it('accepts boundary and allowed characters', async () => { + const { bridge } = fakeBridge(); + const identity = new Identity(bridge); + await identity.setUserId('a'.repeat(128)); + await identity.setUserId('Az09_-.:'); + assert.equal(await identity.getUserId(), 'Az09_-.:'); + }); +}); + +describe('consent gating', () => { + let t: ReturnType; + beforeEach(() => { + t = fakeBridge(); + }); + + it('stores but never forwards while consent is undecided', async () => { + const identity = new Identity(t.bridge); + await identity.configure(true); + await identity.setUserId('u1'); + assert.equal(t.state.stored, 'u1'); + assert.deepEqual(t.state.forwards, []); + }); + + it('forwards once on consent grant', async () => { + const identity = new Identity(t.bridge); + await identity.configure(true); + await identity.setUserId('u1'); + await identity.setConsent(true); + await identity.setConsent(true); + assert.deepEqual(t.state.forwards, ['u1']); + }); + + it('clears the native id when consent is revoked, and resends on re-grant', async () => { + const identity = new Identity(t.bridge); + await identity.configure(false); + await identity.setUserId('u1'); + await identity.setConsent(false); + assert.equal(t.state.stored, 'u1'); + await identity.setConsent(true); + assert.deepEqual(t.state.forwards, ['u1', null, 'u1']); + }); + + it('forwards immediately when consent is not required', async () => { + const identity = new Identity(t.bridge); + await identity.configure(false); + await identity.setUserId('u1'); + await identity.setUserId('u1'); + await identity.setUserId('u2'); + assert.deepEqual(t.state.forwards, ['u1', 'u2']); + }); + + it('swallows forward failures', async () => { + const identity = new Identity(t.bridge); + await identity.configure(false); + t.state.failForward = true; + await identity.setUserId('u1'); + assert.equal(await identity.getUserId(), 'u1'); + }); +}); + +describe('persistence and logout', () => { + it('restores the stored id on the next launch and re-identifies', async () => { + const first = fakeBridge(); + const a = new Identity(first.bridge); + await a.configure(false); + await a.setUserId('u1'); + + const second = fakeBridge(first.state.stored); + const b = new Identity(second.bridge); + await b.configure(false); + assert.equal(await b.getUserId(), 'u1'); + assert.deepEqual(second.state.forwards, ['u1']); + }); + + it('does not forward a restored id before consent', async () => { + const t = fakeBridge('u1'); + const identity = new Identity(t.bridge); + await identity.configure(true); + assert.deepEqual(t.state.forwards, []); + await identity.setConsent(true); + assert.deepEqual(t.state.forwards, ['u1']); + }); + + it('ignores a corrupt stored id', async () => { + const t = fakeBridge('bad id!'); + const identity = new Identity(t.bridge); + await identity.configure(false); + assert.equal(await identity.getUserId(), null); + }); + + it('logout clears storage and detaches the id', async () => { + const t = fakeBridge(); + const identity = new Identity(t.bridge); + await identity.configure(false); + await identity.setUserId('u1'); + await identity.logout(); + assert.equal(t.state.stored, null); + assert.equal(await identity.getUserId(), null); + assert.deepEqual(t.state.forwards, ['u1', null]); + }); +});