Skip to content

feat(integrations): unlink, re-verify and connection health on connected accounts (#40) - #111

Merged
Adron merged 1 commit into
parity/queuefrom
issue/40-unlink-reverify
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/40-unlink-reverify

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #40. Part of epic #38. (OAuth linking itself, #39, remains blocked on redirect URIs.)

Contracts checked before wiring

  • DELETE /api/user/identities takes provider as a query parameter
    (?provider=mastodon%3Atechhub.social) — not a body, not a path segment. Confirmed in the spec
    ("name": "provider", "in": "query") and consistent with what :feature:profile already does.
  • POST /api/user/identities/verify takes { "provider": "…" } in a JSON body.
  • Both success bodies are unspecified (type: object), so they are read as a raw ResponseBody and
    discarded — the refreshed list is the source of truth.

Envelope confirmed live (the agent could not reach the API; I checked afterwards):
GET /api/user/identities → {"identities":[{id, provider, providerUsername, profileUrl, avatarUrl, connectedAt, lastVerifiedAt}]}. So the { "identities": [...] } modelling here is
right, and the published help page's bare-array example is wrong. Also confirmed: provider is
instance-qualified
— a real row reads "mastodon:techhub.social".

Staleness threshold: 30 days

/help/cross-posting → Keeping your connections active says platform authorizations expire on
their own, and that LinkedIn's "generally expire after a couple of months and can't be refreshed
automatically". 30 days is half the shortest documented expiry — roughly a month of warning before
the earliest point a cross-post could start silently failing, while staying quiet for anyone who
verifies regularly.

The UI says what it means rather than printing a timestamp:

Last verified 2 months ago. It may have expired — posts may stop reaching LinkedIn without an
error. Tap Verify to check it.

Two design points

  • getConnectedAccounts() now merges GET /api/user/identities into the existing per-provider
    /api/auth/<p>/status rows. One identity = one row, so multiple Mastodon instances stay
    distinct
    — collapsing them into a single "Mastodon" row would have made unlink hit an arbitrary
    instance. (The live account has mastodon:techhub.social, so this is not hypothetical.) A failed
    identities read degrades to status-only rows rather than blanking the screen.
  • Neither mutation is optimistic. A failed unlink changes nothing and surfaces the server's
    message; only success triggers a reload.

The unlink confirmation is provider-aware: cross-post targets get "This stops cross-posting to
LinkedIn."; GitHub — not a cross-post target — gets honest wording instead of a claim that is not
true.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1069 tests repo-wide, 0
failures
. :feature:integrations went from 26 to 66 tests. 4 Compose tests compile, not executed
(no emulator).

Mutation-checked, since everything passed first try: widening STALE_AFTER to 90 days fails 5
tests (the three-state and boundary assertions); adding a reload() to the failure branch fails the
two "failed unlink/verify leaves the row untouched" tests. Both reverted.

No :app change was needed — ConnectedAccountsRoute(onBack)'s signature is unchanged.

Remaining risk

The verify response body is ignored. If the server can return 200 with something like
{"verified": false} rather than a 4xx, a failed verification would read as success until the
refreshed lastVerifiedAt contradicts it. I did not call POST /api/user/identities/verify live to
settle this, since it mutates lastVerifiedAt on your real linked accounts.

Also noted, out of scope: :feature:profile has its own connected-accounts screen with overlapping
unlink logic. Deduplicating the two is a reasonable future issue.

The connected-accounts screen was read-only: DELETE /api/user/identities was
declared but unused and POST /api/user/identities/verify was never called, so a
connection could quietly lapse and take cross-posting with it.

Contracts (confirmed against the OpenAPI spec before wiring):
  - DELETE /api/user/identities takes the identity's `provider` as a QUERY
    PARAMETER (`?provider=mastodon%3Atechhub.social`), not a body or path segment.
  - POST /api/user/identities/verify takes `{ "provider": "..." }` in a JSON BODY.
Both success bodies are unspecified, so they are read as raw ResponseBody and
discarded; the refreshed list is the source of truth.

getConnectedAccounts() now merges GET /api/user/identities into the per-provider
status rows, which supplies the unlink/verify key plus connectedAt and
lastVerifiedAt. One identity becomes one row, so Mastodon instances stay distinct
and unlinking one cannot take out another. A failed identities read degrades to
status-only rows rather than blanking the screen.

Connection health uses a 30-day staleness threshold: the help centre
(/help/cross-posting -> "Keeping your connections active") documents platform
authorizations expiring on their own, LinkedIn's "after a couple of months", so
30 days leaves roughly a month of warning before the earliest point a cross-post
could start failing. Three states render distinctly - verified recently, verified
too long ago ("Check connection"), and never verified - and the copy names the
consequence ("posts may stop reaching LinkedIn without an error") instead of
printing a raw timestamp.

Unlink is confirmed first, and the dialog states the consequence plainly: it
stops cross-posting to that network. Neither mutation is applied optimistically -
on success the list is re-read so the screen can never show a state that is no
longer true, and on failure the connection stays exactly where it was with the
server's own message surfaced.

Linking itself is untouched: it still needs a browser OAuth redirect (#39).

Tests: connection-health states and the 30-day boundary; the three rendering
cases; unlink round-trip with list refresh; failed unlink keeps the row and
surfaces the server message; verify round-trip with refresh; identity merge,
query-parameter and request-body shapes against MockWebServer; plus Compose
coverage of the confirmation dialog and the stale badge.

Closes #40
@Adron
Adron merged commit 598c88a into parity/queue Sep 16, 2026
1 check passed
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