Same shape of bug as the refreshToken duplication: the regex that strips
access_token / Bearer headers from logged HTTP bodies existed in three
near-identical copies (HasSocialHttpClient trait, TokenRefreshClient,
SocialPublishException), drifting subtly — SocialPublishException was
missing the JSON "token" pattern.
Extracts a TokenRedactor::redact(string) helper and routes all callers
through it. Adding a new token format now means one regex in one file.
Every per-platform publisher and analytics class had its own private
refreshToken() implementation, all doing essentially the same thing as
ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of
near-identical OAuth refresh logic across the codebase, which is how
the 5xx→TokenExpired bug got planted in the publish path even after we
fixed it on the hourly/daily jobs.
Consolidation:
- All publish/analytics paths call app(ConnectionVerifier::class)
->refreshToken($account) instead of their own implementation.
- 17 private refreshToken() methods deleted.
- refreshTokenWithLock helper removed from HasSocialHttpClient trait
(its lock semantics are duplicated by ConnectionVerifier's per-
account lock).
- ConnectionVerifier::refreshLinkedInToken passes $account->platform
to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label
in error messages is preserved.
- TokenRefreshClient now treats HTTP 429 the same as 5xx (raises
PlatformUnavailableException) — replaces the retry-on-429 behavior
that socialHttp() provided to the deleted refresh methods.
Net: -460 LOC. Single per-platform refresh implementation. Every fix
or new platform now lands in exactly one place.
The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.
Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.
Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
status and pulls error_description / error.message from the JSON
body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
explicitly: the post is marked failed (category: platform_unavailable,
with http_status in error_context) but the account stays Connected.
No retry inside this job — the scheduler reattempts the next run.
Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.
- New PlatformUnavailableException: API unreachable / 5xx, transient.
TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
refresh HTTP calls across all providers. Takes a Platform enum so
typos fail at compile time and the user-facing label comes from
one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
default PDS host moved into config/trypost.php alongside the
existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
the inner refresh propagates and is treated as a transient skip.