From ce2535ff0f7fbde6fa400ce015dc6401b4adc5c4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 01:00:24 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20AI=20workflow=20=EC=9C=84=EC=9E=84?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT_ROLES.md | 10 ++++++++++ AGENT_WORKFLOWS.md | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/AGENT_ROLES.md b/AGENT_ROLES.md index 79296315..bcf92d94 100644 --- a/AGENT_ROLES.md +++ b/AGENT_ROLES.md @@ -44,6 +44,15 @@ Default role-to-model assignment: Do not assign `Spark` as the only model for production Swift implementation, target dependency changes, DI assembly, repository/service contract changes, Firebase or SDK placement, Widget data-flow changes, StorePattern responsibility changes, commits, pushes, PR creation, or final integration. +### Model dispatch requirements + +- A model tier assignment is an execution requirement, not a label for work the main agent already performed. +- When a role is assigned to `Spark` or `Fast`, and tooling can select that model, the main agent must dispatch that role through a separate model or sub-agent call before using its result. +- Do not satisfy a `Spark` or `Fast` role by completing the role directly in `Primary` and describing it as delegated work. +- If the assigned model cannot be called because the model or model-selecting tool is unavailable, apply the fallback policy and report which role was not dispatched to its default tier. +- If the assigned model is available but current tool policy requires explicit user permission before dispatch, missing permission is not fallback. Stop and ask for permission before continuing the required role. +- `Primary` must integrate and verify delegated output, but must not skip the delegated role when the workflow requires it and the assigned model is available. + ### Fallback policy - If `gpt-5.3-codex-spark` is unavailable, assign `Spark` roles to the fastest available read-only capable coding model. @@ -123,6 +132,7 @@ Rules: - Stay inside the role permissions. - Do not edit files if this is a read-only role. - Do not run, launch, install, boot, or open the app or Simulator. +- Perform this role in the assigned model context. Do not return work copied from a different model context as this role's own result. - Stop and report if the task packet conflicts with `AGENTS.md`. - Return only the output format defined for ``. ``` diff --git a/AGENT_WORKFLOWS.md b/AGENT_WORKFLOWS.md index 3987cf66..c4b3932f 100644 --- a/AGENT_WORKFLOWS.md +++ b/AGENT_WORKFLOWS.md @@ -17,12 +17,14 @@ The main agent must run every workflow with this protocol. 3. Create the task packet. 4. Assign only the roles required by the selected workflow. 5. Assign each role a model tier from `AGENT_ROLES.md`. -6. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits. -7. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint. -8. Integrate role outputs. -9. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing. -10. Run completion gates. -11. Report changed files, architecture decision, verification result, and unresolved decisions. +6. Dispatch each `Spark` or `Fast` role through a separate model or sub-agent call when tooling can select that assigned model. +7. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits. +8. Do not complete a required `Spark` or `Fast` role directly in `Primary` unless the model or model-selecting tool is unavailable and the fallback policy in `AGENT_ROLES.md` applies. +9. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint. +10. Integrate role outputs. +11. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing. +12. Run completion gates. +13. Report changed files, architecture decision, verification result, delegated roles, model tiers used, and unresolved decisions. Do not skip the task packet. The task packet is the contract between models. @@ -33,6 +35,7 @@ Stop and ask the user before editing when: - The task packet conflicts with `AGENTS.md`. - The requested fix requires relaxing a layer boundary. - A role needs to run, launch, install, boot, or open the app or Simulator. +- A required `Spark` or `Fast` role needs a separate model or sub-agent call, but current tool policy requires user permission that has not been granted. - The current issue or PR scope is unclear after live GitHub inspection. - Two editing roles would touch the same file. - A read-only role reports `Block` or `Needs Owner Decision`. @@ -236,6 +239,9 @@ Use for PR body, issue text, release note, README wording, review reply draft, o ### Execution - Documentation Writer must inspect the actual diff before writing PR or release text. +- When the Documentation Writer role is required and `Spark` is available, the main agent must dispatch the draft to `gpt-5.3-codex-spark` or the configured `Spark` fallback before writing the final response. +- If dispatch requires explicit user permission and it has not been granted, ask before drafting, returning, or posting the Documentation Writer output. +- `Primary` must review the Documentation Writer output against the template, issue scope, and diff before returning or posting it. - Do not write AI workflow documents under `docs/`. - If the user asks only for text, return text directly and do not create files. - If documentation files are changed, keep the change scoped to the requested document. From 3197c638a312793957084ed4b28a4f21c04fc4f2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 08:37:41 +0900 Subject: [PATCH 2/8] =?UTF-8?q?test:=20GitHub=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B3=80=EA=B2=BD=20=EC=B6=A9=EB=8F=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileTab/Tests/Settings/AccountFeatureTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift index 945bef91..5b149215 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift @@ -172,6 +172,11 @@ struct AccountFeatureTests { title: String(localized: "account_alert_already_linked_title"), message: String(localized: "account_alert_already_linked_message") ), + AccountLinkFailureScenario( + error: AuthError.githubEmailChangedAccountConflict, + title: String(localized: "account_alert_github_email_conflict_title"), + message: String(localized: "account_alert_github_email_conflict_message") + ), AccountLinkFailureScenario( error: AccountTestError.failure, title: String(localized: "common_error_title"), From 56e2e241557727686e1808f5bd714460b067817f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 09:17:59 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20Data=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Common/DataLayerError.swift | 23 ++++++++----------- .../Data/Sources/Mapper/ErrorMapping.swift | 2 ++ .../Mapper/PushNotificationMapping.swift | 2 +- .../Data/Sources/Mapper/TodoMapping.swift | 2 +- .../Data/Sources/Mapper/WebPageMapping.swift | 4 ++-- .../Repository/AuthDataRepositoryImpl.swift | 8 +++---- .../AuthenticationRepositoryImpl.swift | 4 ++-- .../PushNotificationRepositoryImpl.swift | 2 +- .../Repository/TodoRepositoryImpl.swift | 6 ++--- .../Sources/Entity/DomainLayerError.swift | 10 ++++++++ .../Sources/Service/FunctionAPIClient.swift | 12 +++++----- .../AppleAuthenticationServiceImpl.swift | 4 ++-- .../GithubAuthenticationServiceImpl.swift | 8 +++---- .../GoogleAuthenticationServiceImpl.swift | 4 ++-- 14 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 Application/Domain/Sources/Entity/DomainLayerError.swift diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 2cfed763..6183f869 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -8,31 +8,33 @@ import Foundation import Core -public enum EmailFetchError: Error, Equatable { - case emailNotFound - case emailMismatch +public enum EmailError: Error, Equatable { + case notFound + case mismatch public var code: String { switch self { - case .emailMismatch: + case .mismatch: "email_mismatch" - case .emailNotFound: + case .notFound: "email_not_found" } } } -public enum DataError: Error { +public enum DataLayerError: Error { + case notAuthenticated + case linkCredentialAlreadyInUse case invalidData(context: String) - private static let logger = Logger(category: "DataError") + private static let logger = Logger(category: "DataLayerError") public static func invalidData( _ context: String, file: String = #file, function: String = #function, line: Int = #line - ) -> DataError { + ) -> DataLayerError { logger.error( "Invalid data: \(context)", file: file, @@ -42,8 +44,3 @@ public enum DataError: Error { return .invalidData(context: context) } } - -public enum DataLayerError: Error { - case notAuthenticated - case linkCredentialAlreadyInUse -} diff --git a/Application/Data/Sources/Mapper/ErrorMapping.swift b/Application/Data/Sources/Mapper/ErrorMapping.swift index adaf3a2f..ac85f06c 100644 --- a/Application/Data/Sources/Mapper/ErrorMapping.swift +++ b/Application/Data/Sources/Mapper/ErrorMapping.swift @@ -14,6 +14,8 @@ extension Error { return AuthError.notAuthenticated case .linkCredentialAlreadyInUse: return AuthError.linkCredentialAlreadyInUse + case .invalidData(let context): + return DomainLayerError.invalidData(context: context) case .none: return self } diff --git a/Application/Data/Sources/Mapper/PushNotificationMapping.swift b/Application/Data/Sources/Mapper/PushNotificationMapping.swift index 373c503e..8b1848e1 100644 --- a/Application/Data/Sources/Mapper/PushNotificationMapping.swift +++ b/Application/Data/Sources/Mapper/PushNotificationMapping.swift @@ -15,7 +15,7 @@ public extension PushNotificationResponse { case .decoded(let category): todoCategory = category case .raw(let category): - throw DataError.invalidData( + throw DataLayerError.invalidData( "PushNotificationResponse.todoCategory must be resolved before toDomain(): \(category)" ) } diff --git a/Application/Data/Sources/Mapper/TodoMapping.swift b/Application/Data/Sources/Mapper/TodoMapping.swift index 18804df1..e6cec398 100644 --- a/Application/Data/Sources/Mapper/TodoMapping.swift +++ b/Application/Data/Sources/Mapper/TodoMapping.swift @@ -54,7 +54,7 @@ public extension TodoResponse { case .decoded(let category): todoCategory = category case .raw(let category): - throw DataError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)") + throw DataLayerError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)") } return Todo( diff --git a/Application/Data/Sources/Mapper/WebPageMapping.swift b/Application/Data/Sources/Mapper/WebPageMapping.swift index ed4e5d52..8f67087d 100644 --- a/Application/Data/Sources/Mapper/WebPageMapping.swift +++ b/Application/Data/Sources/Mapper/WebPageMapping.swift @@ -11,10 +11,10 @@ import Domain public extension WebPageResponse { func toDomain() throws -> WebPage { guard let url = URL(string: url) else { - throw DataError.invalidData("WebPageResponse.url is invalid: \(url)") + throw DataLayerError.invalidData("WebPageResponse.url is invalid: \(url)") } guard let displayURL = URL(string: displayURL) else { - throw DataError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)") + throw DataLayerError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)") } let imageURL: URL? if !self.imageURL.isEmpty { diff --git a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift index d060bbb0..0d25528b 100644 --- a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift @@ -85,11 +85,11 @@ final class AuthDataRepositoryImpl: AuthDataRepository { private extension AuthDataRepositoryImpl { func mapLinkError(_ error: Error) -> Error { - if let emailFetchError = error as? EmailFetchError { - switch emailFetchError { - case .emailNotFound: + if let emailError = error as? EmailError { + switch emailError { + case .notFound: return AuthError.linkEmailNotFound - case .emailMismatch: + case .mismatch: return AuthError.linkEmailMismatch } } diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index bbbbcdca..7dc3208f 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -128,8 +128,8 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { private extension AuthenticationRepositoryImpl { func mapSignInError(_ error: Error) -> Error { - if let emailFetchError = error as? EmailFetchError, - emailFetchError == .emailNotFound { + if let emailError = error as? EmailError, + emailError == .notFound { return AuthError.emailNotFound } diff --git a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift index 8344e249..7f9734de 100644 --- a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift @@ -208,7 +208,7 @@ private extension PushNotificationRepositoryImpl { }) { todoCategory = .user(userTodoCategory) } else { - throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") + throw DataLayerError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") } return PushNotificationResponse( diff --git a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift index 68fb44b1..f7af928a 100644 --- a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift @@ -115,7 +115,7 @@ final class TodoRepositoryImpl: TodoRepository { return try responses.reduce(into: [Int: TodoReference]()) { partialResult, pair in let response = try resolve(pair.value, userTodoCategories: userTodoCategories) guard case let .decoded(category) = response.category else { - throw DataError.invalidData("TodoReferenceResponse.category must be resolved before use") + throw DataLayerError.invalidData("TodoReferenceResponse.category must be resolved before use") } partialResult[pair.key] = TodoReference( @@ -208,7 +208,7 @@ private extension TodoRepositoryImpl { }) { category = .user(userTodoCategory) } else { - throw DataError.invalidData("TodoResponse.category is invalid: \(id)") + throw DataLayerError.invalidData("TodoResponse.category is invalid: \(id)") } return TodoResponse( @@ -249,7 +249,7 @@ private extension TodoRepositoryImpl { }) { category = .user(userTodoCategory) } else { - throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)") + throw DataLayerError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)") } return TodoReferenceResponse( diff --git a/Application/Domain/Sources/Entity/DomainLayerError.swift b/Application/Domain/Sources/Entity/DomainLayerError.swift new file mode 100644 index 00000000..af35c33d --- /dev/null +++ b/Application/Domain/Sources/Entity/DomainLayerError.swift @@ -0,0 +1,10 @@ +// +// DomainLayerError.swift +// Domain +// +// Created by opfic on 7/10/26. +// + +public enum DomainLayerError: Error { + case invalidData(context: String) +} diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 1fcdf821..82a6f241 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -124,10 +124,10 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { ) else { return nil } switch body.code { - case EmailFetchError.emailNotFound.code: - return EmailFetchError.emailNotFound - case EmailFetchError.emailMismatch.code: - return EmailFetchError.emailMismatch + case EmailError.notFound.code: + return EmailError.notFound + case EmailError.mismatch.code: + return EmailError.mismatch default: return nil } @@ -145,7 +145,7 @@ private actor FirebaseAuthTokenProvider: NXAuthTokenProvider { } extension Error { - var apiEmailFetchError: EmailFetchError? { + var apiEmailError: EmailError? { guard let error = self as? NXError, case let .server( statusCode: _, @@ -153,6 +153,6 @@ extension Error { underlying: underlying ) = error else { return nil } - return underlying as? EmailFetchError + return underlying as? EmailError } } diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index b7eb4594..45959f70 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -150,12 +150,12 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { guard let appleEmail = credential.email else { try await revokeAppleAccessToken(token: refreshToken) - throw EmailFetchError.emailNotFound + throw EmailError.notFound } if appleEmail != email { try await revokeAppleAccessToken(token: refreshToken) - throw EmailFetchError.emailMismatch + throw EmailError.mismatch } let appleCredential = OAuthProvider.credential( diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index cb91de80..993b39f1 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -135,13 +135,13 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { guard let githubEmail = githubUser.email else { logger.error("GitHub email not found") try await revokeAccessToken(accessToken: accessToken) - throw EmailFetchError.emailNotFound + throw EmailError.notFound } if githubEmail != email { logger.error("Email mismatch - Expected: \(email), Got: \(githubEmail)") try await revokeAccessToken(accessToken: accessToken) - throw EmailFetchError.emailMismatch + throw EmailError.mismatch } try await tokensRef.setData(["githubAccessToken": accessToken], merge: true) @@ -310,8 +310,8 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } private func mapRequestTokensError(_ error: Error) -> Error { - if let emailFetchError = error.apiEmailFetchError { - return emailFetchError + if let emailError = error.apiEmailError { + return emailError } return error diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index cf2feb85..3c34ff1b 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -123,11 +123,11 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) guard let googleEmail = signIn.user.profile?.email else { - throw EmailFetchError.emailNotFound + throw EmailError.notFound } if googleEmail != email { - throw EmailFetchError.emailMismatch + throw EmailError.mismatch } guard let idToken = signIn.user.idToken?.tokenString else { From bdd45ab373e63b6760a41d6b841d966622473116 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 09:26:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20GitHub=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=20=EC=97=90=EB=9F=AC=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/Common/DataLayerError.swift | 3 +++ .../Data/Sources/Repository/AuthDataRepositoryImpl.swift | 2 ++ Application/Domain/Sources/Entity/AuthError.swift | 1 + Application/Infra/Sources/Service/FunctionAPIClient.swift | 2 ++ .../ProfileTab/Tests/Settings/AccountFeatureTests.swift | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 6183f869..878ccfc1 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -11,6 +11,7 @@ import Core public enum EmailError: Error, Equatable { case notFound case mismatch + case githubEmailConflict public var code: String { switch self { @@ -18,6 +19,8 @@ public enum EmailError: Error, Equatable { "email_mismatch" case .notFound: "email_not_found" + case .githubEmailConflict: + "github-email-changed-account-conflict" } } } diff --git a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift index 0d25528b..70c2f8ae 100644 --- a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift @@ -91,6 +91,8 @@ private extension AuthDataRepositoryImpl { return AuthError.linkEmailNotFound case .mismatch: return AuthError.linkEmailMismatch + case .githubEmailConflict: + return AuthError.githubEmailConflict } } diff --git a/Application/Domain/Sources/Entity/AuthError.swift b/Application/Domain/Sources/Entity/AuthError.swift index d23be3f1..4506d38c 100644 --- a/Application/Domain/Sources/Entity/AuthError.swift +++ b/Application/Domain/Sources/Entity/AuthError.swift @@ -12,5 +12,6 @@ public enum AuthError: Error { case linkEmailNotFound case linkEmailMismatch case linkCredentialAlreadyInUse + case githubEmailConflict case unsupportedProvider } diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 82a6f241..84995eb0 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -128,6 +128,8 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { return EmailError.notFound case EmailError.mismatch.code: return EmailError.mismatch + case EmailError.githubEmailConflict.code: + return EmailError.githubEmailConflict default: return nil } diff --git a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift index 5b149215..bdc2c49b 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift @@ -173,7 +173,7 @@ struct AccountFeatureTests { message: String(localized: "account_alert_already_linked_message") ), AccountLinkFailureScenario( - error: AuthError.githubEmailChangedAccountConflict, + error: AuthError.githubEmailConflict, title: String(localized: "account_alert_github_email_conflict_title"), message: String(localized: "account_alert_github_email_conflict_message") ), From 81d7ef619432f4e85dff8be3241684e81800dd18 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 09:37:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20REST=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=86=8C=EC=9C=A0=EA=B6=8C=EC=9D=84=20Inf?= =?UTF-8?q?ra=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Common/DataLayerError.swift | 11 ----------- .../Sources/Service/FunctionAPIClient.swift | 18 +++++++++++------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 878ccfc1..937c4449 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -12,17 +12,6 @@ public enum EmailError: Error, Equatable { case notFound case mismatch case githubEmailConflict - - public var code: String { - switch self { - case .mismatch: - "email_mismatch" - case .notFound: - "email_not_found" - case .githubEmailConflict: - "github-email-changed-account-conflict" - } - } } public enum DataLayerError: Error { diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 84995eb0..45c8a228 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -112,6 +112,12 @@ private struct FunctionAPIErrorBody: Decodable { let message: String? } +private enum FunctionAPIErrorCode: String { + case emailNotFound = "email-not-found" + case emailMismatch = "email-mismatch" + case githubEmailConflict = "github-email-changed-account-conflict" +} + private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { func decodeServerError( data: Data, @@ -121,17 +127,15 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { guard let body = try? decoder.decode( FunctionAPIErrorBody.self, from: data - ) else { return nil } + ), let code = FunctionAPIErrorCode(rawValue: body.code) else { return nil } - switch body.code { - case EmailError.notFound.code: + switch code { + case .emailNotFound: return EmailError.notFound - case EmailError.mismatch.code: + case .emailMismatch: return EmailError.mismatch - case EmailError.githubEmailConflict.code: + case .githubEmailConflict: return EmailError.githubEmailConflict - default: - return nil } } } From bd4c2be6f6678199f9122e3d5c9e35e2ffe78048 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 09:50:15 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20GitHub=20provider=20link=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=EC=9D=84=20=EC=A0=84=EC=9A=A9=20alert=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileTab/Sources/Settings/AccountFeature.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift index 4eb4bda7..b21eb811 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift @@ -39,6 +39,7 @@ struct AccountFeature { case linkEmailNotFound case linkEmailMismatch case linkCredentialAlreadyInUse + case githubEmailConflict case error } @@ -201,6 +202,8 @@ private extension AccountFeature { return .linkEmailMismatch case .linkCredentialAlreadyInUse: return .linkCredentialAlreadyInUse + case .githubEmailConflict: + return .githubEmailConflict case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: return .error } @@ -220,6 +223,9 @@ private extension AccountFeature { case .linkCredentialAlreadyInUse: title = String(localized: "account_alert_already_linked_title") message = String(localized: "account_alert_already_linked_message") + case .githubEmailConflict: + title = String(localized: "account_alert_github_email_conflict_title") + message = String(localized: "account_alert_github_email_conflict_message") case .error: title = String(localized: "common_error_title") message = String(localized: "common_error_message") From 4cb3c964d700f00980a7c6ce794ae7562644f6dd Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 14:17:30 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20GitHub=20provider=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20REST=20API=EB=A1=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/FunctionAPIEndpoint.swift | 1 + .../GithubAuthenticationServiceImpl.swift | 43 ++++++++++--------- .../Service/FunctionAPIEndpointTests.swift | 19 ++++++++ 3 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 7c141eb6..8a6ff347 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -42,6 +42,7 @@ extension FunctionAPIEndpoint where Response == FunctionAPIResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") static let refreshAppleAccessToken = Self(method: .post, path: "/auth/apple/access-token") static let requestAppleRefreshToken = Self(method: .post, path: "/auth/apple/refresh-token") + static let linkGithubProvider = Self(method: .post, path: "/auth/github/link") static let requestGithubTokens = Self(method: .post, path: "/auth/github/tokens") } diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 993b39f1..2164fb59 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -122,32 +122,16 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } } - func link(uid: String, email: String) async throws -> Bool { + func link(uid: String, email _: String) async throws -> Bool { logger.info("Linking GitHub account for user: \(uid)") do { let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) let authorizationCode = try await requestAuthorizationCode() - let (accessToken, _) = try await requestTokens(authorizationCode: authorizationCode) - - let githubUser = try await requestUserProfile(accessToken: accessToken) - - guard let githubEmail = githubUser.email else { - logger.error("GitHub email not found") - try await revokeAccessToken(accessToken: accessToken) - throw EmailError.notFound - } - - if githubEmail != email { - logger.error("Email mismatch - Expected: \(email), Got: \(githubEmail)") - try await revokeAccessToken(accessToken: accessToken) - throw EmailError.mismatch - } + let accessToken = try await requestProviderLink(authorizationCode: authorizationCode) try await tokensRef.setData(["githubAccessToken": accessToken], merge: true) - - let credential = OAuthProvider.credential(providerID: providerID, accessToken: accessToken) - try await user?.link(with: credential) + try await user?.reload() logger.info("Successfully linked GitHub account") return true @@ -257,7 +241,24 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } throw TokenError.invalidResponse } catch { - throw mapRequestTokensError(error) + throw mapAPIEmailError(error) + } + } + + private func requestProviderLink(authorizationCode: String) async throws -> String { + do { + let response = try await FunctionAPIClient.shared.send( + .linkGithubProvider, + payload: ["code": authorizationCode], + requiresAuthentication: true + ) + + guard let accessToken = response.accessToken else { + throw TokenError.invalidResponse + } + return accessToken + } catch { + throw mapAPIEmailError(error) } } @@ -309,7 +310,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { return gitHubEmails.first(where: { $0.verified })?.email } - private func mapRequestTokensError(_ error: Error) -> Error { + private func mapAPIEmailError(_ error: Error) -> Error { if let emailError = error.apiEmailError { return emailError } diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift new file mode 100644 index 00000000..0755ff8c --- /dev/null +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -0,0 +1,19 @@ +// +// FunctionAPIEndpointTests.swift +// InfraTests +// +// Created by opfic on 7/10/26. +// + +import Testing +@testable import Infra + +struct FunctionAPIEndpointTests { + @Test("GitHub provider 연결 endpoint는 인증 link 경로를 사용한다") + func 깃허브_provider_연결_endpoint는_인증_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.linkGithubProvider + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/github/link") + } +} From 1958396a7357ea8dfc46bd2b2b23fdc57b165fe5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 10 Jul 2026 14:17:41 +0900 Subject: [PATCH 8/8] =?UTF-8?q?ui:=20GitHub=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=B6=A9=EB=8F=8C=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Resource/Localizable.xcstrings | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index a687bcb7..4b2792d2 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -139,6 +139,40 @@ } } }, + "account_alert_github_email_conflict_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A GitHub email change caused a conflict during the linking process. To protect existing data, linking cannot be completed." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 이메일 변경으로 연결 과정에서 충돌이 발생했습니다. 기존 데이터를 보호하기 위해 연결을 진행할 수 없습니다." + } + } + } + }, + "account_alert_github_email_conflict_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Link Account" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계정을 연결할 수 없음" + } + } + } + }, "account_connect" : { "extractionState" : "manual", "localizations" : {