A guardian finishing a connect for a managed profile could attach their
own provider account to the profile. Every OAuth callback (Withings,
WHOOP, Fitbit, Google Health, Strava, Polar, Oura) now refuses a
provider account another record already holds with reason
already_linked, shown as its own message on the integrations page.
WHOOP's owner_conflict tag becomes already_linked as well.
Oura keeps the account id of a grant (personal_info, migration 0441) so
the check has something to compare; existing connections fill it at
their next connect.
A disconnect leaves Withings webhook subscriptions and the Strava app
authorisation in place while another record holds the same account,
and the Withings webhook routes an account held twice to the record
that linked it first instead of whichever row a lookup returned.
The provider app copied from a guardian onto a managed profile is now
marked (migration 0442) and removed again on disconnect or when that
guardian no longer holds a live MANAGE grant: revoke, renounce,
removal, handover, account deletion and data wipe. Each removal is
audited as integration.client_app.removed.
A guardian acting on a managed profile can now connect and disconnect its
Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's
integration cards, and each card says whose record it connects for. The
connect starts with a fenced fetch that mints a state naming the profile;
the provider's callback lands tokens and data there only after checking
that the browser belongs to a guardian with a live MANAGE grant on it.
Without a provider app of its own, the profile borrows the guardian's,
with an audit row. Withings and Google Health callbacks read the session
directly, since a provider redirect cannot carry the record-session header.
Minting the state, building the consent URL and dropping a grant each had a
copy per provider route. The routes keep their auth, rate limits and
redirects and hand the rest to one implementation, which the managed-profile
routes reuse.
A provider describes where its grant lives and how its token endpoint is
called; the kit reads the grant, refreshes it under the per-user advisory
lock, re-reads under the lock so a peer's refresh or a reconnect wins, and
writes back only over the refresh token it spent. Retry-After is read in
one place, both forms, with a cap.