trypost/app/Http/Controllers/Auth/FacebookController.php

247 lines
8.8 KiB
PHP
Raw Permalink Normal View History

2026-01-15 17:24:39 +00:00
<?php
declare(strict_types=1);
2026-01-15 17:24:39 +00:00
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
use App\Exceptions\SocialAccount\ConnectPopupException;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
use App\Models\SocialAccount;
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
use App\Services\Social\Meta\ManagedPages;
2026-01-15 17:24:39 +00:00
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
2026-01-15 17:24:39 +00:00
use Illuminate\Support\Facades\Log;
Fix Facebook Page connect pagination (#212) (#253) * Fix Facebook and Instagram-via-Facebook Page connect pagination. Follow Graph API paging.next on /me/accounts so authorized non-first Pages are found and multi-Page accounts get the picker instead of silently connecting the first result. Co-authored-by: Cursor <cursoragent@cursor.com> * Paginate Meta accounts until paging.next is exhausted. Drop the artificial 50-page cap and stop only when there is no next URL, or the same request URL repeats (broken pagination loop). Co-authored-by: Cursor <cursoragent@cursor.com> * Redact tokens in Graph pagination logs and harden test coverage. Cover happy-path and failure cases for Meta /me/accounts pagination, including mid-loop failures, invalid paging.next, and Instagram pages without a linked IG account. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail closed on incomplete Meta accounts pagination. If a later /me/accounts page fails after earlier pages succeeded, throw instead of returning a truncated list that could auto-connect the wrong Page. Also revert the IG detail timeout that could wipe the whole connect list. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify Graph pagination helpers and page fetchers. Bake the first request query into the URL, drop requestKey, and let IncompleteGraphPaginationException bubble from the controllers without catch/rethrow noise. Co-authored-by: Cursor <cursoragent@cursor.com> * Move incomplete pagination exception under Social\Meta. Colocate it with GraphPaginator so the Meta scope is clear from the namespace instead of a generic Social exception name. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename pagination exception to IncompleteMetaGraphPaginationException. Keep it under Exceptions/Social with Meta in the class name instead of moving it into Services. Co-authored-by: Cursor <cursoragent@cursor.com> * Make GraphPaginator results explicit before mapping pages. Assign the paginated accounts to a variable first so the Facebook and Instagram-via-Facebook fetchers read more clearly. Co-authored-by: Cursor <cursoragent@cursor.com> * Build Meta Graph pagination URLs with Laravel Uri. Replace manual http_build_query concatenation with Uri::of()->withQuery(). Co-authored-by: Cursor <cursoragent@cursor.com> * Use Laravel HTTP and Uri helpers in Meta Graph pagination. Prefer response collect/json key access, filled(), and Uri path parsing over manual array and parse_url handling. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify graphVersion using Uri path and str(). Drop basename and native string casts; Uri::path() already yields the Graph API version segment. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unnecessary str() around graph API config. Uri: :of() already accepts the string returned by config(). Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify GraphPaginator with Laravel helpers. Consolidate failure handling via abort(), and use collect, when, throw_if, and Uri::value() for a shorter pagination loop. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor social OAuth page/channel selection handling. Update selectPage and selectChannel methods in Facebook, Instagram, and YouTube controllers to return popup callbacks instead of redirecting on session expiration or workspace not found. Enhance HandleInertiaRequests middleware to prevent deferring onboarding progress on social OAuth popup routes. Add tests to verify behavior for expired sessions and onboarding progress. * Unify Instagram connect behind one card with a method picker. Hide the Instagram-via-Facebook grid card and offer Instagram Login vs Facebook Pages from a single network entry, matching LinkedIn. Co-authored-by: Cursor <cursoragent@cursor.com> * Move social popup onboarding assertions into connection tests. Cover the deferred-prop popup regression on Facebook, Instagram, and YouTube select routes instead of a synthetic onboarding share check. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop suppressing onboarding defer on all social routes. Override onboardingProgress only in popupCallback so picker pages stay deferred and the close page does not re-hit select after session clear. Co-authored-by: Cursor <cursoragent@cursor.com> * Always open the Instagram method dialog on connect. Drop connectMethods and the single-method OAuth shortcut; the picker always offers both Login and Facebook Pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Filter Instagram dialog options by enabled platforms. Keep always opening the method picker, but only list OAuth entry points that are turned on. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Instagram connect methods into a dedicated helper. Keep connectableOptions focused on shaping grid options while the enabled OAuth list lives in instagramConnectMethods(). Co-authored-by: Cursor <cursoragent@cursor.com> * Harden Meta Graph pagination and localize Instagram connect copy. Fail closed on Graph request errors and pathological paging, keep Instagram connect going when profile detail lookups time out, and translate the Instagram method dialog strings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:01:46 +00:00
use Illuminate\Support\Uri;
2026-01-15 17:24:39 +00:00
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
2026-01-15 17:24:39 +00:00
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
class FacebookController extends MetaController
2026-01-15 17:24:39 +00:00
{
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
protected string $pageFields = 'id,name,username,picture{url},access_token';
protected string $noPagesKey = 'accounts.popup_callback.no_facebook_pages';
2026-01-15 17:24:39 +00:00
protected SocialPlatform $platform = SocialPlatform::Facebook;
protected array $scopes = [
'public_profile',
2026-01-15 17:24:39 +00:00
'pages_show_list',
'pages_read_engagement',
'pages_manage_posts',
2026-04-02 20:57:06 +00:00
'read_insights',
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
'business_management',
2026-01-15 17:24:39 +00:00
];
public function connect(Request $request): Response
2026-01-15 17:24:39 +00:00
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
2026-01-15 17:24:39 +00:00
$this->authorize('manageAccounts', $workspace);
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$this->rememberConnectSession($request, $workspace);
2026-01-15 17:24:39 +00:00
return Inertia::location(
Socialite::driver($this->driver)
->usingGraphVersion($this->graphVersion())
2026-03-29 14:51:00 +00:00
->setScopes($this->scopes)
2026-01-15 17:24:39 +00:00
->redirect()
->getTargetUrl()
);
}
public function callback(Request $request): InertiaResponse|RedirectResponse
2026-01-15 17:24:39 +00:00
{
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$workspace = $this->connectWorkspace($request);
2026-01-15 17:24:39 +00:00
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$reconnect = $this->reconnectAccount($workspace);
2026-01-15 17:24:39 +00:00
try {
$socialUser = Socialite::driver($this->driver)->usingGraphVersion($this->graphVersion())->user();
2026-01-15 17:24:39 +00:00
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
$this->touchProfile($socialUser->token);
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
$granted = $this->grantedScopes($socialUser->token);
if ($granted instanceof InertiaResponse) {
return $granted;
}
$walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline());
$listed = $this->toPageCards($walk->pages);
$pages = ManagedPages::publishable($listed);
2026-01-15 17:24:39 +00:00
if (empty($pages)) {
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
return $this->noPagesOnOffer($walk, $listed);
2026-01-15 17:24:39 +00:00
}
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect);
if (empty($pages)) {
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
return $this->noConnectableIdentities($reconnect, 'page_not_found', $walk->complete);
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
}
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) {
2026-01-15 17:24:39 +00:00
$page = $pages[0];
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
2026-01-15 17:24:39 +00:00
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
SocialAccount::connectIdentity(
$workspace,
$this->platform,
(string) data_get($page, 'id'),
[
'username' => data_get($page, 'username', null),
'display_name' => data_get($page, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
'scopes' => $granted,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'page_id' => data_get($page, 'id'),
'user_id' => $socialUser->getId(),
'user_token' => $socialUser->token,
],
2026-01-15 17:24:39 +00:00
],
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$reconnect,
);
2026-01-15 17:24:39 +00:00
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
return $this->connectedCallback($reconnect);
2026-01-15 17:24:39 +00:00
}
// Multiple pages - store data and show selection
session([
'facebook_oauth' => [
'user_token' => $socialUser->token,
'user_id' => $socialUser->getId(),
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
'scopes' => $granted,
2026-01-15 17:24:39 +00:00
'pages' => $pages,
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
'reconnect_id' => $reconnect?->id,
2026-01-15 17:24:39 +00:00
],
]);
return redirect()->route('app.social.facebook.select-page');
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
} catch (NetworkAlreadyConnectedException $e) {
return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value);
2026-01-15 17:24:39 +00:00
} catch (\Exception $e) {
Log::error('Facebook OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
2026-01-15 17:24:39 +00:00
}
}
Fix Facebook Page connect pagination (#212) (#253) * Fix Facebook and Instagram-via-Facebook Page connect pagination. Follow Graph API paging.next on /me/accounts so authorized non-first Pages are found and multi-Page accounts get the picker instead of silently connecting the first result. Co-authored-by: Cursor <cursoragent@cursor.com> * Paginate Meta accounts until paging.next is exhausted. Drop the artificial 50-page cap and stop only when there is no next URL, or the same request URL repeats (broken pagination loop). Co-authored-by: Cursor <cursoragent@cursor.com> * Redact tokens in Graph pagination logs and harden test coverage. Cover happy-path and failure cases for Meta /me/accounts pagination, including mid-loop failures, invalid paging.next, and Instagram pages without a linked IG account. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail closed on incomplete Meta accounts pagination. If a later /me/accounts page fails after earlier pages succeeded, throw instead of returning a truncated list that could auto-connect the wrong Page. Also revert the IG detail timeout that could wipe the whole connect list. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify Graph pagination helpers and page fetchers. Bake the first request query into the URL, drop requestKey, and let IncompleteGraphPaginationException bubble from the controllers without catch/rethrow noise. Co-authored-by: Cursor <cursoragent@cursor.com> * Move incomplete pagination exception under Social\Meta. Colocate it with GraphPaginator so the Meta scope is clear from the namespace instead of a generic Social exception name. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename pagination exception to IncompleteMetaGraphPaginationException. Keep it under Exceptions/Social with Meta in the class name instead of moving it into Services. Co-authored-by: Cursor <cursoragent@cursor.com> * Make GraphPaginator results explicit before mapping pages. Assign the paginated accounts to a variable first so the Facebook and Instagram-via-Facebook fetchers read more clearly. Co-authored-by: Cursor <cursoragent@cursor.com> * Build Meta Graph pagination URLs with Laravel Uri. Replace manual http_build_query concatenation with Uri::of()->withQuery(). Co-authored-by: Cursor <cursoragent@cursor.com> * Use Laravel HTTP and Uri helpers in Meta Graph pagination. Prefer response collect/json key access, filled(), and Uri path parsing over manual array and parse_url handling. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify graphVersion using Uri path and str(). Drop basename and native string casts; Uri::path() already yields the Graph API version segment. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unnecessary str() around graph API config. Uri: :of() already accepts the string returned by config(). Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify GraphPaginator with Laravel helpers. Consolidate failure handling via abort(), and use collect, when, throw_if, and Uri::value() for a shorter pagination loop. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor social OAuth page/channel selection handling. Update selectPage and selectChannel methods in Facebook, Instagram, and YouTube controllers to return popup callbacks instead of redirecting on session expiration or workspace not found. Enhance HandleInertiaRequests middleware to prevent deferring onboarding progress on social OAuth popup routes. Add tests to verify behavior for expired sessions and onboarding progress. * Unify Instagram connect behind one card with a method picker. Hide the Instagram-via-Facebook grid card and offer Instagram Login vs Facebook Pages from a single network entry, matching LinkedIn. Co-authored-by: Cursor <cursoragent@cursor.com> * Move social popup onboarding assertions into connection tests. Cover the deferred-prop popup regression on Facebook, Instagram, and YouTube select routes instead of a synthetic onboarding share check. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop suppressing onboarding defer on all social routes. Override onboardingProgress only in popupCallback so picker pages stay deferred and the close page does not re-hit select after session clear. Co-authored-by: Cursor <cursoragent@cursor.com> * Always open the Instagram method dialog on connect. Drop connectMethods and the single-method OAuth shortcut; the picker always offers both Login and Facebook Pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Filter Instagram dialog options by enabled platforms. Keep always opening the method picker, but only list OAuth entry points that are turned on. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Instagram connect methods into a dedicated helper. Keep connectableOptions focused on shaping grid options while the enabled OAuth list lives in instagramConnectMethods(). Co-authored-by: Cursor <cursoragent@cursor.com> * Harden Meta Graph pagination and localize Instagram connect copy. Fail closed on Graph request errors and pathological paging, keep Instagram connect going when profile detail lookups time out, and translate the Instagram method dialog strings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:01:46 +00:00
public function selectPage(Request $request): InertiaResponse
2026-01-15 17:24:39 +00:00
{
$oauthData = session('facebook_oauth');
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
if (! $oauthData) {
throw new ConnectPopupException('session_expired', $this->platform);
2026-01-15 17:24:39 +00:00
}
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$workspace = $this->connectWorkspace($request);
2026-01-15 17:24:39 +00:00
$pages = collect(data_get($oauthData, 'pages'))
->map(fn ($page) => Arr::except($page, ['access_token']))
->toArray();
2026-01-15 17:24:39 +00:00
return Inertia::render('accounts/FacebookPageSelect', [
'workspace' => $workspace,
'pages' => $pages,
2026-01-15 17:24:39 +00:00
]);
}
public function select(Request $request): InertiaResponse
2026-01-15 17:24:39 +00:00
{
$request->validate([
'page_id' => 'required|string',
]);
$oauthData = session('facebook_oauth');
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
if (! $oauthData) {
throw new ConnectPopupException('session_expired', $this->platform);
2026-01-15 17:24:39 +00:00
}
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$workspace = $this->connectWorkspace($request);
2026-01-15 17:24:39 +00:00
try {
$selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('id', $request->page_id);
2026-01-15 17:24:39 +00:00
if (! $selectedPage) {
return $this->popupCallback(false, __('accounts.popup_callback.page_not_found'), $this->platform->value);
2026-01-15 17:24:39 +00:00
}
$avatarPath = uploadFromUrl(data_get($selectedPage, 'picture'));
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$reconnect = $this->reconnectAccount($workspace, data_get($oauthData, 'reconnect_id'));
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
SocialAccount::connectIdentity(
$workspace,
$this->platform,
(string) data_get($selectedPage, 'id'),
[
'username' => data_get($selectedPage, 'username') ?? null,
'display_name' => data_get($selectedPage, 'name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
'scopes' => data_get($oauthData, 'scopes', $this->scopes),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'page_id' => data_get($selectedPage, 'id'),
'user_id' => data_get($oauthData, 'user_id'),
'user_token' => data_get($oauthData, 'user_token'),
],
],
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
$reconnect,
);
2026-01-15 17:24:39 +00:00
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
session()->forget('facebook_oauth');
2026-01-15 17:24:39 +00:00
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
return $this->connectedCallback($reconnect);
} catch (NetworkAlreadyConnectedException $e) {
return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value);
2026-01-15 17:24:39 +00:00
} catch (\Exception $e) {
Log::error('Facebook page selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_page'), $this->platform->value);
2026-01-15 17:24:39 +00:00
}
}
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
/**
* @param array<int, array<string, mixed>> $pages
* @return list<array<string, mixed>>
*/
private function toPageCards(array $pages): array
2026-01-15 17:24:39 +00:00
{
Fix Facebook Page connect pagination (#212) (#253) * Fix Facebook and Instagram-via-Facebook Page connect pagination. Follow Graph API paging.next on /me/accounts so authorized non-first Pages are found and multi-Page accounts get the picker instead of silently connecting the first result. Co-authored-by: Cursor <cursoragent@cursor.com> * Paginate Meta accounts until paging.next is exhausted. Drop the artificial 50-page cap and stop only when there is no next URL, or the same request URL repeats (broken pagination loop). Co-authored-by: Cursor <cursoragent@cursor.com> * Redact tokens in Graph pagination logs and harden test coverage. Cover happy-path and failure cases for Meta /me/accounts pagination, including mid-loop failures, invalid paging.next, and Instagram pages without a linked IG account. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail closed on incomplete Meta accounts pagination. If a later /me/accounts page fails after earlier pages succeeded, throw instead of returning a truncated list that could auto-connect the wrong Page. Also revert the IG detail timeout that could wipe the whole connect list. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify Graph pagination helpers and page fetchers. Bake the first request query into the URL, drop requestKey, and let IncompleteGraphPaginationException bubble from the controllers without catch/rethrow noise. Co-authored-by: Cursor <cursoragent@cursor.com> * Move incomplete pagination exception under Social\Meta. Colocate it with GraphPaginator so the Meta scope is clear from the namespace instead of a generic Social exception name. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename pagination exception to IncompleteMetaGraphPaginationException. Keep it under Exceptions/Social with Meta in the class name instead of moving it into Services. Co-authored-by: Cursor <cursoragent@cursor.com> * Make GraphPaginator results explicit before mapping pages. Assign the paginated accounts to a variable first so the Facebook and Instagram-via-Facebook fetchers read more clearly. Co-authored-by: Cursor <cursoragent@cursor.com> * Build Meta Graph pagination URLs with Laravel Uri. Replace manual http_build_query concatenation with Uri::of()->withQuery(). Co-authored-by: Cursor <cursoragent@cursor.com> * Use Laravel HTTP and Uri helpers in Meta Graph pagination. Prefer response collect/json key access, filled(), and Uri path parsing over manual array and parse_url handling. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify graphVersion using Uri path and str(). Drop basename and native string casts; Uri::path() already yields the Graph API version segment. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unnecessary str() around graph API config. Uri: :of() already accepts the string returned by config(). Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify GraphPaginator with Laravel helpers. Consolidate failure handling via abort(), and use collect, when, throw_if, and Uri::value() for a shorter pagination loop. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor social OAuth page/channel selection handling. Update selectPage and selectChannel methods in Facebook, Instagram, and YouTube controllers to return popup callbacks instead of redirecting on session expiration or workspace not found. Enhance HandleInertiaRequests middleware to prevent deferring onboarding progress on social OAuth popup routes. Add tests to verify behavior for expired sessions and onboarding progress. * Unify Instagram connect behind one card with a method picker. Hide the Instagram-via-Facebook grid card and offer Instagram Login vs Facebook Pages from a single network entry, matching LinkedIn. Co-authored-by: Cursor <cursoragent@cursor.com> * Move social popup onboarding assertions into connection tests. Cover the deferred-prop popup regression on Facebook, Instagram, and YouTube select routes instead of a synthetic onboarding share check. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop suppressing onboarding defer on all social routes. Override onboardingProgress only in popupCallback so picker pages stay deferred and the close page does not re-hit select after session clear. Co-authored-by: Cursor <cursoragent@cursor.com> * Always open the Instagram method dialog on connect. Drop connectMethods and the single-method OAuth shortcut; the picker always offers both Login and Facebook Pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Filter Instagram dialog options by enabled platforms. Keep always opening the method picker, but only list OAuth entry points that are turned on. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Instagram connect methods into a dedicated helper. Keep connectableOptions focused on shaping grid options while the enabled OAuth list lives in instagramConnectMethods(). Co-authored-by: Cursor <cursoragent@cursor.com> * Harden Meta Graph pagination and localize Instagram connect copy. Fail closed on Graph request errors and pathological paging, keep Instagram connect going when profile detail lookups time out, and translate the Instagram method dialog strings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:01:46 +00:00
return collect($pages)->map(fn (array $page) => [
'id' => data_get($page, 'id'),
'name' => data_get($page, 'name'),
'username' => data_get($page, 'username'),
'picture' => data_get($page, 'picture.data.url'),
'access_token' => data_get($page, 'access_token'),
])->all();
2026-01-15 17:24:39 +00:00
}
private function graphVersion(): string
{
Connect the Pages a login only reaches through a Business Portfolio (#301) * fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. * fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. * refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. * test: pin the Page a login only reaches through a portfolio Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. * refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. * test: cover the portfolio walk's remaining shapes The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. * test: stop the Meta connect tests from calling Graph for real Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. * fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. * fix: three ways the portfolio walk misread what Meta returned Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. * fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. * fix: keep the portfolio walk honest and cheap Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. * test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. * fix: keep the paging-host guard on the pooled edge walk GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. * fix: stop a cut-short walk from passing for a complete one optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. * test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. * fix: an unreadable portfolio must not deny the pages that were readable The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. * fix: an incomplete walk must not answer as if it were sure Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. * fix: stop every Inertia test from calling an SSR server inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. * fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. * fix: bound the cursor walk by requests, and keep what it read MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. * refactor: one Meta connect flow instead of two kept in step by hand The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. * fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. * fix: one rule for a refused portfolio, and a clock on the walk Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. * docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. * fix: put the whole callback inside the budget it advertises meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. * fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. * fix: one budget for the callback, not one per phase of it The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. * fix: a Pages throttle on a user token was reading as a refusal Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. * fix: a reconnect no longer loses its handle to a slow Graph persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --------- Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
return Uri::of($this->graphApi())->path();
}
2026-01-15 17:24:39 +00:00
}