The Bluesky lexicon identifiers (createRecord, createSession, feed.post, facet
types, etc.) were repeated as magic strings across BlueskyPublisher,
BlueskyAnalytics, ConnectionVerifier and BlueskyController, where a typo fails
silently at runtime as "Invalid request". Define them once as named constants
so a typo is an undefined-constant error instead. Tests keep the literal NSIDs
as the independent contract.
Replace the additive LINKEDIN_EXTRA_SCOPES approach with a single
full-override env var per flow, exploded into an array at the config
layer (config/trypost.php -> platforms.linkedin{,-page}.scopes). This
keeps env values as plain comma-separated strings, lets self-hosters
override the entire set in one place, and removes the controller-side
scope-merge logic.
- config/trypost.php: explode LINKEDIN_SCOPES / LINKEDIN_PAGE_SCOPES
into the scopes arrays (deprecated r_basicprofile stays out of the
personal default)
- LinkedInController: drop resolveScopes(), read config scopes directly
- LinkedInPageController: drop the hardcoded $scopes property, read
config scopes at both call sites
- tests: drive the connect scope assertions from config overrides
- .env.example, docker/.env.docker.example: document LINKEDIN_SCOPES
and LINKEDIN_PAGE_SCOPES
Consolidate the default scope set alongside the existing LinkedIn host
config under config/trypost.php -> platforms.linkedin, matching the
project convention that per-platform service config lives there. The
default still drops the deprecated r_basicprofile scope, and
LINKEDIN_EXTRA_SCOPES stays additive (merged onto the defaults rather
than replacing them) so operators can opt back into legacy scopes
without risking a misconfigured full-replacement.
- config/trypost.php: add scopes + extra_scopes to platforms.linkedin
- config/services.php: drop the moved extra_scopes key
- LinkedInController::resolveScopes(): read both from trypost config
- tests: repoint config() overrides to the new key
Make r_basicprofile opt-in via LINKEDIN_EXTRA_SCOPES so self-hosted users
unblock by default and ops with legacy/enterprise products keep working.
Why
---
LinkedIn rejects OAuth authorize requests with a generic "Bummer,
something went wrong" page when an app asks for a scope it can't grant.
`r_basicprofile` is a legacy scope deprecated in 2018; new LinkedIn dev
apps don't have it, so every self-hosted user hits the rejection
immediately on `/connect/linkedin`.
The two products LinkedIn actually grants to standard apps today are:
- Sign In with LinkedIn using OpenID Connect → `openid profile email`
- Share on LinkedIn → `w_member_social`
That set is enough for the connect flow. The only piece of data
`r_basicprofile` was buying us is `/v2/me`'s `vanityName` (pretty
`linkedin.com/in/<slug>`). `fetchVanityName()` already handles HTTP
failure gracefully (returns null), and the only downstream consumer —
`LinkedInPagePublisher`'s post-URL builder — already falls back to a
numeric `linkedin.com/feed/update/<id>` URL when `$account->username`
is null.
Backward compatibility
----------------------
Ops with legacy or enterprise LinkedIn products approved on their dev
app (so they DO have `r_basicprofile`) can opt back in via env:
LINKEDIN_EXTRA_SCOPES=r_basicprofile
`LinkedInController::resolveScopes()` merges this comma-separated list
into the default scope array. The connect flow's `Socialite::scopes()`
call then includes the legacy scope, preserving the pre-PR behaviour
end-to-end (including `vanityName` lookup).
Net effect for users without `r_basicprofile`:
- Connect flow works (was previously rejected by LinkedIn).
- Posts publish exactly the same way.
- Generated post URLs use the numeric form instead of the vanity slug.
Tests
-----
- `linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset`
- `linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set`
- Existing `splits comma-separated approvedScopes` fixture updated to
match the new default set.
Self-hosted installs (SELF_HOSTED=true, the default) now close /register
to the public. Workspace invites still work — the AcceptInvite page
links into /register with ?invite={id}, the middleware persists that
into the session, and POST /register passes through.
- EnsureRegistrationEnabled middleware gates GET/POST /register.
Accepts ?invite=… (URL) or pending_invite_id (session) as the pass.
- RegisteredUserController::store clears the marker after signup.
- AcceptInvite.vue passes invite.id in the register link's query string.
- Login.vue hides the "Sign up" link when self_hosted.
- UserSeeder bootstraps a single admin (admin@trypost.it / password).
Idempotent; not wired into DatabaseSeeder — operator runs
`php artisan db:seed --class=UserSeeder` per the install docs.
- Tests cover both flag values for every changed surface.
Docs PR: see trypost-docs self-hosting/installation.mdx step 3.
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.
Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
The LinkedIn Page connection has a two-step OAuth: first the
`callback` stashes the Socialite user in `linkedin_page_pending` and
redirects to the page picker, then `select` finalizes by writing the
chosen organization to social_accounts. The pending payload was missing
`approved_scopes`, and both finalize paths (`update` for reconnect,
`updateOrCreate` for first connect) never wrote the `scopes` column.
Result: every LinkedIn Page account had `scopes = NULL` in the DB,
the publish-time scope check saw `w_organization_social` as missing
and blocked every post with 'Missing permissions. Please reconnect
your account.'
Fix: stash `approved_scopes` in the session payload, then in both
finalize paths persist it with the same comma-split treatment used by
the LinkedIn personal controller (the LinkedIn-OpenID provider has the
same separator quirk — granted scopes come CSV-joined inside a
single Socialite array element).
Test: `linkedin page select splits comma-separated approvedScopes
before saving` covers the persist + split path.
LinkedIn and Pinterest's OAuth providers return the granted scope list
joined by comma (LinkedIn) or space-in-one-element (Pinterest), but
Socialite's scope splitter doesn't match either, so 'approvedScopes'
lands as a single-element array containing the whole list:
LinkedIn: ['email,openid,profile,r_basicprofile,w_member_social']
Pinterest: ['boards:read boards:write pins:read pins:write user_accounts:read']
That breaks the publish-time scope check in PublishToSocialPlatform
(array_diff does exact string compare), surfacing as
'Missing permissions: w_member_social. Please reconnect your account'
even though the scopes were actually granted at the provider.
Fix is inline at each callback — re-split before saving. Each provider
has its own quirk (LinkedIn = comma, Pinterest = space), so each
controller handles its own separator.
Tests added: callback splits the joined approvedScopes into individual
tokens for both providers.
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.
Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.
- routes/auth.php returns to its original state — redirect + callback
both back inside the `guest` group.
- routes/app.php gains a paired callback route at
/settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
connectProviderCallback override Socialite's redirectUrl so the
round-trip stays on the connect-flow URL. The Auth::check() branch
in the auth controllers is gone.
The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.
Splits the flows by intent:
- New `app.authentication.connect-provider` route in the auth group,
handled by the settings controller (where it sits next to
disconnect-provider). Replaces the OAuth signup link as the
Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
provider, since OAuth apps only register one) and gain a single
Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
to a different user; otherwise sets it on the current user and
redirects back to settings with a flash message.
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):
- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
the register/redirect GET, retrieves them on the POST/callback —
surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer
Adds GitHub as a second OAuth provider:
- GitHubController mirroring the Google one (now renamed from
SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
on Login/Register, rendering each enabled provider plus a single
"or continue with" divider
UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
- Block all billing/usage/subscribe routes in self-hosted mode (redirect to calendar)
- Hide billing_email field in account settings when self-hosted
- Migrate deprecated Facebook Page Insights metrics to new Media Views API (v25.0)
- Fix X analytics token refresh to use Basic Auth (matching XPublisher/ConnectionVerifier)
- Prevent duplicate social accounts by using updateOrCreate across all OAuth controllers
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
- New card-based layout showing connected accounts with avatar, platform
badge, brand label, and connection date
- "Add Social" button opens dialog with platform grid for connecting
- Removed unique constraint on workspace_id+platform to allow multiple
accounts per platform
- Removed "already connected" checks from all 13 OAuth controllers
- Updated tests to verify multi-account connection works
Integrates YouTube Analytics API v2 to display channel-level metrics
(views, minutes watched, avg view duration, avg view percentage,
subscribers gained/lost, likes) with date range support and caching.
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
Critical:
- Fix EnsureUserSetupIsComplete middleware route name prefixes and
redirect Subscription step to subscribe page (not onboarding)
- Fix MCP session pollution: Auth::setUser() instead of Auth::login()
- Remove dead BillingController::addWorkspace/removeWorkspace methods
- Remove broken Workspace::pendingInvites() method
Security (IDOR):
- MediaController: add workspace ownership verification on all endpoints
- UpdatePostRequest: scope label_ids validation to current workspace
- UpdatePostRequest: scope platform IDs validation to current post
Security (other):
- Fix open redirect in login and registration (validate internal URLs)
- Add validation to API PostController store/update (was $request->all())
- Prevent Owner role assignment via updateRole endpoint
- Fix API post author attribution to use workspace owner
Authorization:
- PostController: use createPost policy instead of view for store/update/destroy
Logic:
- Post Status enum labels now use translation system instead of hardcoded Portuguese
- Workspace deletion cleans up current_workspace_id for all affected members
- StoreWorkspaceInviteRequest: replace Portuguese validation messages with __()
Rename onboarding:
- Step1.vue -> Role.vue, Step2.vue -> Connect.vue
- Controller methods: step1->role, storeStep1->storeRole, step2->connect, storeStep2->storeConnect
All 728 tests passing.
Auth pages:
- Create AuthSplitLayout with animated feature slides (6 slides, 3 languages)
- All auth pages use split layout (form left, visual right)
- Add show/hide password toggle with tooltip on Register
- Legal footer only shown on Register via showLegal prop
Subscribe page:
- Redesign to match auth card pattern (centered, clean)
- Platform icons, feature checklist, dynamic trial days (trialDays - 1)
- Add "Switch workspace" link
- Full i18n (en, es, pt-BR)
Onboarding:
- Rename URLs: step1 -> role, step2 -> connect
- Add enforceStep() to prevent skipping/going back steps
- Redirect /onboarding to /onboarding/role
- Redesign Step2 with AuthSplitLayout and compact platform list
- 21 tests covering all step enforcement scenarios
Workspaces page:
- Redesign with AuthSplitLayout (list with avatars, current badge)
Language system:
- Move locale from DB to cookie (forever, unencrypted, session.domain)
- Create SetLocale middleware (sets cookie if missing, validates against config)
- Rename lang/pt-br to lang/pt-BR
- Add dayjs es locale
Other:
- Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard)
- ConfirmDeleteModal with text confirmation (sendkit pattern)
- i18n for ConfirmDeleteModal internal strings (common.php)
- EmptyState component for posts index
- Exact match for "All" posts in sidebar
- Posts breadcrumbs show current status filter
- DialogFooter buttons aligned left
- API Keys page redesign with Table, DropdownMenu, EmptyState
- Extract CreateApiKeyDialog and InviteMemberDialog to components
- Remove API Keys from sidebar
- DropdownMenuItem destructive variant for Remove action
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
- Extract business logic from controllers into Action classes:
Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)
Enables incremental authorization so previously granted scopes
are preserved when requesting new permissions.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Move billing routes from /billing to /settings/billing
- Add billing translations (en/pt-br)
- Update billing/Index.vue to use $t() for all strings
- Fix all controllers using route('dashboard') to use route('calendar') or route('accounts')
- Replace ->with('error', ...) pattern with session()->flash('flash.banner', ...)
- Add self-hosted mode support for hasActiveSubscription() and workspace methods
- Add flash translations for account connection errors
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create LinkedInTokenSynchronizer service
- Sync tokens when connecting LinkedIn personal or page
- Sync tokens when refreshing tokens in publishers
- Sync tokens when verifying connections
- Prevents token invalidation when connecting both accounts