From 0338a4d2cd107c2261fe25bc2306330bed3e16d5 Mon Sep 17 00:00:00 2001 From: Bogdan Carpusor Date: Tue, 21 Apr 2026 21:34:11 +0300 Subject: [PATCH 1/2] feat: Add the SuperTokens lazy user migration flow --- Sources/Rownd/Models/RowndConfig.swift | 22 +++++++ Sources/Rownd/Rownd.swift | 2 + Sources/Rownd/framework/SuperTokensSync.swift | 66 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 Sources/Rownd/framework/SuperTokensSync.swift diff --git a/Sources/Rownd/Models/RowndConfig.swift b/Sources/Rownd/Models/RowndConfig.swift index b2d72b5..82f900e 100644 --- a/Sources/Rownd/Models/RowndConfig.swift +++ b/Sources/Rownd/Models/RowndConfig.swift @@ -7,6 +7,27 @@ import Foundation + +public struct SuperTokensAppInfo: Encodable { + public var appName: String + public var apiDomain: String + public var apiBasePath: String + + public init(appName: String, apiDomain: String, apiBasePath: String = "/auth") { + self.appName = appName + self.apiDomain = apiDomain + self.apiBasePath = apiBasePath + } +} + +public struct SuperTokensConfig: Encodable { + public var appInfo: SuperTokensAppInfo + + public init(appInfo: SuperTokensAppInfo) { + self.appInfo = appInfo + } +} + public struct RowndConfig: Encodable { internal init() {} @@ -21,6 +42,7 @@ public struct RowndConfig: Encodable { public var customizations: RowndCustomizations = RowndCustomizations() // These will not be encoded + public var supertokens: SuperTokensConfig? = nil public var appGroupPrefix: String? public var enableSmartLinkPasteBehavior: Bool = true public var signInLinkPattern: String = ".*\\.rownd\\.link$" diff --git a/Sources/Rownd/Rownd.swift b/Sources/Rownd/Rownd.swift index c702ddf..bb65f84 100644 --- a/Sources/Rownd/Rownd.swift +++ b/Sources/Rownd/Rownd.swift @@ -48,6 +48,8 @@ public class Rownd: NSObject { config.appKey = _appKey } + registerSuperTokensSyncEventHandler() + let state = await inst.inflateStoreCache() // Skip the rest within app extensions diff --git a/Sources/Rownd/framework/SuperTokensSync.swift b/Sources/Rownd/framework/SuperTokensSync.swift new file mode 100644 index 0000000..98a12bf --- /dev/null +++ b/Sources/Rownd/framework/SuperTokensSync.swift @@ -0,0 +1,66 @@ +import Foundation +import OSLog + +private let log = Logger(subsystem: "io.rownd.sdk", category: "supertokens-sync") + +private final class SuperTokensSyncEventHandler: RowndEventHandlerDelegate { + func handleRowndEvent(_ event: RowndEvent) { + let userType = event.data?["user_type"] ?? nil + + guard event.event == .signInCompleted, + userType?.value as? String == "new_user", + let appInfo = Rownd.config.supertokens?.appInfo + else { + return + } + + Task { + do { + guard let accessToken = try await Rownd.getAccessToken() else { + return + } + + await syncUserToSuperTokens(accessToken: accessToken, appInfo: appInfo) + } catch { + log.error("[Rownd->ST] failed to read access token for migration: \(error.localizedDescription)") + } + } + } +} + +private let superTokensSyncEventHandler = SuperTokensSyncEventHandler() + +func registerSuperTokensSyncEventHandler() { + guard Rownd.config.supertokens?.appInfo != nil else { + return + } + + let alreadyRegistered = Context.currentContext.eventListeners.contains { listener in + listener === superTokensSyncEventHandler + } + + if !alreadyRegistered { + Context.currentContext.eventListeners.append(superTokensSyncEventHandler) + } +} + +func syncUserToSuperTokens( + accessToken: String, + appInfo: SuperTokensAppInfo +) async { + let base = "\(appInfo.apiDomain)\(appInfo.apiBasePath)" + + do { + var request = URLRequest(url: URL(string: "\(base)/plugin/rownd/migrate")!) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.httpShouldHandleCookies = true + + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + log.error("[Rownd->ST] migrate failed with status: \(http.statusCode)") + } + } catch { + log.error("[Rownd->ST] migrate failed (non-fatal): \(error.localizedDescription)") + } +} From f560aad17b487e45bc6d4e97c6e50fb9bfde9bb0 Mon Sep 17 00:00:00 2001 From: Bogdan Carpusor Date: Wed, 22 Apr 2026 20:43:19 +0300 Subject: [PATCH 2/2] fix: Code review fixes --- Sources/Rownd/Models/RowndConfig.swift | 37 +++++++++++++++++++ Sources/Rownd/framework/SuperTokensSync.swift | 13 ++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Sources/Rownd/Models/RowndConfig.swift b/Sources/Rownd/Models/RowndConfig.swift index 82f900e..6919001 100644 --- a/Sources/Rownd/Models/RowndConfig.swift +++ b/Sources/Rownd/Models/RowndConfig.swift @@ -13,6 +13,43 @@ public struct SuperTokensAppInfo: Encodable { public var apiDomain: String public var apiBasePath: String + internal var normalizedApiDomain: String? { + let domain = apiDomain.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + guard let url = URL(string: domain), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.host != nil + else { + return nil + } + + return url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + internal var normalizedApiBasePath: String { + let segments = apiBasePath + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "/") + .map(String.init) + + return segments.isEmpty ? "" : "/" + segments.joined(separator: "/") + } + + internal var migrationURL: URL? { + guard let normalizedApiDomain else { + return nil + } + + guard var components = URLComponents(string: normalizedApiDomain) else { + return nil + } + + components.path = normalizedApiBasePath + "/plugin/rownd/migrate" + return components.url + } + public init(appName: String, apiDomain: String, apiBasePath: String = "/auth") { self.appName = appName self.apiDomain = apiDomain diff --git a/Sources/Rownd/framework/SuperTokensSync.swift b/Sources/Rownd/framework/SuperTokensSync.swift index 98a12bf..44db1f0 100644 --- a/Sources/Rownd/framework/SuperTokensSync.swift +++ b/Sources/Rownd/framework/SuperTokensSync.swift @@ -31,10 +31,6 @@ private final class SuperTokensSyncEventHandler: RowndEventHandlerDelegate { private let superTokensSyncEventHandler = SuperTokensSyncEventHandler() func registerSuperTokensSyncEventHandler() { - guard Rownd.config.supertokens?.appInfo != nil else { - return - } - let alreadyRegistered = Context.currentContext.eventListeners.contains { listener in listener === superTokensSyncEventHandler } @@ -48,10 +44,15 @@ func syncUserToSuperTokens( accessToken: String, appInfo: SuperTokensAppInfo ) async { - let base = "\(appInfo.apiDomain)\(appInfo.apiBasePath)" + guard let url = appInfo.migrationURL else { + log.error( + "[Rownd->ST] invalid migration URL constructed from apiDomain=\(appInfo.apiDomain) apiBasePath=\(appInfo.apiBasePath)" + ) + return + } do { - var request = URLRequest(url: URL(string: "\(base)/plugin/rownd/migrate")!) + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.httpShouldHandleCookies = true