trypost/CLAUDE.md
Paulo Castellano 02e44b9785
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 10:42:59 -03:00

31 KiB
Raw Blame History

=== foundation rules ===

Laravel Boost Guidelines

The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.

Foundational Context

This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.

Before relying on a package's API, confirm its installed version:

  • PHP packages: run composer show --direct to list direct dependencies with versions, or composer show <vendor/package> for a single package.
  • JS packages: check package.json for the installed versions.

Skills Activation

This project has domain-specific skills available in **/skills/**. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.

Conventions

  • You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
  • Use descriptive names for variables and methods. For example, isRegisteredForDiscounts, not discount().
  • Check for existing components to reuse before writing a new one.

Verification Scripts

  • Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.

Application Structure & Architecture

  • Stick to existing directory structure; don't create new base folders without approval.
  • Do not change the application's dependencies without approval.

Frontend Bundling

  • If the user doesn't see a frontend change reflected in the UI, it could mean they need to run npm run build, npm run dev, or composer run dev. Ask them.

Documentation Files

  • You must only create documentation files if explicitly requested by the user.

Replies

  • Be concise in your explanations - focus on what's important rather than explaining obvious details.

=== boost rules ===

Laravel Boost

Tools

  • Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
  • Use database-query to run read-only queries against the database instead of writing raw SQL in tinker.
  • Use database-schema to inspect table structure before writing migrations or models.
  • Use get-absolute-url to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
  • Use browser-logs to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.

Searching Documentation (IMPORTANT)

  • Always use search-docs before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
  • Pass a packages array to scope results when you know which packages are relevant.
  • Use multiple broad, topic-based queries: ['rate limiting', 'routing rate limiting', 'routing']. Expect the most relevant results first.
  • Do not add package names to queries because package info is already shared. Use test resource table, not filament 4 test resource table.

Search Syntax

  1. Use words for auto-stemmed AND logic: rate limit matches both "rate" AND "limit".
  2. Use "quoted phrases" for exact position matching: "infinite scroll" requires adjacent words in order.
  3. Combine words and phrases for mixed queries: middleware "rate limit".
  4. Use multiple queries for OR logic: queries=["authentication", "middleware"].

Project Rules

  • This project keeps committed, area-grouped rules in .ai/rules (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under .ai/rules/boost — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run grep -rin 'keyword' .ai/rules to catch what a path match alone misses. Do not write code until you have read and are following every matching rule.
  • Record durable rules with record-rule so the next agent or teammate inherits them instead of working them out again. Pass a glob (e.g. app/Http/Controllers/**), a short title, and a few-line note. Always use record-rule, never your native memory or notes tool — native memory is personal and session-scoped; only .ai/rules is shared with the team and persists in the repo.

Artisan

  • Run Artisan commands directly via the command line (e.g., php artisan route:list). Use php artisan list to discover available commands and php artisan [command] --help to check parameters.
  • Inspect routes with php artisan route:list. Filter with: --method=GET, --name=users, --path=api, --except-vendor, --only-vendor.
  • Read configuration values using dot notation: php artisan config:show app.name, php artisan config:show database.default. Or read config files directly from the config/ directory.

Tinker

  • Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
  • Always use single quotes to prevent shell expansion: php artisan tinker --execute 'Your::code();'
    • Double quotes for PHP strings inside: php artisan tinker --execute 'User::where("active", true)->count();'

=== php rules ===

PHP

  • Always use curly braces for control structures, even for single-line bodies.
  • Use PHP 8 constructor property promotion: public function __construct(public GitHub $github) { }. Do not leave empty zero-parameter __construct() methods unless the constructor is private.
  • Use explicit return type declarations and type hints for all method parameters: function isAccessible(User $user, ?string $path = null): bool
  • Use TitleCase for Enum keys: FavoritePerson, BestLake, Monthly.
  • Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
  • Use array shape type definitions in PHPDoc blocks.

=== deployments rules ===

Deployment

  • Laravel can be deployed using Laravel Cloud, which is the fastest way to deploy and scale production Laravel applications.

=== herd rules ===

Laravel Herd

  • The application is served by Laravel Herd at https?://[kebab-case-project-dir].test. Use the get-absolute-url tool to generate valid URLs. Never run commands to serve the site. It is always available.
  • Use the herd CLI to manage services, PHP versions, and sites (e.g. herd sites, herd services:start <service>, herd php:list). Run herd list to discover all available commands.

=== tests rules ===

Test Enforcement

  • Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
  • Run the minimum number of tests needed to ensure code quality and speed. Use php artisan test --compact with a specific filename or filter.

=== inertia-laravel/core rules ===

Inertia

  • Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
  • Components live in resources/js/pages (unless specified in vite.config.js). Use Inertia::render() for server-side routing instead of Blade views.
  • ALWAYS use search-docs tool for version-specific Inertia documentation and updated code examples.
  • IMPORTANT: Activate inertia-vue-development when working with Inertia Vue client-side patterns.

Inertia v3

  • Use all Inertia features from v1, v2, and v3. Check the documentation before making changes to ensure the correct approach.
  • New v3 features: standalone HTTP requests (useHttp hook), optimistic updates with automatic rollback, layout props (useLayoutProps hook), instant visits, simplified SSR via @inertiajs/vite plugin, custom exception handling for error pages.
  • Carried over from v2: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
  • When using deferred props, add an empty state with a pulsing or animated skeleton.
  • Axios has been removed. Use the built-in XHR client with interceptors, or install Axios separately if needed.
  • Inertia::lazy() / LazyProp has been removed. Use Inertia::optional() instead.
  • Prop types (Inertia::optional(), Inertia::defer(), Inertia::merge()) work inside nested arrays with dot-notation paths.
  • SSR works automatically in Vite dev mode with @inertiajs/vite - no separate Node.js server needed during development.
  • Event renames: invalid is now httpException, exception is now networkError.
  • router.cancel() replaced by router.cancelAll().
  • The future configuration namespace has been removed - all v2 future options are now always enabled.

=== laravel/core rules ===

Do Things the Laravel Way

  • Use php artisan make: commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using php artisan list and check their parameters with php artisan [command] --help.
  • If you're creating a generic PHP class, use php artisan make:class.
  • Pass --no-interaction to all Artisan commands to ensure they work without user input. You should also pass the correct --options to ensure correct behavior.

Model Creation

  • When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using php artisan make:model --help to check the available options.

APIs & Eloquent Resources

  • For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.

URL Generation

  • When generating links to other pages, prefer named routes and the route() function.

Testing

  • When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
  • Faker: Use methods such as $this->faker->word() or fake()->randomDigit(). Follow existing conventions whether to use $this->faker or fake().
  • When creating tests, make use of php artisan make:test [options] {name} to create a feature test, and pass --unit to create a unit test. Most tests should be feature tests.

Vite Error

  • If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run npm run build or ask the user to run npm run dev or composer run dev.

=== wayfinder/core rules ===

Laravel Wayfinder

Use Wayfinder to generate TypeScript functions for Laravel routes. Import from @/actions/ (controllers) or @/routes/ (named routes).

=== pint/core rules ===

Laravel Pint Code Formatter

  • If you have modified any PHP files, you must run vendor/bin/pint --dirty --format agent before finalizing changes to ensure your code matches the project's expected style.
  • Do not run vendor/bin/pint --test --format agent, simply run vendor/bin/pint --format agent to fix any formatting issues.

=== pest/core rules ===

Pest

  • This project uses Pest for testing. Create tests: php artisan make:test --pest {name}.
  • The {name} argument should not include the test suite directory. Use php artisan make:test --pest SomeFeatureTest instead of php artisan make:test --pest Feature/SomeFeatureTest.
  • Run tests: php artisan test --compact or filter: php artisan test --compact --filter=testName.
  • Do NOT delete tests without approval.

=== inertia-vue/core rules ===

Inertia + Vue

Vue components must have a single root element.

  • IMPORTANT: Activate inertia-vue-development when working with Inertia Vue client-side patterns.

Project-Specific Rules

Frontend (Vue/TypeScript)

  • Always use arrow functions in Vue components and TypeScript files. Never use function declarations.

Inertia SSR

  • This project does not run Inertia SSR. config/inertia.php defaults ssr.enabled to false and nothing in the repo sets INERTIA_SSR_ENABLED.
  • Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing Http::fake() stubs.
  • The build wiring is still shipped (resources/js/ssr.ts, vite.config.ts, npm run build:ssr in docker/Dockerfile). Turning SSR on means building that bundle and running inertia:start-ssr alongside the app, not just flipping the env.

Dialogs

  • In <DialogFooter>, put the primary action button first in the markup, then secondary/cancel (e.g. Save → Cancel). DialogFooter uses flex-col on mobile (primary on top, cancel at the bottom) and sm:flex-row sm:justify-start on desktop, so the first child is the leftmost action on larger screens.
  • Match sibling dialogs in the same feature area before inventing a new footer layout.

AI agents (app/Ai/Agents)

  • Never embed prompts in PHP (<<<PROMPT, heredocs, or long string literals in instructions()).
  • Put system/instruction text in Blade under resources/views/prompts/ (e.g. prompts.post_content.generator, prompts.post_image.regenerator).
  • In instructions(), return view('prompts....', [...])->render() and pass only the variables the Blade file needs — same pattern as PostContentStreamer, PostContentReviewer, and BrandAnalyzer.

System AI (always allowed, never metered)

  • The brand analyzer / workspace autofill (App\Services\Brand\BrandAnalyzerRunner, App\Actions\Ai\AutofillBrand, WorkspaceController::autofillBrand) is a system feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
  • It MUST always be allowed: NEVER gate it behind the useAi policy, an active subscription, or a credit check.
  • It MUST NOT deduct anything: NEVER call RecordAiUsage (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
  • Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.

Stripe Checkout (env knobs)

Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through App\Support\Billing\ConfigureSubscriptionCheckout (called from StartSubscriptionCheckout).

Env Config Default Effect
REQUIRE_CARD_FOR_TRIAL trypost.billing.require_card_for_trial true true: app access only after Stripe Checkout (no generic signup trial). false: generic accounts.trial_ends_at trial without a card
CASHIER_TRIAL_DAYS cashier.trial_days 8 Card-required Checkout: trialDays(N) for first-time subscribers when no first-month coupon is applied (0 = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial
STRIPE_FIRST_MONTH_COUPON_ID cashier.first_month_coupon_id empty Optional. When set for a qualifying first-time single-workspace checkout, applies withCoupon and skips trial. Empty = trial mode
CASHIER_ALLOW_PROMOTION_CODES cashier.allow_promotion_codes false When true and no coupon is applied, show the Checkout promo-code field

Standing constraints:

  • Stripe rejects discounts (coupon) and allow_promotion_codes on the same session — if both would apply, ConfigureSubscriptionCheckout must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does not qualify for the coupon (no throw).
  • A set first-month coupon wins over trial (trialDays is skipped for that checkout).
  • Empty coupon + card required + first-time must use trialDays — do not reintroduce a required-coupon throw.
  • Coupon qualification stays: card required, exactly one workspace, no prior real subscription (incomplete / incomplete_expired still qualify).
  • Prefer documenting durable billing decisions here (and in AGENTS.md) — do not create a .ai/ rules folder for this project.

Multiple social accounts per network

One connected identity per social network per workspace is the Cloud default. This is not tied to SELF_HOSTED — Cloud cannot flip that flag, but it can flip this one.

Env Config Default Effect
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS trypost.allow_multiple_social_accounts false (falls back to SELF_HOSTED when unset) true: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). false: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same platform + platform_user_id still updates the existing row. Shared to Inertia as allowMultipleSocialAccounts.

Self-hosted compose / .env.example set this true. When the env is unset, the config falls back to SELF_HOSTED so existing self-hosted installs keep multiple accounts. Do not use selfHosted for the occupancy check (observer, Telegram connect, NetworkConnectGrid).

Icons (@tabler/icons-vue)

  • This project uses @tabler/icons-vue for all icons. NEVER use lucide-vue-next.
  • All Tabler icons are prefixed with Icon, e.g. IconCheck, IconChevronRight, IconMail.
  • Import icons from @tabler/icons-vue: import { IconCheck, IconX } from '@tabler/icons-vue'.
  • Browse available icons at https://tabler.io/icons

Dates

  • For date manipulation, always use @/dayjs (pre-configured dayjs instance with utc, timezone, relativeTime plugins).
  • For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use @/date which centralizes all formatting logic with proper timezone handling.
  • Never use raw new Date() for date calculations — use dayjs.

Routing (Wayfinder)

  • This project uses Laravel Wayfinder for type-safe frontend routing.
  • ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. register(), login(), dashboard()). NEVER hardcode URL strings like href="/register".
  • After creating or modifying PHP routes/controllers, run php artisan wayfinder:generate to regenerate the TypeScript route helpers.
  • Import routes from @/routes/... (e.g. import { store } from '@/routes/login').

Pagination

  • Always use normal pagination (->paginate()). NEVER use cursor pagination (->cursorPaginate()).
  • All paginated lists must use Inertia's scroll pagination (Inertia::scroll() on the backend with <InfiniteScroll> on the frontend). NEVER use traditional page-based pagination with page links/buttons.
  • The page size ALWAYS comes from config('app.pagination.default') — never a magic number, and never a perPage/per_page value supplied by the request or frontend. Action/service list methods must NOT accept a $perPage parameter; call ->paginate((int) config('app.pagination.default')) directly.
    • The only exception is the public REST API (app/Http/Controllers/Api), which uses its own fixed, documented page size (15) as a stable API contract.

Form Validation

  • NEVER use HTML5 validation attributes (required, minlength, pattern, etc.) on form inputs. Always rely solely on backend validation.

Backend Validation

  • Validation rules always live in a dedicated Illuminate\Foundation\Http\FormRequest subclass under app/Http/Requests/App/<Group>/. Controller actions must type-hint the FormRequest as the parameter — NEVER call $request->validate([...]) inline in the controller.
  • Naming: <Verb><Resource>Request.php (e.g. StorePostRequest, UpdatePostRequest, LinkPreviewRequest).

Per-Platform Post Meta (PostPlatform.meta)

  • All platforms.*.meta validation (the parent array rule AND every per-platform sub-key: aspect_ratio, TikTok privacy_level/flags, Pinterest board_id, Discord channel_id/mentions/embeds, etc.) lives in ONE place: App\Support\PostPlatformMetaRules.
    • Every post create/update entry point — web (App\Http\Requests\App\Post\UpdatePostRequest), public API (App\Http\Requests\Api\Post\{Store,Update}PostRequest), and MCP (App\Mcp\Tools\Post\{Create,Update}PostTool) — spreads ...PostPlatformMetaRules::rules(). NEVER add a per-platform meta rule inline to a single request/tool.
    • Why: FormRequest::validated() (and MCP $request->validate()) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
  • Required-on-publish (meta a platform needs to publish, e.g. Discord channel_id) also lives there: addRequiredOnPublishErrors() for request-driven flows (web/API update withValidator), assertStoredPostPublishable() for flows that publish stored state without resubmitting platforms (MCP PublishPostTool). Add new required-meta rules to requiredMetaViolation(), not inline.
  • When adding a new platform's meta field, add it (and any publish requirement) to PostPlatformMetaRules ONLY, and cover it in tests/Feature/Api/PostApiPlatformMetaTest.php + tests/Feature/Mcp/PostPlatformMetaToolTest.php.

Media Types (image / video / document)

  • A media item is one of exactly three types: image, video, document (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input).
  • Media-type detection lives in ONE place per side — NEVER hand-write type === 'image', mime_type === 'application/pdf', mime.startsWith('video/'), or extension checks inline.
    • Backend: App\Enums\Media\Typeclassify(), fromMime(), fromExtension(), isGif(), plus the allowedMimeTypes() / extensions() allow-lists. Use these, never a raw MIME/extension comparison.
    • Frontend: resources/js/lib/mediaType.ts — the mirror of the backend enum: the MediaType union, classify(), fromMimeType() (for a browser File.type), fromExtension(), isImage()/isVideo()/isDocument()/isGif(). @/composables/useMedia re-exports isImageMedia/isVideoMedia/isDocumentMedia aliases for legacy call sites.
    • Detection trusts the explicit type first, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without a type) still classifies correctly. A bare item.type === 'image' (with a v-else video) silently mis-renders those.
  • The type field on every media-ish interface is the MediaType union, never stringMediaItem, and any sibling picked/asset/saved shape (PickedMedia, AssetMedia, SavedMedia, etc.).
  • The upload accept attribute for "everything we allow" comes from acceptAttribute() (frontend) / Media\Type::allowedMimeTypes() (backend) — never a hardcoded MIME list. Per-capability accept builders driven by content-type rules (e.g. image/*,video/*) are fine; those aren't detection.

Pest / Feature Tests

  • ALWAYS use named routes via the route() helper in feature tests. NEVER hardcode URL strings like '/posts/ai/create'.
    • Example: $this->postJson(route('app.posts.store')) instead of $this->postJson('/posts').
    • With params: route('app.posts.ai.create.finalize', $creationId).

Browser Tests (Pest + Playwright)

Browser tests live in tests/Browser and run on pestphp/pest-plugin-browser driving Playwright. Laravel Dusk is not installed — there is no DuskTestCase, no $browser object, and no browse(). Do not add dusk="..." attributes; they select nothing.

  • ALWAYS use named routes via route(). NEVER hardcode URLs like 'https://trypost.test/login'.
    • Example: visit(route('login')).
  • ALWAYS target elements by data-testid. NEVER use CSS classes (.text-red-600), tag names, or text strings.
    • @my-element resolves to [data-testid="my-element"], so add data-testid="my-element" in the Vue component and use $page->click('@my-element').
    • Bind it for repeated elements: :data-testid="connect-${platform.value}".
  • Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the waitFor*TestId() helper at the top of tests/Browser/WelcomeConnectTest.php and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files).
  • BrowserTestCase sets $fakesVite = false on purpose: these tests load real built assets, so faking Vite blanks the app.
  • End page assertions with ->assertNoJavaScriptErrors().
  • CI runs them un-parallelised (php artisan test tests/Browser --compact) against npm run build output, so keep them independent of a running dev server.

Array Data Access

  • In Action classes and similar service classes, ALWAYS use Laravel's data_get() helper instead of direct array access.
    • Example: data_get($data, 'name') instead of $data['name'].
    • Use the third parameter for fallback values: data_get($data, 'username', $sender->username) instead of $data['username'] ?? $sender->username.

Eloquent Models & Morph Map

  • EVERY Eloquent model in app/Models MUST be registered in Relation::enforceMorphMap([...]) inside AppServiceProvider::configureMorphMap(), keyed by a camelCase alias (e.g. 'postPlatform' => PostPlatform::class).
  • When you add a new model, add it to the morph map in the same change. tests/Unit/MorphMapTest.php fails if any model is missing.
  • The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.

Imports

  • NEVER use inline class references (e.g., \DB::listen, \Str::uuid()). ALWAYS import classes at the top of the file with a use statement.
    • PHP: use Illuminate\Support\Facades\DB; then DB::listen(...)
    • TypeScript/Vue: import { ref } from 'vue' then ref(...)

API Response Status Codes

  • When returning JSON responses with explicit status codes, always use Symfony\Component\HttpFoundation\Response constants instead of magic numbers.
    • Example: Response::HTTP_CREATED instead of 201, Response::HTTP_NO_CONTENT instead of 204.

String Interpolation

  • When injecting variables into strings, prefer double-quoted interpolation with curly braces over concatenation with ..
    • PHP: "workspace.{$workspace->id}" instead of 'workspace.'.$workspace->id.
    • Use curly braces {} even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
    • Single quotes are still preferred when the string has no interpolation.

External Service URLs

  • NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. https://api.x.com/2, https://www.linkedin.com/oauth/v2/accessToken, https://bsky.social). They live in config/trypost.php under platforms.<name> with a matching env(...) default, so self-hosted users can override them and we have a single source of truth.
    • Production code: config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', never the literal URL.
    • Tests: use the same config(...) value in Http::fake([...])Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...]). Tests with hardcoded URLs drift silently when the config changes.
    • Path/route segments after the host (e.g. /oauth/v2/accessToken, /xrpc/com.atproto.server.refreshSession) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.

Social Platform API Documentation (official sources)

Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory. APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:

TryPost.it Documentation

Git

  • NEVER add Co-Authored-By lines to commit messages.
  • NEVER commit, push, or open PRs unless explicitly asked by the user.
  • Always create a new branch for feature work before making changes.