Skip to content

feat(identities): add per-identity connection check - #99

Closed
Adron wants to merge 1 commit into
fix/unlink-identity-query-paramfrom
feat/identity-token-health
Closed

Adron wants to merge 1 commit into
fix/unlink-identity-query-paramfrom
feat/identity-token-health

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Closes #92.

Stacked on #93 (fix/unlink-identity-query-param), which must merge first — this PR targets
that branch, not main, because both touch the same region of Services/APIClient.swift and the
same Views/LinkedIdentitiesView.swift. Once #93 lands, re-base this onto main.

LinkedIdentitiesView reported each provider's /status, and none of those five routes looks at the
account's stored token — they answer either "the server holds OAuth app credentials for this
provider" (LinkedIn/Twitter/GitHub) or "a row exists in this user's identity table"
(Bluesky/Mastodon). Both stay cheerfully configured: true after a token is revoked upstream, so a
user with an expired Bluesky or Mastodon credential saw a healthy row and found out only when a
cross-post silently failed.

POST /api/user/identities/verify is the only route that loads the credential and exercises it —
restoring the DPoP-bound AT Protocol session for Bluesky, calling the provider's "who am I" endpoint
otherwise. APIClient.verifyIdentity existed for it and had no caller; this wires it up.

What's included

  • "Check connection" per linked identity. Manual, not an on-appear sweep: every call makes the
    backend hit the third-party provider, so verifying all five on a screen the user opened to read is
    the wrong default.
  • Answers and failures stay separate, which is the acceptance criterion the route source settles:
    route says app shows
    200 {"success":true} "Sign-in verified" (a new IdentityHealth.verified, distinct from the weaker status-route .connected)
    400 — provider rejected the credential, or no token stored "Needs reconnect — …rejected the saved sign-in", with the existing Reconnect button
    404 — identity row gone "Needs reconnect — …is no longer connected to this account"
    401 / 5xx / no network "Couldn't run the check just now. Your … connection is unchanged" — never a reconnect prompt
  • postCamel(_:body:mappingStatuses:) in APIClientTransport, the write-side twin of the
    existing get(_:mappingStatuses:). Without it, checkResponse flattens the route's 400 and a 500
    into the same .server(…) and "your token expired" becomes indistinguishable from "the check
    never ran".
  • providerId dropped from verifyIdentity — the route reads only body.provider and never
    looks at the id. Existing tests updated; a new one asserts the key is absent from the body.
  • A 401 from the check calls authState.handleUnauthorized() (re-validation), never logout().
  • Doc comments corrected: the "no caller yet" framing is gone, and the APIClient+Identities header
    no longer claims a revoked token is invisible to the app — it now points at the route that sees it.

No new .swift files, so project.pbxproj is untouched (and can't conflict with the other in-flight
PRs).

Testing

xcodebuild test on simulator 6DD28061-659E-4E65-937A-D01DE0E8AECF, serialized, E2E skipped:
1166 tests, 0 failures. Clean build, no new warnings.

15 tests added or rewritten:

  • APIClientIdentitiesTests — path/method, camelCase body, providerId absent, 200 → verified,
    400 → needsReconnect(.credentialRejected), 404 → needsReconnect(.identityMissing), 401 → APIError.status(401), 500 → throws (not needs-reconnect), and a transport failure via a
    throwing URLSessionProtocol stub → throws (not needs-reconnect).
  • IdentityHealthTests — verify-outcome → row-health mapping: verified is not connected, both
    failures are stale and name the provider, the two failure reasons differ from each other, and
    uncheckable is unknown and never stale.

Mutation-checked: swapping the 400/404 mapping, mapping 500 to a rejection, re-adding providerId
to the body, returning .connected for a verified credential, and making uncheckable a
needsReconnect produced 8 failures across the 9 new assertions' tests; restoring the
implementation returned the suite to green.

Not verified: the live end-to-end path against a genuinely revoked provider token — that needs a real
expired OAuth credential. The status→outcome mapping is taken from the route source
(app/api/user/identities/verify/route.ts), not inferred.

🤖 Generated with Claude Code

The five `/status` routes report whether a provider is configured server-side,
never whether this account's stored token still works, so an expired Bluesky or
Mastodon credential looked healthy until a cross-post silently failed.

`POST /api/user/identities/verify` is the only route that loads the credential
and exercises it upstream. Wire it to a manual "Check connection" action per row
— manual because each call makes the backend hit the third-party provider, so an
on-appear sweep would bill five remote round-trips to a screen opened to read.

The route's answers are kept apart from its failures: 400 (credential rejected /
no token stored) and 404 (identity row gone) return `needsReconnect` and light up
the existing reconnect affordance, while 401, 5xx and transport errors throw and
render as "couldn't run the check — your connection is unchanged". Without the
new `postCamel(_:body:mappingStatuses:)`, `checkResponse` flattens a 400 and a
500 into the same `.server(…)`, making a dead token indistinguishable from a dead
check.

Also drops `providerId` from `verifyIdentity`: the route reads only
`body.provider` and never looks at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
@Adron

Adron commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

Superseded by a new PR — see below.

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.

1 participant