D112 — stables-core-app is API-only: operator UI moves to a standalone SPA, emails are the only rendered output
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Supersedes the operator-UI half of D12 (central Livewire backoffice) and D20 (module-owned Blade + Livewire). The passkey posture consequence links down to the backoffice module README — the wiki does not restate config specifics.
Context
The tenant/client app has always been headless — a TanStack Start SPA (stables-core-web)
against the /api/v1/* JSON:API (D15). The operator app, by contrast, was originally the
Laravel starter-kit's server-rendered shell (D12: "central Livewire backoffice"; D20: module-owned
Blade views + Livewire). That shell was progressively hollowed out — the settings/profile screens
were deleted long ago (they mutated Eloquent directly, violating the CQRS boundary), leaving only a
/ welcome page, a /dashboard stub, and the Fortify auth Blade views (login / 2FA / password
reset / email verify). Every real operator capability is already exposed as the Sanctum-stateful
JSON API under /api/v1/backoffice/*.
The operator UI has since been built as its own standalone TanStack Start SPA,
stables-core-web-backoffice, which authenticates headlessly against this app —
GET /sanctum/csrf-cookie → POST /api/v1/backoffice/login (backoffice.api.v1.login) →
cookie-authenticated XHR, through a
sealed-session BFF (the same pattern the documents stream route already assumes, D109). That was
verified working end-to-end before this change. With both faces now SPAs, the vestigial operator
Blade shell + the entire Vite/npm/Tailwind/Livewire/Flux frontend toolchain earn nothing but carry
cost: a Node build stage in the prod image, a vite dev service, a Node step in CI, @vite-coupled
Blade the test suite must render, and two frontend packages in composer.json.
Emails are not affected: the app's mail is built with MailMessage line-builders, not Blade
templates, so removing the view layer leaves the mail path untouched.
Decision
stables-core-app is an API-only backend. The operator UI lives entirely in
stables-core-web-backoffice; the client UI in stables-core-web. Emails (MailMessage
line-builders) are the only rendered output. No first-party Blade UI ships.
Concretely:
- Deleted: the operator Blade views (
backoffice::welcome/dashboard/ the Fortifylivewire.auth.*screens), the rootresources/views/**layouts/components/partials, and the whole frontend toolchain —resources/js,resources/css,vite.config.js,package.json,package-lock.json, thenode:24-alpinedevvitecompose service, the Node stage of the prodDockerfile, and the Node/npm ci/npm run buildsteps in CI.livewire/flux+livewire/livewireare removed fromcomposer.json. - Routing: the
Route::view('/')welcome +/dashboardroutes are gone (GET /→ 404). The infra health check is/up(bootstrap/app.php), which renders no Blade. - Fortify runs headless:
config/fortify.phpviews => false. All Fortify features the operator API path uses stay enabled (password reset, email verification, 2FA, passkeys) on thewebguard; only the Blade view routes disappear. ThePOSTauth endpoints (login.store,logout,two-factor-challenge,forgot-password,reset-password, …) remain. - Unauthenticated web requests get JSON, not a redirect to a dead route. With
views => falsethere is noroute('login'); Laravel's defaultunauthenticated()wouldRouteNotFoundException(500) on a non-JSON request. The FoundationJsonApiExceptionHandlernow renders everyAuthenticationExceptionas a JSON:API401(no longer gated onexpectsJson()). - Passkeys stay server-side and become cross-origin-ready. Fortify's first-party passkey
endpoints remain (
passkeys/login,passkeys/login/options,passkeys/confirm,passkeys/confirm/options,user/passkeys,user/passkeys/options,user/passkeys/{passkey}— all JSON, controller-backed, no Blade). The browser WebAuthn ceremony is owned by the FE SPAs.config/passkeys.phpbecomes env-driven:PASSKEYS_RELYING_PARTY_ID(default: theAPP_URLhost) andPASSKEYS_ALLOWED_ORIGINS(comma-separated, appended to theAPP_URLdefault, mirroringconfig/cors.php+config/sanctum.php) — Fortify'sconfigurePasskeys()overwritesconfig('passkeys.*')fromconfig('fortify.passkeys.*')at boot, soconfig/fortify.php'spasskeysblock duplicates the identical expression and is the one that actually reaches the ceremony. The RP-ID / allowed-origins rules and the per-environment values are documented in the backoffice module README.
Consequences
- Passkeys: the relying-party ID must be the shared registrable domain. WebAuthn binds a
credential to the RP ID, and the browser reports the FE origin, not this API's. So the RP ID
must be the registrable parent domain shared by the API and every FE origin (e.g.
stables.xyzfor APIapi.stables.xyz+ FEbackoffice.stables.xyz), and each FE origin must be allow-listed inPASSKEYS_ALLOWED_ORIGINS. Changing an existing RP ID invalidates every passkey already registered under the old ID — those credentials stop verifying and users must re-enrol. This is the one migration hazard of the env-driven RP ID; the README section carries the operational detail. - CI + prod image shrink. No Node stage in the prod
Dockerfile, novitedev service, no Node setup /npm ci/npm run buildin CI. The test suite no longer renders@viteBlade, so a missing manifest can no longer redden the suite. - Two fewer Composer packages.
livewire/flux+livewire/livewireare gone;make ide-helpermake boost-updateregenerate stubs/guidelines without them.
- Horizon is unaffected. The
/horizondashboard ships its own precompiled assets (not our Vite build) and keeps its gate-based authorization. - Emails are untouched. Mail is
MailMessageline-builders; there was never a Blade dependency on the mail path. resources/views/is kept as an empty directory (a.gitkeep) soconfig/view.php's path + the prodview:cachestep (Symfony Finder throws on a missing directory) stay valid.- Deploy workflows unchanged.
deploy-preview.ymland the other workflows reference no Node/npm/Vite steps (verified), so onlyci.ymlneeded frontend-step removal.
Revisit trigger
Revisit only if a first-party server-rendered surface is ever genuinely required in this app (e.g. a signed operator export page that cannot live in the SPA). Short of that, all UI stays in the two SPA repos and this backend renders only JSON + email.