Skip to content

feat(auth): check all five identity providers for connection health - #67

Merged
Adron merged 1 commit into
mainfrom
feat/identity-status-all-providers
Sep 13, 2026
Merged

Adron merged 1 commit into
mainfrom
feat/identity-status-all-providers

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

LinkedIdentitiesView checked only the LinkedIn and Twitter status routes, so a Bluesky, Mastodon or GitHub row always rendered as a healthy "connected" account — the user's first signal of trouble was a cross-post failing later. All five routes are now read in parallel when the view appears, and each identity shows its own health.

Closes #63

What's included

Commit 44c303b:

  • Services/APIClient+Identities.swift (new) — the three missing status calls (github, bluesky, mastodon). Mastodon is per-instance (the route 400s without ?instance=), so each linked instance gets its own percent-encoded call. GitHub's route is public; the other two are Bearer.
  • Models/IdentityHealth.swift (new) — a pure resolver mapping the five outcomes onto connected · needs reconnect · unknown. Kept out of APIClient (HTTP-only per CLAUDE.md) 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.
  • Views/LinkedIdentitiesView.swift — reads the five routes from .task via async let (plus a task group for the Mastodon instances), renders a per-row health badge with a reason, and offers Reconnect on a stale row through the existing OAuthCoordinator.authenticate(provider:instance:link:true) flow. The Mastodon instance prompt still works and now prefills the row's existing instance when reconnecting.
  • A failed status call degrades to unknown and never downgrades a healthy identity. The default snapshot is unknown, so a view that never got an answer cannot render a connection as disconnected.
  • 401 handling — status-route, identities and unlink 401s now go through authState.handleUnauthorized() rather than a logout or a generic error, per CLAUDE.md.
  • Four new files registered in project.pbxproj via the xcodeproj gem.

What these routes can and cannot prove

Worth a review eye, because it shapes the mapping (documented in both new files):

  • LinkedIn / Twitter / GitHub status read no per-user data at all — they report server-side OAuth app configuration (lib/integrations/connected-accounts-status.ts: "nothing here reads per-user data"). So configured: false means the provider is unusable for everyone, not that this user's connection went stale. That maps to unknown, not needs-reconnect: reconnecting cannot fix missing server credentials, so offering the button there would be a dead end.
  • Bluesky / Mastodon are per-user but only report whether a LinkedIdentity row exists — the same table GET /api/user/identities reads. They catch a row that vanished server-side, not a revoked token.

Follow-up (backend ask): a token revoked upstream still reads as connected, so issue #63's "revoking a provider elsewhere shows needs reconnect" isn't fully reachable from the app yet. The real signal already exists — LinkedIdentity.needsReconnect, flagged by lib/twitter/token-refresh.ts on permanent auth failure and already returned by getLinkedIdentitiesForUser (alongside expiresSoon) — but GET /api/user/identities doesn't select it. Wiring that route to getLinkedIdentitiesForUser would make this view report true per-identity health with no further iOS work.

Known caveat: the ?link=true callback authenticates via the web session cookie and finishes on a web redirect rather than the custom-scheme token handoff (the same constraint that keeps linkingEnabled = false). A reconnect the server actually completed therefore surfaces as an OAuthError.cancelled, so the view reloads before believing it failed and only points at the web if the row is still stale afterwards.

Testing

  • xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=6BBED4E1…' -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests buildBUILD SUCCEEDED, no new warnings.
  • Full unit suite, serialized — 966 tests, 0 failures.
  • 24 of those are new: APIClientIdentityStatusTests (11 — path, decode and failure per route, incl. a Bearer 401 and instance percent-encoding) and IdentityHealthTests (13 — all five providers connected, all five failing to unknown, the default snapshot, per-instance Mastodon resolution, and the two different meanings of configured: false).
  • E2E suite deliberately skipped.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFt41K2Z9ghobFHy8Jiqgx
@Adron
Adron merged commit 98a7515 into main Sep 13, 2026
1 check passed
@Adron
Adron deleted the feat/identity-status-all-providers branch September 13, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

W8: Identity status for Bluesky, Mastodon and GitHub (P5)

1 participant