From 44c303b9915fdc78dcaa849407e28531749019c4 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 10:04:18 -0700 Subject: [PATCH] feat(auth): check all five identity providers for connection health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkedIdentitiesView read only the LinkedIn and Twitter status routes, so a Bluesky, Mastodon or GitHub row always rendered as healthy and the user's first signal of trouble was a cross-post failing. - Add Services/APIClient+Identities.swift with the three missing status calls (github, bluesky, mastodon). Mastodon is per-instance — the route 400s without ?instance= — so each linked instance gets its own call, percent-encoded. - Add Models/IdentityHealth.swift: a pure resolver mapping the five outcomes onto connected / needs reconnect / unknown. Kept out of APIClient (HTTP-only) so the rules are testable without a network, and because MockURLSession serves stubs FIFO with no path matching — five concurrent async let calls would race it. - Read the five routes in parallel from .task via async let, with a task group for the Mastodon instances. - A failed status call degrades to unknown and never downgrades a healthy row; the default snapshot is unknown, so a view that never got an answer cannot render a connection as disconnected. - Offer Reconnect on a stale row via OAuthCoordinator link flow, prefilling the Mastodon prompt with the row's existing instance. Because the ?link=true callback ends on a web redirect rather than the custom-scheme token handoff, a server-side success arrives here as a cancellation: reload before believing it failed, and point at the web only if the row is still stale afterwards. - Route a status-route 401 through authState.handleUnauthorized() rather than a logout, per CLAUDE.md; same for the identities and unlink calls, which previously fell through to a generic error. Honest limits of what these routes can prove, documented in both new files: LinkedIn/Twitter/GitHub status read no per-user data at all (server OAuth config only), so their configured:false means the provider is unusable for everyone and maps to unknown, not needs-reconnect — reconnecting cannot fix it. Bluesky and Mastodon report only whether an identity row exists, not token freshness, so a token revoked upstream still reads as connected. The real signal (LinkedIdentity.needsReconnect) exists server-side and is already returned by getLinkedIdentitiesForUser, but GET /api/user/identities does not select it. Register the four new files in project.pbxproj (no synced groups). Refs #63 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YFt41K2Z9ghobFHy8Jiqgx --- InterlinedList.xcodeproj/project.pbxproj | 16 ++ InterlinedList/Models/IdentityHealth.swift | 124 ++++++++++++ .../Services/APIClient+Identities.swift | 68 +++++++ .../Views/LinkedIdentitiesView.swift | 179 ++++++++++++++++-- .../APIClientIdentityStatusTests.swift | 122 ++++++++++++ .../ModelTests/IdentityHealthTests.swift | 160 ++++++++++++++++ 6 files changed, 654 insertions(+), 15 deletions(-) create mode 100644 InterlinedList/Models/IdentityHealth.swift create mode 100644 InterlinedList/Services/APIClient+Identities.swift create mode 100644 InterlinedListTests/APIClientTests/APIClientIdentityStatusTests.swift create mode 100644 InterlinedListTests/ModelTests/IdentityHealthTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f6d3089..4d5346c 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 5B19E1BF3FA99B70C1F0153A /* APIClientSharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */; }; 5F19AD96D4278D5B8D87154E /* MessagesInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471B0516327616EC36DE917 /* MessagesInboxView.swift */; }; 5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */; }; + 61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; @@ -58,6 +59,7 @@ 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; 798DC41883DC941055210F39 /* AISuggestionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */; }; + 7B9B1453EE6A8560D94D357A /* APIClient+Identities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC9510D4092B9D8DE46F152 /* APIClient+Identities.swift */; }; 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; }; 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; }; 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; @@ -110,6 +112,7 @@ B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */; }; B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000E /* ListItemFormView.swift */; }; B270170D3EEF336A45464D01 /* CrossPostLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164718CC67967AC147E30465 /* CrossPostLinksView.swift */; }; + BDCCBC7CA67219EA0998AE91 /* APIClientIdentityStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */; }; C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A026223AD393B5E1A339E045 /* APIClient+Limits.swift */; }; C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; @@ -119,6 +122,7 @@ C60FC4296F72CEDEEF394848 /* DMThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */; }; C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */; }; C68E4AC129D6026DF9FF5A5B /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */; }; + C77AB91280416826FCC6A6D3 /* IdentityHealthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DBBE05B636285F9CA9A2431 /* IdentityHealthTests.swift */; }; C818B9571C62180579743014 /* DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */; }; C83E43066F7C0ABF783444CA /* SharedResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C470172F5310397C2E6195BC /* SharedResolution.swift */; }; C841BDE9BCCCB4D2517BBFEE /* ShareInvitesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */; }; @@ -232,6 +236,7 @@ 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTagsTests.swift; sourceTree = ""; }; 164718CC67967AC147E30465 /* CrossPostLinksView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossPostLinksView.swift; sourceTree = ""; }; 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MutedUsersView.swift; sourceTree = ""; }; + 1DC9510D4092B9D8DE46F152 /* APIClient+Identities.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Identities.swift"; sourceTree = ""; }; 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; @@ -273,7 +278,9 @@ 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = ""; }; 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; 7F919661375CA53C1864025A /* AIArtifact.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifact.swift; sourceTree = ""; }; + 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IdentityHealth.swift; sourceTree = ""; }; 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownEditor.swift; sourceTree = ""; }; + 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientIdentityStatusTests.swift; sourceTree = ""; }; 8471B0516327616EC36DE917 /* MessagesInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesInboxView.swift; sourceTree = ""; }; 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSync.swift; sourceTree = ""; }; 8B79D26B822DCA1188DF37E9 /* PushService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushService.swift; sourceTree = ""; }; @@ -286,6 +293,7 @@ 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedListsTests.swift; sourceTree = ""; }; + 9DBBE05B636285F9CA9A2431 /* IdentityHealthTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IdentityHealthTests.swift; sourceTree = ""; }; A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIWritingAssistantButton.swift; sourceTree = ""; }; A026223AD393B5E1A339E045 /* APIClient+Limits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Limits.swift"; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -511,6 +519,7 @@ 70A0129BD986243B7BECC74B /* AIRequest.swift */, 5D154417430173A77A358118 /* AISeriesLoading.swift */, DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */, + 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */, ); path = Models; sourceTree = ""; @@ -540,6 +549,7 @@ 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */, 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, + 1DC9510D4092B9D8DE46F152 /* APIClient+Identities.swift */, ); path = Services; sourceTree = ""; @@ -669,6 +679,7 @@ 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */, 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */, + 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -700,6 +711,7 @@ DCE615A2103F89BAFD24418B /* AIJSONTests.swift */, F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */, EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */, + 9DBBE05B636285F9CA9A2431 /* IdentityHealthTests.swift */, ); path = ModelTests; sourceTree = ""; @@ -932,6 +944,8 @@ FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */, 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */, E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, + 7B9B1453EE6A8560D94D357A /* APIClient+Identities.swift in Sources */, + 61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1018,6 +1032,8 @@ 016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */, 8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */, C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */, + BDCCBC7CA67219EA0998AE91 /* APIClientIdentityStatusTests.swift in Sources */, + C77AB91280416826FCC6A6D3 /* IdentityHealthTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/IdentityHealth.swift b/InterlinedList/Models/IdentityHealth.swift new file mode 100644 index 0000000..aca4ab3 --- /dev/null +++ b/InterlinedList/Models/IdentityHealth.swift @@ -0,0 +1,124 @@ +// +// IdentityHealth.swift +// InterlinedList +// + +import Foundation + +/// What a provider's `/status` route actually measures. The five routes look alike +/// and mean different things, so health has to be resolved per kind rather than by +/// treating every `configured: false` the same way (see `APIClient+Identities`). +enum ProviderStatusKind: Equatable { + /// LinkedIn / Twitter / GitHub: server-side OAuth app credentials. Reads no + /// per-user data, so it can never single out one user's stale connection. + case serverConfiguration + /// Bluesky / Mastodon: whether *this* user still has a stored identity row. + case userIdentityRow +} + +extension OAuthProvider { + var statusKind: ProviderStatusKind { + switch self { + case .linkedin, .twitter, .github: return .serverConfiguration + case .bluesky, .mastodon: return .userIdentityRow + } + } +} + +/// The outcome of one status call. +/// +/// `failed` is deliberately a case of its own rather than a `false`: a call that +/// could not be made proves nothing, and must never be read as "disconnected". +enum ProviderStatusOutcome: Equatable { + case configured + case notConfigured + /// `unauthorized` marks a 401 so the view can re-validate the session through + /// `authState.handleUnauthorized()`. A feature-endpoint 401 does **not** mean + /// logged out — some routes only accept session cookies (CLAUDE.md). + case failed(unauthorized: Bool) +} + +/// Per-identity health, as far as the status routes can honestly report it. +enum IdentityHealth: Equatable { + case connected + /// The provider positively contradicted a listed identity — the row it should + /// have is gone, so reconnecting is the repair. + case needsReconnect(reason: String) + /// Either the check failed, or the route can't speak to this identity at all. + /// Never rendered as "disconnected". + case unknown(reason: String) + + var isStale: Bool { + if case .needsReconnect = self { return true } + return false + } +} + +/// The five status outcomes gathered for one pass over `LinkedIdentitiesView`. +/// +/// Every field defaults to `failed` so a snapshot that was never filled in reads +/// as *unknown* rather than as healthy or disconnected. +struct IdentityStatusSnapshot: Equatable { + var linkedin: ProviderStatusOutcome = .failed(unauthorized: false) + var twitter: ProviderStatusOutcome = .failed(unauthorized: false) + var bluesky: ProviderStatusOutcome = .failed(unauthorized: false) + var github: ProviderStatusOutcome = .failed(unauthorized: false) + /// Mastodon is per-instance, keyed by the host out of `mastodon:{host}`. + var mastodon: [String: ProviderStatusOutcome] = [:] + + var sawUnauthorized: Bool { + ([linkedin, twitter, bluesky, github] + mastodon.values).contains(.failed(unauthorized: true)) + } + + func outcome(for identity: APIClient.LinkedIdentity) -> ProviderStatusOutcome { + guard let provider = OAuthProvider(rawValue: identity.providerType) else { + return .failed(unauthorized: false) + } + switch provider { + case .linkedin: return linkedin + case .twitter: return twitter + case .bluesky: return bluesky + case .github: return github + case .mastodon: + guard let instance = identity.providerInstance else { + return .failed(unauthorized: false) + } + return mastodon[instance] ?? .failed(unauthorized: false) + } + } + + func health(for identity: APIClient.LinkedIdentity) -> IdentityHealth { + guard let provider = OAuthProvider(rawValue: identity.providerType) else { + return .unknown(reason: "No status check for this provider.") + } + switch outcome(for: identity) { + case .configured: + return .connected + case .failed: + return .unknown(reason: "Couldn't check this connection just now.") + case .notConfigured: + switch provider.statusKind { + case .userIdentityRow: + return .needsReconnect( + reason: "\(provider.displayName) no longer has this connection stored." + ) + case .serverConfiguration: + // The route answered about the *server*, not this user: reconnecting + // can't fix missing OAuth credentials, so this is not "needs reconnect". + return .unknown( + reason: "\(provider.displayName) sign-in is unavailable right now, so this connection can't be checked." + ) + } + } + } +} + +extension APIClient.LinkedIdentity { + /// Instance host out of `mastodon:techhub.social`; `nil` when the provider + /// string carries no suffix. + var providerInstance: String? { + guard let separator = provider.firstIndex(of: ":") else { return nil } + let host = String(provider[provider.index(after: separator)...]) + return host.isEmpty ? nil : host + } +} diff --git a/InterlinedList/Services/APIClient+Identities.swift b/InterlinedList/Services/APIClient+Identities.swift new file mode 100644 index 0000000..df98f27 --- /dev/null +++ b/InterlinedList/Services/APIClient+Identities.swift @@ -0,0 +1,68 @@ +// +// APIClient+Identities.swift +// InterlinedList +// + +import Foundation + +/// The three OAuth status routes `APIClient.swift` didn't already cover +/// (`linkedinStatus()` / `twitterStatus()` live there). +/// +/// **The five routes are not interchangeable, and none of them proves a token is +/// still good upstream.** Two different contracts hide behind the same `/status` +/// suffix: +/// +/// - **LinkedIn, Twitter, GitHub** answer from server environment only +/// (`lib/integrations/connected-accounts-status.ts` — "nothing here reads +/// per-user data"). `configured` means the server holds OAuth app credentials +/// for the provider, so a `false` says the provider is unusable for *everyone*, +/// not that this user's connection went stale. GitHub's is **public** — no auth +/// check at all — while the other two simply ignore the Bearer we send. +/// - **Bluesky, Mastodon** are Bearer/session (`getCurrentUserOrSyncToken`) and +/// per-user, but only report whether a `LinkedIdentity` row exists for the +/// user. That is the same table `GET /api/user/identities` reads, so they +/// detect a row that vanished server-side — not a revoked token. +/// +/// The backend *does* track real per-identity health (`LinkedIdentity.needsReconnect`, +/// flagged by `lib/twitter/token-refresh.ts` on a permanent auth failure, and +/// already surfaced by `getLinkedIdentitiesForUser`), but `GET /api/user/identities` +/// does not select it. Until it does, a token revoked upstream cannot be seen +/// from the app — see `IdentityHealth` for how far these routes let us go. +extension APIClient { + + /// `GET /api/auth/github/status` — public. `clientId` is not a secret (the + /// client *secret* is never exposed); `manageOrgAccessUrl` is GitHub's + /// authorized-app page, the only reliable place to grant org access to an + /// already-authorized OAuth App. + struct GitHubOAuthStatus: Decodable, Equatable { + let configured: Bool + let clientId: String? + let manageOrgAccessUrl: String? + } + + /// `GET /api/auth/{bluesky,mastodon}/status` — `configured` is "this user has a + /// stored identity row for the provider", not "the stored token still works". + struct ProviderLinkStatus: Decodable, Equatable { + let configured: Bool + } + + func githubStatus() async throws -> GitHubOAuthStatus { + try await get("/api/auth/github/status") + } + + func blueskyStatus() async throws -> ProviderLinkStatus { + try await get("/api/auth/bluesky/status") + } + + /// Mastodon is per-instance: the route **400s without `?instance=`** and looks + /// up the identity stored as `mastodon:{instance}`, so each linked instance + /// needs its own call. + func mastodonStatus(instance: String) async throws -> ProviderLinkStatus { + var components = URLComponents() + components.queryItems = [URLQueryItem(name: "instance", value: instance)] + guard let query = components.percentEncodedQuery, !query.isEmpty else { + throw APIError.invalidURL + } + return try await get("/api/auth/mastodon/status?\(query)") + } +} diff --git a/InterlinedList/Views/LinkedIdentitiesView.swift b/InterlinedList/Views/LinkedIdentitiesView.swift index 26594f8..074500a 100644 --- a/InterlinedList/Views/LinkedIdentitiesView.swift +++ b/InterlinedList/Views/LinkedIdentitiesView.swift @@ -5,26 +5,31 @@ import SwiftUI -/// Lists the OAuth providers linked to the signed-in account and lets the user disconnect -/// them (`DELETE /api/user/identities`). In-app *linking* of a new provider is gated off -/// (see `linkingEnabled`). Reachable only for subscribers (gated by the caller in `MainTabView`). +/// Lists the OAuth providers linked to the signed-in account, reports how healthy +/// each connection looks, and lets the user disconnect (`DELETE /api/user/identities`) +/// or reconnect a stale one. Reachable only for subscribers (gated by the caller in +/// `MainTabView`). struct LinkedIdentitiesView: View { @EnvironmentObject var authState: AuthState - /// In-app provider linking is disabled: the backend `?link=true` callback - /// authenticates via the web session cookie (`getCurrentUser()`), not the - /// Bearer token, so a native (Bearer-only) client can't link a new provider - /// through it. Flip to `true` once the backend exposes a Bearer-authenticated - /// link endpoint. (Backend auth contract — Open Dependency #2.) + /// In-app linking of a *new* provider is disabled: the backend `?link=true` + /// callback authenticates via the web session cookie (`getCurrentUser()`), not + /// the Bearer token, so a native client can't link a new provider through it. + /// Flip to `true` once the backend exposes a Bearer-authenticated link endpoint. + /// (Backend auth contract — Open Dependency #2.) private let linkingEnabled = false @State private var identities: [APIClient.LinkedIdentity] = [] + @State private var statusSnapshot: IdentityStatusSnapshot? @State private var isLoading = true @State private var errorMessage: String? @State private var pendingUnlink: APIClient.LinkedIdentity? @State private var linkInFlight = false @State private var showMastodonPrompt = false @State private var mastodonInstance = "" + /// Set when the Mastodon prompt is repairing an existing row rather than + /// linking a fresh one, so the reconnect follow-up can re-check that row. + @State private var reconnectingIdentity: APIClient.LinkedIdentity? var body: some View { List { @@ -49,6 +54,11 @@ struct LinkedIdentitiesView: View { } } header: { Text("Connected accounts") + } footer: { + if !isLoading && !identities.isEmpty { + Text("A connection shown as unknown couldn't be checked — it hasn't been disconnected.") + .font(.ilMono(12)) + } } if linkingEnabled { @@ -97,9 +107,10 @@ struct LinkedIdentitiesView: View { TextField("mastodon.social", text: $mastodonInstance) .textInputAutocapitalization(.never) Button("Continue") { - runLink(provider: .mastodon, instance: mastodonInstance) + runLink(provider: .mastodon, instance: mastodonInstance, reconnecting: reconnectingIdentity) + reconnectingIdentity = nil } - Button("Cancel", role: .cancel) { } + Button("Cancel", role: .cancel) { reconnectingIdentity = nil } } message: { Text("Enter your Mastodon server hostname.") } @@ -107,11 +118,12 @@ struct LinkedIdentitiesView: View { @ViewBuilder private func identityRow(_ identity: APIClient.LinkedIdentity) -> some View { + let health = statusSnapshot?.health(for: identity) HStack(spacing: 12) { Image(systemName: OAuthProvider(rawValue: identity.providerType)?.systemImageName ?? "link") .frame(width: 24) .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(displayName(for: identity.provider)) .font(.ilBody()) if let username = identity.providerUsername, !username.isEmpty { @@ -119,6 +131,16 @@ struct LinkedIdentitiesView: View { .font(.ilMono()) .foregroundStyle(.secondary) } + healthLabel(health, for: identity) + if health?.isStale == true { + Button("Reconnect") { + startReconnect(identity) + } + .buttonStyle(.bordered) + .font(.ilBody(14)) + .disabled(linkInFlight) + .accessibilityLabel("Reconnect \(displayName(for: identity.provider))") + } } Spacer() Button("Disconnect", role: .destructive) { @@ -130,6 +152,41 @@ struct LinkedIdentitiesView: View { } } + @ViewBuilder + private func healthLabel(_ health: IdentityHealth?, for identity: APIClient.LinkedIdentity) -> some View { + let name = displayName(for: identity.provider) + switch health { + case nil: + Text("Checking…") + .font(.ilMono(12)) + .foregroundStyle(.secondary) + .accessibilityLabel("Checking \(name) connection") + case .connected: + Label("Connected", systemImage: "checkmark.circle.fill") + .font(.ilMono(12)) + .foregroundStyle(.green) + .accessibilityLabel("\(name) connected") + case .needsReconnect(let reason): + VStack(alignment: .leading, spacing: 2) { + Label("Needs reconnect", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(reason) + .foregroundStyle(.secondary) + } + .font(.ilMono(12)) + .accessibilityLabel("\(name) needs reconnect. \(reason)") + case .unknown(let reason): + VStack(alignment: .leading, spacing: 2) { + Label("Status unknown", systemImage: "questionmark.circle") + .foregroundStyle(.secondary) + Text(reason) + .foregroundStyle(.secondary) + } + .font(.ilMono(12)) + .accessibilityLabel("\(name) status unknown. \(reason)") + } + } + private func displayName(for provider: String) -> String { OAuthProvider(rawValue: String(provider.prefix(while: { $0 != ":" })))?.displayName ?? provider.capitalized } @@ -137,13 +194,77 @@ struct LinkedIdentitiesView: View { private func load() async { errorMessage = nil isLoading = true - defer { isLoading = false } do { identities = try await APIClient.shared.linkedIdentities() + isLoading = false + } catch APIError.status(401) { + isLoading = false + authState.handleUnauthorized() + errorMessage = "Couldn't load connected accounts." + return } catch APIError.server(let message) { + isLoading = false errorMessage = message + return } catch { + isLoading = false errorMessage = "Couldn't load connected accounts." + return + } + await loadStatuses() + } + + /// All five status routes at once. Mastodon needs one call per linked instance, + /// which is why the instance list has to come from `identities` first. + private func loadStatuses() async { + let instances = Set(identities.compactMap { identity -> String? in + identity.providerType == OAuthProvider.mastodon.rawValue ? identity.providerInstance : nil + }) + + async let linkedin = Self.statusOutcome { try await APIClient.shared.linkedinStatus().configured } + async let twitter = Self.statusOutcome { try await APIClient.shared.twitterStatus().configured } + async let bluesky = Self.statusOutcome { try await APIClient.shared.blueskyStatus().configured } + async let github = Self.statusOutcome { try await APIClient.shared.githubStatus().configured } + async let mastodon = Self.mastodonOutcomes(instances: instances) + + let snapshot = IdentityStatusSnapshot( + linkedin: await linkedin, + twitter: await twitter, + bluesky: await bluesky, + github: await github, + mastodon: await mastodon + ) + statusSnapshot = snapshot + // A status-route 401 proves nothing about the session on its own, so it goes + // through re-validation rather than a logout (CLAUDE.md). + if snapshot.sawUnauthorized { + authState.handleUnauthorized() + } + } + + private static func mastodonOutcomes(instances: Set) async -> [String: ProviderStatusOutcome] { + await withTaskGroup(of: (String, ProviderStatusOutcome).self) { group in + for instance in instances { + group.addTask { + let outcome = await statusOutcome { + try await APIClient.shared.mastodonStatus(instance: instance).configured + } + return (instance, outcome) + } + } + return await group.reduce(into: [:]) { $0[$1.0] = $1.1 } + } + } + + /// Runs one status call, turning any failure into `.failed` so a dead check can + /// never downgrade a healthy identity. + private static func statusOutcome(_ call: @Sendable () async throws -> Bool) async -> ProviderStatusOutcome { + do { + return try await call() ? .configured : .notConfigured + } catch APIError.status(401) { + return .failed(unauthorized: true) + } catch { + return .failed(unauthorized: false) } } @@ -153,6 +274,9 @@ struct LinkedIdentitiesView: View { do { try await APIClient.shared.unlinkIdentity(provider: identity.provider, providerId: identity.id) await load() + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "Couldn't disconnect \(displayName(for: identity.provider))." } catch APIError.server(let message) { errorMessage = message } catch { @@ -163,13 +287,25 @@ struct LinkedIdentitiesView: View { private func startLink(provider: OAuthProvider) { if provider == .mastodon { mastodonInstance = "" + reconnectingIdentity = nil showMastodonPrompt = true return } - runLink(provider: provider, instance: nil) + runLink(provider: provider, instance: nil, reconnecting: nil) } - private func runLink(provider: OAuthProvider, instance: String?) { + private func startReconnect(_ identity: APIClient.LinkedIdentity) { + guard let provider = OAuthProvider(rawValue: identity.providerType) else { return } + if provider == .mastodon { + mastodonInstance = identity.providerInstance ?? "" + reconnectingIdentity = identity + showMastodonPrompt = true + return + } + runLink(provider: provider, instance: nil, reconnecting: identity) + } + + private func runLink(provider: OAuthProvider, instance: String?, reconnecting: APIClient.LinkedIdentity?) { Task { errorMessage = nil linkInFlight = true @@ -178,14 +314,27 @@ struct LinkedIdentitiesView: View { _ = try await OAuthCoordinator.shared.authenticate(provider: provider, instance: instance, link: true) await load() } catch OAuthError.cancelled { - // User backed out — nothing to surface. + // The `?link=true` callback finishes on a *web* redirect, not the + // custom-scheme token handoff, so a link that the server actually + // completed still surfaces here as a cancellation. Reload before + // believing it failed. + await load() } catch OAuthError.providerError(let message) { errorMessage = message } catch { errorMessage = "Couldn't link \(provider.displayName). Please try again." } + if let reconnecting, stillStale(reconnecting) { + errorMessage = "Couldn't confirm the \(provider.displayName) reconnect. Finish connecting at interlinedlist.com." + } } } + + private func stillStale(_ identity: APIClient.LinkedIdentity) -> Bool { + guard let snapshot = statusSnapshot, + let current = identities.first(where: { $0.id == identity.id }) else { return false } + return snapshot.health(for: current).isStale + } } #Preview { diff --git a/InterlinedListTests/APIClientTests/APIClientIdentityStatusTests.swift b/InterlinedListTests/APIClientTests/APIClientIdentityStatusTests.swift new file mode 100644 index 0000000..f576398 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientIdentityStatusTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import InterlinedList + +/// The three status routes added in `APIClient+Identities.swift`. +/// +/// Each call is stubbed on its own rather than fired together: `MockURLSession` +/// serves `enqueue` FIFO with no path matching, so the view's five concurrent +/// `async let` calls would consume the queue in nondeterministic order. The +/// aggregation those calls feed is covered by `IdentityHealthTests` instead. +final class APIClientIdentityStatusTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + } + + override func tearDown() { + sut = nil + session = nil + super.tearDown() + } + + // MARK: githubStatus + + func test_githubStatus_sendsCorrectPathAndDecodes() async throws { + session.stub(json: #"{"configured":true,"clientId":"Iv1.abc","manageOrgAccessUrl":"https://github.com/settings/connections/applications/Iv1.abc"}"#) + let status = try await sut.githubStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/github/status") + XCTAssertTrue(status.configured) + XCTAssertEqual(status.clientId, "Iv1.abc") + XCTAssertEqual(status.manageOrgAccessUrl, "https://github.com/settings/connections/applications/Iv1.abc") + } + + func test_githubStatus_notConfiguredDecodesNulls() async throws { + session.stub(json: #"{"configured":false,"clientId":null,"manageOrgAccessUrl":null}"#) + let status = try await sut.githubStatus() + XCTAssertFalse(status.configured) + XCTAssertNil(status.clientId) + XCTAssertNil(status.manageOrgAccessUrl) + } + + // MARK: blueskyStatus + + func test_blueskyStatus_sendsCorrectPathAndDecodes() async throws { + session.stub(json: #"{"configured":true}"#) + let status = try await sut.blueskyStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/bluesky/status") + XCTAssertTrue(status.configured) + } + + func test_blueskyStatus_configuredFalse() async throws { + session.stub(json: #"{"configured":false}"#) + let status = try await sut.blueskyStatus() + XCTAssertFalse(status.configured) + } + + /// Bluesky is Bearer-authenticated, so a 401 has to reach the caller as + /// `.status(401)` — the view re-validates rather than logging out. + func test_blueskyStatus_401_throwsStatus401() async throws { + session.stub(json: #"{"error":"Unauthorized"}"#, statusCode: 401) + do { + _ = try await sut.blueskyStatus() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: mastodonStatus + + func test_mastodonStatus_sendsInstanceQuery() async throws { + session.stub(json: #"{"configured":true}"#) + let status = try await sut.mastodonStatus(instance: "techhub.social") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/mastodon/status") + XCTAssertEqual(session.lastRequest?.url?.query, "instance=techhub.social") + XCTAssertTrue(status.configured) + } + + func test_mastodonStatus_configuredFalse() async throws { + session.stub(json: #"{"configured":false}"#) + let status = try await sut.mastodonStatus(instance: "mastodon.social") + XCTAssertFalse(status.configured) + } + + /// An instance needing escaping must not be pasted raw into the query string — + /// an unencoded `&` would split into a second query item and the route would + /// see no instance at all (which it answers with a 400). + func test_mastodonStatus_percentEncodesInstance() async throws { + session.stub(json: #"{"configured":false}"#) + _ = try await sut.mastodonStatus(instance: "a b&c") + XCTAssertEqual(session.lastRequest?.url?.query, "instance=a%20b%26c") + } + + func test_mastodonStatus_500_throws() async throws { + session.stub(data: Data(), statusCode: 500) + do { + _ = try await sut.mastodonStatus(instance: "techhub.social") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 500) + } + } + + // MARK: existing two, for the five-provider set + + func test_linkedinStatus_stillReadsItsRoute() async throws { + session.stub(json: #"{"configured":true,"redirectUri":"https://example.com/cb","orgScopesEnabled":false}"#) + let status = try await sut.linkedinStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/linkedin/status") + XCTAssertTrue(status.configured) + } + + func test_twitterStatus_stillReadsItsRoute() async throws { + session.stub(json: #"{"configured":true,"redirectUri":"https://x/cb"}"#) + let status = try await sut.twitterStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/twitter/status") + XCTAssertTrue(status.configured) + } +} diff --git a/InterlinedListTests/ModelTests/IdentityHealthTests.swift b/InterlinedListTests/ModelTests/IdentityHealthTests.swift new file mode 100644 index 0000000..ac507d2 --- /dev/null +++ b/InterlinedListTests/ModelTests/IdentityHealthTests.swift @@ -0,0 +1,160 @@ +import XCTest +@testable import InterlinedList + +/// Health resolution for all five providers, including the failure case. +final class IdentityHealthTests: XCTestCase { + + private func identity(_ provider: String, id: String = "id-1") -> APIClient.LinkedIdentity { + APIClient.LinkedIdentity( + id: id, + provider: provider, + providerUsername: "someone", + createdAt: nil + ) + } + + // MARK: all five report a status when configured + + func test_allFiveProviders_configured_readAsConnected() { + let snapshot = IdentityStatusSnapshot( + linkedin: .configured, + twitter: .configured, + bluesky: .configured, + github: .configured, + mastodon: ["techhub.social": .configured] + ) + for provider in ["linkedin", "twitter", "bluesky", "github", "mastodon:techhub.social"] { + XCTAssertEqual(snapshot.health(for: identity(provider)), .connected, + "\(provider) should read as connected") + } + } + + // MARK: the failure case — never a downgrade + + func test_failedStatusCall_degradesToUnknown_forEveryProvider() { + let snapshot = IdentityStatusSnapshot( + linkedin: .failed(unauthorized: false), + twitter: .failed(unauthorized: false), + bluesky: .failed(unauthorized: false), + github: .failed(unauthorized: false), + mastodon: ["techhub.social": .failed(unauthorized: false)] + ) + for provider in ["linkedin", "twitter", "bluesky", "github", "mastodon:techhub.social"] { + let health = snapshot.health(for: identity(provider)) + guard case .unknown = health else { + return XCTFail("\(provider) failure should be unknown, got \(health)") + } + XCTAssertFalse(health.isStale, "\(provider) failure must not read as stale") + } + } + + /// The default snapshot is what a view holds before any call lands, and what it + /// keeps if every call dies: unknown, never healthy and never disconnected. + func test_defaultSnapshot_isUnknownNotConnected() { + let snapshot = IdentityStatusSnapshot() + for provider in ["linkedin", "twitter", "bluesky", "github", "mastodon:techhub.social"] { + guard case .unknown = snapshot.health(for: identity(provider)) else { + return XCTFail("\(provider) should default to unknown") + } + } + } + + func test_failedCallDoesNotDowngradeAHealthyPeer() { + let snapshot = IdentityStatusSnapshot( + linkedin: .configured, + twitter: .failed(unauthorized: false), + bluesky: .configured, + github: .failed(unauthorized: true), + mastodon: [:] + ) + XCTAssertEqual(snapshot.health(for: identity("linkedin")), .connected) + XCTAssertEqual(snapshot.health(for: identity("bluesky")), .connected) + } + + // MARK: notConfigured means different things per route kind + + /// Bluesky and Mastodon read per-user rows, so a `false` positively contradicts + /// a listed identity — that is a genuine "needs reconnect". + func test_perUserRoutes_notConfigured_needsReconnect() { + let snapshot = IdentityStatusSnapshot( + bluesky: .notConfigured, + mastodon: ["techhub.social": .notConfigured] + ) + XCTAssertTrue(snapshot.health(for: identity("bluesky")).isStale) + XCTAssertTrue(snapshot.health(for: identity("mastodon:techhub.social")).isStale) + } + + /// LinkedIn / Twitter / GitHub status reads no per-user data, so a `false` says + /// the server lost its OAuth credentials. Reconnecting cannot fix that, so it + /// must not be offered as a stale row. + func test_serverConfigRoutes_notConfigured_isUnknownNotStale() { + let snapshot = IdentityStatusSnapshot( + linkedin: .notConfigured, + twitter: .notConfigured, + github: .notConfigured + ) + for provider in ["linkedin", "twitter", "github"] { + let health = snapshot.health(for: identity(provider)) + guard case .unknown = health else { + return XCTFail("\(provider) should be unknown, got \(health)") + } + XCTAssertFalse(health.isStale, "\(provider) must not offer reconnect") + } + } + + // MARK: mastodon is per-instance + + func test_mastodon_resolvesPerInstance() { + let snapshot = IdentityStatusSnapshot( + mastodon: ["techhub.social": .configured, "mastodon.social": .notConfigured] + ) + XCTAssertEqual(snapshot.health(for: identity("mastodon:techhub.social")), .connected) + XCTAssertTrue(snapshot.health(for: identity("mastodon:mastodon.social")).isStale) + } + + func test_mastodon_instanceWithNoStatusIsUnknown() { + let snapshot = IdentityStatusSnapshot(mastodon: ["techhub.social": .configured]) + guard case .unknown = snapshot.health(for: identity("mastodon:other.example")) else { + return XCTFail("an unchecked instance should be unknown") + } + } + + func test_bareMastodonProviderWithNoInstanceIsUnknown() { + let snapshot = IdentityStatusSnapshot(mastodon: ["techhub.social": .configured]) + guard case .unknown = snapshot.health(for: identity("mastodon")) else { + return XCTFail("mastodon with no instance suffix should be unknown") + } + } + + func test_unrecognisedProviderIsUnknown() { + let snapshot = IdentityStatusSnapshot(linkedin: .configured) + guard case .unknown = snapshot.health(for: identity("someothernetwork")) else { + return XCTFail("an unknown provider should be unknown") + } + } + + // MARK: 401 surfacing + + func test_sawUnauthorized_onlyWhenA401Happened() { + XCTAssertFalse(IdentityStatusSnapshot(linkedin: .configured, bluesky: .notConfigured).sawUnauthorized) + XCTAssertTrue(IdentityStatusSnapshot(bluesky: .failed(unauthorized: true)).sawUnauthorized) + XCTAssertTrue(IdentityStatusSnapshot(mastodon: ["a.example": .failed(unauthorized: true)]).sawUnauthorized) + } + + // MARK: provider parsing + + func test_providerInstance_parsing() { + XCTAssertEqual(identity("mastodon:techhub.social").providerInstance, "techhub.social") + XCTAssertNil(identity("mastodon").providerInstance) + XCTAssertNil(identity("mastodon:").providerInstance) + XCTAssertEqual(identity("mastodon:techhub.social").providerType, "mastodon") + } + + func test_statusKind_perProvider() { + XCTAssertEqual(OAuthProvider.linkedin.statusKind, .serverConfiguration) + XCTAssertEqual(OAuthProvider.twitter.statusKind, .serverConfiguration) + XCTAssertEqual(OAuthProvider.github.statusKind, .serverConfiguration) + XCTAssertEqual(OAuthProvider.bluesky.statusKind, .userIdentityRow) + XCTAssertEqual(OAuthProvider.mastodon.statusKind, .userIdentityRow) + } +}