Summary
The three Meta account-picker pages guard on the pending-auth cookie and, when it is gone, send the operator back to /channels/create with a bare redirect("/channels/create"). The picker renders as if nothing had happened. The operator clicked "Messenger", was sent to Facebook, came back, and is now looking at the channel list again with no indication of whether they did something wrong, whether it is worth retrying, or whether the product is broken.
There are four such exits and they cover two distinct conditions:
- Three of them (
messenger:61, instagram:15, instagram-facebook:83) are the same condition — readPendingAuth(...) returned null.
- The fourth (
instagram:21) is different: getInstagramAccount(...) returned null, which itself collapses two unrelated causes — the account is not a Business/Creator account, or the Graph call failed.
What makes this worth reporting rather than a UI wish: this repository already names, translates and renders that first condition. CONNECT_SESSION_ERROR_CODES.sessionExpired is documented as "Pending-auth cookie / signup session missing, expired, or invalid" — the exact predicate those three exits test — and resolveConnectSession throws it, SESSION_ERROR_MESSAGE_KEYS maps it, ConnectSessionErrorAlert renders it, and channels.connectMany.sessionError.sessionExpired is already translated. That path runs one step later in the same flow, when the operator clicks an account. The SSR guard that runs before it swallows the identical condition silently.
The information also already exists server-side for the fourth exit: both of getInstagramAccount's null paths call logger.warn before returning. The server knows why; the operator never finds out.
Environment
|
|
| Measured against |
upstream/main @ 96032013e2313891f816fcf8400412b44cf158a2 |
| How |
by reading the repository at that commit — not against a deployed instance |
| Consulted with |
git show 96032013e:<path> and git grep -n <pattern> 96032013e |
| Date |
2026-09-11 |
The four exits
git grep -n 'redirect("/channels/create")' 96032013e returns exactly four hits, repository-wide — there are no other spellings and no other select/ pages under channels/:
apps/builder/src/app/(no-sidebar)/channels/instagram-facebook/select/page.tsx:83
apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx:15
apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx:21
apps/builder/src/app/(no-sidebar)/channels/messenger/select/page.tsx:61
// messenger/select/page.tsx:58-62
const pendingAuth = await readPendingAuth(FB_MESSENGER_PENDING_AUTH_COOKIE)
if (!pendingAuth) {
redirect("/channels/create")
}
// instagram/select/page.tsx:12-22
const auth = await readPendingAuth(FB_INSTAGRAM_PENDING_AUTH_COOKIE)
if (!auth) {
redirect("/channels/create") // same condition as above
}
const account = await getInstagramAccount(auth.userToken)
if (!account) {
redirect("/channels/create") // different condition
}
instagram-facebook/select/page.tsx:80-84 repeats the first pattern with its own cookie name.
What each null hides. readPendingAuth (apps/builder/src/lib/facebook-pending-auth.ts:125-138) returns null on three paths: no cookie, a payload that fails facebookAuthCallbackSchema (which includes a tampered or undecryptable token), or Date.now() > parsed.data.expiresAt. getInstagramAccount (integrations/instagram/src/apis/auth.ts:101-149) returns null when account_type is outside ["BUSINESS", "CREATOR", "MEDIA_CREATOR"] (:15), and again when an InstagramException is caught — each after a logger.warn.
So: four code exits, two real meanings, five underlying causes, and one blank screen for all of them.
The machinery to say it already exists
For the three "session" exits, the whole chain is already written and in use:
| Piece |
Where |
| The code, with a comment naming this exact predicate |
packages/business/src/inbox/connect-outcome-types.ts:57-58 — // Pending-auth cookie / signup session missing, expired, or invalid. above sessionExpired |
| Thrown on the same test, one step later |
apps/builder/src/features/channel-connect/lib/resolve-connect-session.ts:87-92 — const pendingAuth = await readPendingAuth(props.cookieName); if (!pendingAuth) { throw connectSessionExpiredException(...) } |
| Code → message key |
apps/builder/src/features/channel-connect/lib/row-status.ts:291 |
| Rendered |
ConnectSessionErrorAlert, used at connect-picker-screen.tsx:182 |
| Copy, already translated |
apps/builder/messages/en.json:762 — "sessionExpired": "Your session expired. Please reconnect." (present in every locale we sampled: es, fr, de, pt-BR, ja, zh-CN, ar) |
The SSR guard and resolveConnectSession test the same thing with the same helper. One explains it; the other returns the operator to a blank picker.
And /channels/create already knows how to paint a reason from the URL:
create/page.tsx:186-193 reads searchParams.error, validates it with isCreateChannelErrorCode against CREATE_CHANNEL_ERROR_MESSAGE_KEYS, and passes errorMessageKey to <InboxSelectCard>, which renders it in an <Alert>. Today that table holds three codes (create-first-workspace.ts:16-19): workspaceLimitReached, trialExpired, macLimitReached — all produced by createFirstWorkspace when the first workspace cannot be created (:34, :62). None of them corresponds to either condition above, which is why these four exits have nothing to pass.
Why we think this is a defect rather than the intended design
Redirecting is right: there is nothing to render on a picker page without a valid pending-auth cookie, and bouncing to /channels/create is the correct destination. We are not asking for the redirect to change.
What we think is unintended is that the redirect carries no information while the codebase demonstrably has it.
The clearest evidence is your own. #531, the PR that introduced these two select pages and facebook-pending-auth.ts, lists this in its test plan:
That item is unchecked, and what shipped is redirect("/channels/create"). So the graceful error state was the intent at the time these pages were written; it is the part that did not land.
Three more arguments, all from the repository itself:
resolveConnectSession treats the identical predicate as worth an explicit, translated, user-visible message. Two behaviours for one condition, decided by which step the operator happened to reach.
/channels/create already accepts a reason over the query string and already renders it. The transport exists and is unused by these callers.
- Both
getInstagramAccount null paths already logger.warn. The diagnosis is computed and then discarded at the boundary where it would be useful.
If the silence is deliberate — for instance, to avoid leaking whether a cookie was tampered with — we would rather hear that and drop it than guess.
Suggested fix
Minimal, additive, and no new translations for three of the four exits:
- Add
sessionExpired to CREATE_CHANNEL_ERROR_MESSAGE_KEYS, pointing at the key that already exists:
export const CREATE_CHANNEL_ERROR_MESSAGE_KEYS = {
workspaceLimitReached: "channels.connectMany.reason.workspaceLimit",
trialExpired: "channels.connectMany.sessionError.trialExpired",
macLimitReached: "channels.connectMany.sessionError.macLimitReached",
+ sessionExpired: "channels.connectMany.sessionError.sessionExpired",
} as const
- Make the three session guards say so:
- redirect("/channels/create")
+ redirect("/channels/create?error=sessionExpired")
- For
instagram/select/page.tsx:21, a distinct code — the operator's next action is different (convert the account to Business vs. simply retry), so reusing sessionExpired would be worse than silence. This one does need a new key. Ideally getInstagramAccount would stop collapsing "not a supported account_type" and "Graph call failed" into the same null, so the two can be told apart; that is a larger change and we are happy to leave it out of a first pass.
One judgement call we would rather leave to you: CREATE_CHANNEL_ERROR_MESSAGE_KEYS lives in create-first-workspace.ts, and its surrounding comment scopes the table to plan-limit failures on the first-workspace path. Adding an OAuth-session code there widens that file's remit. Moving the table to its own module, or keeping a second resolver, are both reasonable — we did not want to pick for you.
We have this implemented on our fork and can send the patch. We held the PR back because step 2 touches all 20 files under apps/builder/messages/ if you prefer a dedicated key over reusing sessionExpired, and that is your call to make first.
What we did not verify
- We did not reproduce this against a running instance. There is no Meta app on our side yet, so we could not let a pending-auth cookie expire and watch the redirect. Everything above is read from the repository at the commit named, and the exit list comes from an exhaustive
git grep.
- We did not check whether a reason on this screen would be undesirable for security reasons — see the last paragraph of the previous section.
- We sampled 8 of the 20 locales for
sessionExpired; all 8 have it. We did not check the remaining 12.
- We did not look at whether the other channels' connect paths (WhatsApp, Telegram, Zalo, TikTok) have equivalent silent exits. This is not searched, not proven absent.
Related issues
We searched open and closed issues and PRs for channels create error, session expired, connect picker and pending auth, and found nothing describing this.
Summary
The three Meta account-picker pages guard on the pending-auth cookie and, when it is gone, send the operator back to
/channels/createwith a bareredirect("/channels/create"). The picker renders as if nothing had happened. The operator clicked "Messenger", was sent to Facebook, came back, and is now looking at the channel list again with no indication of whether they did something wrong, whether it is worth retrying, or whether the product is broken.There are four such exits and they cover two distinct conditions:
messenger:61,instagram:15,instagram-facebook:83) are the same condition —readPendingAuth(...)returnednull.instagram:21) is different:getInstagramAccount(...)returnednull, which itself collapses two unrelated causes — the account is not a Business/Creator account, or the Graph call failed.What makes this worth reporting rather than a UI wish: this repository already names, translates and renders that first condition.
CONNECT_SESSION_ERROR_CODES.sessionExpiredis documented as "Pending-auth cookie / signup session missing, expired, or invalid" — the exact predicate those three exits test — andresolveConnectSessionthrows it,SESSION_ERROR_MESSAGE_KEYSmaps it,ConnectSessionErrorAlertrenders it, andchannels.connectMany.sessionError.sessionExpiredis already translated. That path runs one step later in the same flow, when the operator clicks an account. The SSR guard that runs before it swallows the identical condition silently.The information also already exists server-side for the fourth exit: both of
getInstagramAccount'snullpaths calllogger.warnbefore returning. The server knows why; the operator never finds out.Environment
upstream/main@96032013e2313891f816fcf8400412b44cf158a2git show 96032013e:<path>andgit grep -n <pattern> 96032013eThe four exits
git grep -n 'redirect("/channels/create")' 96032013ereturns exactly four hits, repository-wide — there are no other spellings and no otherselect/pages underchannels/:instagram-facebook/select/page.tsx:80-84repeats the first pattern with its own cookie name.What each
nullhides.readPendingAuth(apps/builder/src/lib/facebook-pending-auth.ts:125-138) returnsnullon three paths: no cookie, a payload that failsfacebookAuthCallbackSchema(which includes a tampered or undecryptable token), orDate.now() > parsed.data.expiresAt.getInstagramAccount(integrations/instagram/src/apis/auth.ts:101-149) returnsnullwhenaccount_typeis outside["BUSINESS", "CREATOR", "MEDIA_CREATOR"](:15), and again when anInstagramExceptionis caught — each after alogger.warn.So: four code exits, two real meanings, five underlying causes, and one blank screen for all of them.
The machinery to say it already exists
For the three "session" exits, the whole chain is already written and in use:
packages/business/src/inbox/connect-outcome-types.ts:57-58—// Pending-auth cookie / signup session missing, expired, or invalid.abovesessionExpiredapps/builder/src/features/channel-connect/lib/resolve-connect-session.ts:87-92—const pendingAuth = await readPendingAuth(props.cookieName); if (!pendingAuth) { throw connectSessionExpiredException(...) }apps/builder/src/features/channel-connect/lib/row-status.ts:291ConnectSessionErrorAlert, used atconnect-picker-screen.tsx:182apps/builder/messages/en.json:762—"sessionExpired": "Your session expired. Please reconnect."(present in every locale we sampled:es,fr,de,pt-BR,ja,zh-CN,ar)The SSR guard and
resolveConnectSessiontest the same thing with the same helper. One explains it; the other returns the operator to a blank picker.And
/channels/createalready knows how to paint a reason from the URL:create/page.tsx:186-193readssearchParams.error, validates it withisCreateChannelErrorCodeagainstCREATE_CHANNEL_ERROR_MESSAGE_KEYS, and passeserrorMessageKeyto<InboxSelectCard>, which renders it in an<Alert>. Today that table holds three codes (create-first-workspace.ts:16-19):workspaceLimitReached,trialExpired,macLimitReached— all produced bycreateFirstWorkspacewhen the first workspace cannot be created (:34,:62). None of them corresponds to either condition above, which is why these four exits have nothing to pass.Why we think this is a defect rather than the intended design
Redirecting is right: there is nothing to render on a picker page without a valid pending-auth cookie, and bouncing to
/channels/createis the correct destination. We are not asking for the redirect to change.What we think is unintended is that the redirect carries no information while the codebase demonstrably has it.
The clearest evidence is your own. #531, the PR that introduced these two select pages and
facebook-pending-auth.ts, lists this in its test plan:That item is unchecked, and what shipped is
redirect("/channels/create"). So the graceful error state was the intent at the time these pages were written; it is the part that did not land.Three more arguments, all from the repository itself:
resolveConnectSessiontreats the identical predicate as worth an explicit, translated, user-visible message. Two behaviours for one condition, decided by which step the operator happened to reach./channels/createalready accepts a reason over the query string and already renders it. The transport exists and is unused by these callers.getInstagramAccountnull paths alreadylogger.warn. The diagnosis is computed and then discarded at the boundary where it would be useful.If the silence is deliberate — for instance, to avoid leaking whether a cookie was tampered with — we would rather hear that and drop it than guess.
Suggested fix
Minimal, additive, and no new translations for three of the four exits:
sessionExpiredtoCREATE_CHANNEL_ERROR_MESSAGE_KEYS, pointing at the key that already exists:export const CREATE_CHANNEL_ERROR_MESSAGE_KEYS = { workspaceLimitReached: "channels.connectMany.reason.workspaceLimit", trialExpired: "channels.connectMany.sessionError.trialExpired", macLimitReached: "channels.connectMany.sessionError.macLimitReached", + sessionExpired: "channels.connectMany.sessionError.sessionExpired", } as constinstagram/select/page.tsx:21, a distinct code — the operator's next action is different (convert the account to Business vs. simply retry), so reusingsessionExpiredwould be worse than silence. This one does need a new key. IdeallygetInstagramAccountwould stop collapsing "not a supportedaccount_type" and "Graph call failed" into the samenull, so the two can be told apart; that is a larger change and we are happy to leave it out of a first pass.One judgement call we would rather leave to you:
CREATE_CHANNEL_ERROR_MESSAGE_KEYSlives increate-first-workspace.ts, and its surrounding comment scopes the table to plan-limit failures on the first-workspace path. Adding an OAuth-session code there widens that file's remit. Moving the table to its own module, or keeping a second resolver, are both reasonable — we did not want to pick for you.We have this implemented on our fork and can send the patch. We held the PR back because step 2 touches all 20 files under
apps/builder/messages/if you prefer a dedicated key over reusingsessionExpired, and that is your call to make first.What we did not verify
git grep.sessionExpired; all 8 have it. We did not check the remaining 12.Related issues
We searched open and closed issues and PRs for
channels create error,session expired,connect pickerandpending auth, and found nothing describing this.connect several pages and numbers in one flow) introduced the picker architecture andConnectSessionErrorAlert, i.e. the machinery we are suggesting these guards reuse — but it did not touch them.