Skip to content

Meta account pickers bounce back to /channels/create with no reason when the connect session expired — the same condition is already surfaced one step later as sessionExpired #1156

Description

@rawdaymx

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-92const 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:

  • Invalid/expired token in select page → graceful error state

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:

  1. 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.
  2. /channels/create already accepts a reason over the query string and already renders it. The transport exists and is unused by these callers.
  3. 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:

  1. 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
  1. Make the three session guards say so:
-    redirect("/channels/create")
+    redirect("/channels/create?error=sessionExpired")
  1. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions