Summary
CONNECT_RETRY_HREF is the bare string "/channels/create" (features/channel-connect/lib/registry.ts:23), used by the four "try again" links a failed connect can show. It carries no workspaceId.
That matters because /channels/create treats the absence of workspaceId as "this user has no workspace yet". The page threads workspaceId into each channel's next hop only when it has one (create/page.tsx:127), the connect route reads it back as undefined (channels/create/messenger/route.ts:34), and both that route (:65-67) and the shared OAuth callback (integrations/[...integration]/callback.ts:321-323) then call createFirstWorkspace(userId) — whose own comment states the premise it is never given a chance to check:
// apps/builder/src/lib/workspace/create-first-workspace.ts:49-54
/**
* First-channel path: the user has no workspace yet, so one is created before
* the channel connects. …
*/
So an operator who was working inside a workspace, hit a connect failure, and clicked "try again" is routed down the first-workspace path. On the Community edition, where COMMUNITY_MAX_WORKSPACES = 1 (packages/business/src/workspace/service.ts:44), workspaceService.create then throws workspaceLimitReached, createFirstWorkspace converts it to redirect("/channels/create?error=workspaceLimitReached"), and the operator is shown "Workspace limit reached." The real cause was a dropped query parameter.
Environment
|
|
| Measured against |
upstream/main @ be7234b93bb47936e9469016ccb82e9b2aa765b7 for the code |
| Runtime measurements |
a Codespace running this repository, edition community (NEXT_PUBLIC_EDITION=community), its own Postgres, browser driven with Playwright |
| Files identical |
the files below are byte-identical between 96032013e and be7234b93 |
| Date |
2026-09-12 |
Measured
1 · The retry target carries nothing. Four call sites, one literal:
$ git grep -n 'CONNECT_RETRY_HREF' be7234b93 -- apps/builder/src
…/channel-connect/lib/registry.ts:23:export const CONNECT_RETRY_HREF = "/channels/create"
…/channel-connect/components/connect-picker-screen.tsx:171: href={CONNECT_RETRY_HREF}
…/channel-connect/components/connect-session-error-alert.tsx:39: href={CONNECT_RETRY_HREF}
…/channel-connect/components/connecting-step.tsx:114: href={CONNECT_RETRY_HREF}
…/integration-messenger/components/messenger-pages.tsx:79: href={CONNECT_RETRY_HREF}
We could not get one of those four to render naturally — that needs a real failed provider session, and this environment has no Meta credentials. So the live half below starts from /channels/create directly, which is where those links land.
2 · Nothing re-attaches it downstream. Signed in as a user who already owns workspaces, GET /channels/create with no query, then clicking "Continue" on Messenger. Worth noting for anyone reading the code: the channel card is a <button type="button">, not a link (inbox-select-card.tsx:131-138), so there is no href to inspect — the navigation happens in handleInboxSelect. The resulting URL:
/channels/create?channel=messenger
No workspaceId, exactly as create/page.tsx:127's ternary predicts. From there the next hop is redirect("/channels/create/messenger"), still without it, and that route resolves workspaceId to undefined and calls createFirstWorkspace.
3 · The gate, against the real service and the real database. Rather than infer what happens at the end of that path, we called the same service createFirstWorkspace calls, for a user who already owns workspaces, on the Codespace's own database:
{
"edicion": "community",
"workspacesPropiosAntes": 3,
"resultado": {
"tipo": "ChatbotXException",
"code": "workspaceLimitReached",
"message": "Workspace limit reached for this plan"
},
"workspacesPropiosDespues": 3
}
It throws, and creates nothing. createFirstWorkspace catches exactly this code (:39-46) and redirects to ?error=workspaceLimitReached.
(That account happened to own three workspaces rather than one — the seed had inserted them directly. It does not change the gate, which fires on owned >= COMMUNITY_MAX_WORKSPACES, so it fires the same way at one.)
4 · What the operator ends up reading. GET /channels/create?error=workspaceLimitReached renders a destructive banner reading "Workspace limit reached." above the channel list, with every channel still selectable underneath. Nothing on the screen connects it to the workspace they were in, and nothing suggests retrying from inside that workspace would work.
Why we think this is a defect rather than the intended design
Creating a workspace when state carries no workspaceId is deliberate, and we are aware it has history: #786 added a dedupe lock around it and #792 removed that lock two hours later, deliberately, described as a simplification. We are not asking for #786 back, and we are not claiming the first-channel path is wrong.
What we think is unintended is that the premise is never established. createFirstWorkspace's comment asserts "the user has no workspace yet"; the two callers reach it purely from the absence of a query parameter, and this repository contains a supported, in-product way for that parameter to go missing for a user who does have one — the retry link. The function is correct about what it does and wrong about when it is called.
The Community consequence is what makes it worth your time: the operator does not get a stray workspace, they get an error attributing their failure to their plan. That is a support ticket that cannot be diagnosed from the message.
Suggested fix
Two independent halves; either alone is an improvement.
-
Carry the context through the retry. CONNECT_RETRY_HREF is already the one place this route is spelled, which makes it the natural seam — make it a function of the workspace the operator was in (and, where the connect flow already knows it, the flow they came from), rather than a constant. All four call sites are inside components that have the workspace in scope.
-
Establish the premise before acting on it. Have createFirstWorkspace check whether the user already owns a workspace and, if so, use it instead of creating — or at minimum fail with a code that says what actually happened rather than borrowing workspaceLimitReached. As it stands the quota error is doing double duty as "you are over your plan" and "we lost track of your workspace", and only one of those is the operator's problem.
We have not sent a patch for either. The first needs a decision about what the retry should carry, and the second about whether reusing an existing workspace is the behaviour you want on that path — both yours. Happy to implement whichever shape you pick.
What we did not verify
- We never completed a real OAuth round trip. There is no Meta app on our side, so the middle of the path — retry link → provider consent → callback →
createFirstWorkspace — is read from the code, not observed. What we measured is the two ends: the URL after choosing a channel with no workspaceId, and the service's behaviour for a user who already owns one.
- We did not see any of the four retry links render. They need a failed provider session. The code is unambiguous about the href, but we did not watch an operator click one.
- We did not test the non-Community path, where the create would presumably succeed and leave a stray "New Workspace" instead of an error. That is the behaviour we would expect from the same code, but we did not run it.
- We did not check whether
ConnectSessionErrorAlert's retry is reachable in a state where the workspace is genuinely unknown, in which case a bare /channels/create would be the right target for that one call site specifically.
Related issues
Summary
CONNECT_RETRY_HREFis the bare string"/channels/create"(features/channel-connect/lib/registry.ts:23), used by the four "try again" links a failed connect can show. It carries noworkspaceId.That matters because
/channels/createtreats the absence ofworkspaceIdas "this user has no workspace yet". The page threadsworkspaceIdinto each channel's next hop only when it has one (create/page.tsx:127), the connect route reads it back asundefined(channels/create/messenger/route.ts:34), and both that route (:65-67) and the shared OAuth callback (integrations/[...integration]/callback.ts:321-323) then callcreateFirstWorkspace(userId)— whose own comment states the premise it is never given a chance to check:So an operator who was working inside a workspace, hit a connect failure, and clicked "try again" is routed down the first-workspace path. On the Community edition, where
COMMUNITY_MAX_WORKSPACES = 1(packages/business/src/workspace/service.ts:44),workspaceService.createthen throwsworkspaceLimitReached,createFirstWorkspaceconverts it toredirect("/channels/create?error=workspaceLimitReached"), and the operator is shown "Workspace limit reached." The real cause was a dropped query parameter.Environment
upstream/main@be7234b93bb47936e9469016ccb82e9b2aa765b7for the codecommunity(NEXT_PUBLIC_EDITION=community), its own Postgres, browser driven with Playwright96032013eandbe7234b93Measured
1 · The retry target carries nothing. Four call sites, one literal:
We could not get one of those four to render naturally — that needs a real failed provider session, and this environment has no Meta credentials. So the live half below starts from
/channels/createdirectly, which is where those links land.2 · Nothing re-attaches it downstream. Signed in as a user who already owns workspaces,
GET /channels/createwith no query, then clicking "Continue" on Messenger. Worth noting for anyone reading the code: the channel card is a<button type="button">, not a link (inbox-select-card.tsx:131-138), so there is nohrefto inspect — the navigation happens inhandleInboxSelect. The resulting URL:No
workspaceId, exactly ascreate/page.tsx:127's ternary predicts. From there the next hop isredirect("/channels/create/messenger"), still without it, and that route resolvesworkspaceIdtoundefinedand callscreateFirstWorkspace.3 · The gate, against the real service and the real database. Rather than infer what happens at the end of that path, we called the same service
createFirstWorkspacecalls, for a user who already owns workspaces, on the Codespace's own database:{ "edicion": "community", "workspacesPropiosAntes": 3, "resultado": { "tipo": "ChatbotXException", "code": "workspaceLimitReached", "message": "Workspace limit reached for this plan" }, "workspacesPropiosDespues": 3 }It throws, and creates nothing.
createFirstWorkspacecatches exactly this code (:39-46) and redirects to?error=workspaceLimitReached.(That account happened to own three workspaces rather than one — the seed had inserted them directly. It does not change the gate, which fires on
owned >= COMMUNITY_MAX_WORKSPACES, so it fires the same way at one.)4 · What the operator ends up reading.
GET /channels/create?error=workspaceLimitReachedrenders a destructive banner reading "Workspace limit reached." above the channel list, with every channel still selectable underneath. Nothing on the screen connects it to the workspace they were in, and nothing suggests retrying from inside that workspace would work.Why we think this is a defect rather than the intended design
Creating a workspace when
statecarries noworkspaceIdis deliberate, and we are aware it has history: #786 added a dedupe lock around it and #792 removed that lock two hours later, deliberately, described as a simplification. We are not asking for #786 back, and we are not claiming the first-channel path is wrong.What we think is unintended is that the premise is never established.
createFirstWorkspace's comment asserts "the user has no workspace yet"; the two callers reach it purely from the absence of a query parameter, and this repository contains a supported, in-product way for that parameter to go missing for a user who does have one — the retry link. The function is correct about what it does and wrong about when it is called.The Community consequence is what makes it worth your time: the operator does not get a stray workspace, they get an error attributing their failure to their plan. That is a support ticket that cannot be diagnosed from the message.
Suggested fix
Two independent halves; either alone is an improvement.
Carry the context through the retry.
CONNECT_RETRY_HREFis already the one place this route is spelled, which makes it the natural seam — make it a function of the workspace the operator was in (and, where the connect flow already knows it, the flow they came from), rather than a constant. All four call sites are inside components that have the workspace in scope.Establish the premise before acting on it. Have
createFirstWorkspacecheck whether the user already owns a workspace and, if so, use it instead of creating — or at minimum fail with a code that says what actually happened rather than borrowingworkspaceLimitReached. As it stands the quota error is doing double duty as "you are over your plan" and "we lost track of your workspace", and only one of those is the operator's problem.We have not sent a patch for either. The first needs a decision about what the retry should carry, and the second about whether reusing an existing workspace is the behaviour you want on that path — both yours. Happy to implement whichever shape you pick.
What we did not verify
createFirstWorkspace— is read from the code, not observed. What we measured is the two ends: the URL after choosing a channel with noworkspaceId, and the service's behaviour for a user who already owns one.ConnectSessionErrorAlert's retry is reachable in a state where the workspace is genuinely unknown, in which case a bare/channels/createwould be the right target for that one call site specifically.Related issues
sessionExpired#1156 is adjacent but different: it is about four SSR guards redirecting with no reason at all. This one is about a link that carries a reason but loses the workspace.New Workspace,createFirstWorkspaceandworkspace limit; fix(integrations): prevent duplicate workspace creation on oauth resubmit #786, fix(integrations): simplify oauth workspace creation, surface messenger connect errors #792, fix(messenger): normalize missing workspaceId to undefined in connect route #922 and fix(telegram): redirect duplicate-error to original workspace id #785 all touch this area and none describes this.