Provider Token Error Triage (Facebook / Instagram / LinkedIn)
2026-10-17 — how a wall of plugin failures in the audit log traced back to three distinct code defects (and one token-drift root cause), what changed, and how to read the next batch of log rows.
Why
Production audit logs (audit_logs, category = 'plugin') showed a repeating set of failures across every connected account:
| Log action | Error | Real cause |
|---|---|---|
facebook-fetch-error(:response) | (#190) This method must be called with a Page Access Token | the facebook/instagram row held a Better Auth user token, not a Page token |
facebook-fetch-error(:response) | Invalid OAuth 2.0 Access Token / User Access Token Is Not Supported (2069032) | same — a user token on a Page endpoint |
facebook-fetch-error(:response) | Invalid OAuth access token - Cannot parse access token on /undefined/conversations | the Page id and token were undefined |
linkedin-…:post-error / linkedin-page-post-error:response | ReferenceError: join is not defined | a missing node:path import in the asset helper |
instagram-post-error | …access [token] has expired… | a Page token that was never force-renewed at publish time |
They looked like one problem ("tokens are broken") but were four independent bugs, so they need four fixes. The lesson: triage each audit-log action to its plugin method before theorising.
What changed
1. join is not defined — missing import (packages/shared/server/utils/asset-utils.ts)
getFileFromAsset() called join(process.cwd(), …) from node:path but the module never imported it. It only runs when a post carries an image, so publishing text succeeded and publishing media died with a bare ReferenceError. Every LinkedIn personal + Page post in the logs had an image.
import { join } from 'node:path';A
ReferenceError: <name> is not definedinside a plugin's catch is almost always a missing import in a sibling helper, not the plugin. Grep the helper, not the plugin.
2. /undefined/conversations — an argument-shift bug (facebook.plugin.ts)
The base class declares:
getConversations(socialMediaAccount, options?)SchedulerPost.getConversations() calls plugin.getConversations(account, options). But FacebookPlugin.getConversations had an extra first parameter:
// WRONG — every argument shifted by one
override async getConversations(extra, socialMediaAccount, options?)So socialMediaAccount received { limit, cursor } and both accountId (/undefined/…) and accessToken were undefined. InstagramPlugin had the correct signature, which is why only Facebook DMs failed. Fixed by dropping the stray extra parameter.
Exposed-method binding (
SchedulerPost.use()) also prepends an args array — but only for names listed inexposedMethods.getConversationsis not one, so the extra parameter was pure human error. Keep the override signature identical to the base.
3. #190 Page Access Token — the token mirror (social-accounts/index.get.ts)
GET /api/v1/social-accounts?platformId=facebook (the Page picker) mirrored the Better Auth user token onto every active facebook row:
// before — corrupts Page-token rows
if (tokenData?.accessToken) {
for (const mirroredAccount of mirroredAccounts) {
await socialMediaAccountService.updateAccount(mirroredAccount.id, {
accessToken: tokenData.accessToken, // ← user token on a Page row
})
}
}Facebook/Instagram rows store Page tokens minted by fetchPageInformation. Meta then rejects the user token with #190 on Insights/feed and 2069032 on feed/conversations. The refresh/[id] route and AutoPost already excluded these platforms; the accounts-list route did not. Fixed by gating the mirror with !usesPageToken(effectivePlatform) (from TokenRefresh.service).
The same user token was also written onto the wrong table in social-accounts/[platform]/[id]/index.post.ts (platformAccount is a Better Auth row, so updateAccount(platformAccount.id, …) was a silent no-op). Removed — the route now only uses the user token to mint the Page token.
4. Instagram "token has expired" — heal at publish time (AutoPost.service.ts)
Page-token rows are renewed through TokenRefresh only. AutoPost called renewRowToken(...) without force, which is skipped when tokenExpiresAt is null or optimistic (Page tokens are often stored with a computed 60-day expiry). A token that died early therefore never renewed and the publish failed with "has expired".
triggerSocialMediaPost now delegates to a small publishPlatformPost() that retries once for Facebook/Instagram when the failure looks like a token-auth error, after a force: true re-mint:
let response = await scheduler.publish(post, [], socialMediaAccount)
if (usesPageToken(platform) && response.status === 'failed' && isPageTokenAuthError(response.error)) {
socialMediaAccount = await this.forceRenewPageToken(platform, socialMediaAccount)
response = await scheduler.publish(post, [], socialMediaAccount)
}isPageTokenAuthError() matches has expired, revoked, invalid oauth, must be called with a Page Access Token, User Access Token Is Not Supported. When renewal itself can't happen (no linked Better Auth account) the row is left untouched and the post is recorded failed — that case genuinely needs a manual reconnect.
How to verify
# type-check the whole site graph (layers included)
cd packages/site && pnpm exec tsc --noEmit -p tsconfig.json
# no new cross-layer imports
pnpm audit:layersThen, with a real connected Page:
- Open the Page picker, then check the Insights card — no
#190in the audit log. - Open Inbox → Facebook conversations — the URL is
/{page-id}/conversations, not/undefined/…. - Publish an image post to LinkedIn — no
join is not defined. - Let an Instagram token expire (or hand-edit
tokenExpiresAtin the past) and publish — the retry re-mints and the post succeeds.
Reading the next batch of log rows
facebook-fetch-error:response+facebook-fetch-errorcome as a pair per call —:responseis the HTTP body, the other is the thrown summary.- A
…:post-erroraction carryingReferenceError/TypeErroris a code bug; a message withOAuthExceptionis a token problem. user_idandtarget_idare often blank on plugin rows — use thetarget_idURL (/undefined/…) and the action prefix (facebook-,linkedin-page-,instagram-) to locate the plugin first.
Related
- Graph API v26.0 Migration
- Facebook & Instagram Setup
packages/db/server/services/TokenRefresh.service.ts,packages/scheduler/server/services/StatsCollector.service.ts