Skip to main content

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-cookiePOST /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 Fortify livewire.auth.* screens), the root resources/views/** layouts/components/partials, and the whole frontend toolchain — resources/js, resources/css, vite.config.js, package.json, package-lock.json, the node:24-alpine dev vite compose service, the Node stage of the prod Dockerfile, and the Node/npm ci/npm run build steps in CI. livewire/flux + livewire/livewire are removed from composer.json.
  • Routing: the Route::view('/') welcome + /dashboard routes are gone (GET / → 404). The infra health check is /up (bootstrap/app.php), which renders no Blade.
  • Fortify runs headless: config/fortify.php views => false. All Fortify features the operator API path uses stay enabled (password reset, email verification, 2FA, passkeys) on the web guard; only the Blade view routes disappear. The POST auth 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 => false there is no route('login'); Laravel's default unauthenticated() would RouteNotFoundException (500) on a non-JSON request. The Foundation JsonApiExceptionHandler now renders every AuthenticationException as a JSON:API 401 (no longer gated on expectsJson()).
  • 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.php becomes env-driven: PASSKEYS_RELYING_PARTY_ID (default: the APP_URL host) and PASSKEYS_ALLOWED_ORIGINS (comma-separated, appended to the APP_URL default, mirroring config/cors.php + config/sanctum.php) — Fortify's configurePasskeys() overwrites config('passkeys.*') from config('fortify.passkeys.*') at boot, so config/fortify.php's passkeys block 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.xyz for API api.stables.xyz + FE backoffice.stables.xyz), and each FE origin must be allow-listed in PASSKEYS_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, no vite dev service, no Node setup / npm ci / npm run build in CI. The test suite no longer renders @vite Blade, so a missing manifest can no longer redden the suite.
  • Two fewer Composer packages. livewire/flux + livewire/livewire are gone; make ide-helper
    • make boost-update regenerate stubs/guidelines without them.
  • Horizon is unaffected. The /horizon dashboard ships its own precompiled assets (not our Vite build) and keeps its gate-based authorization.
  • Emails are untouched. Mail is MailMessage line-builders; there was never a Blade dependency on the mail path.
  • resources/views/ is kept as an empty directory (a .gitkeep) so config/view.php's path + the prod view:cache step (Symfony Finder throws on a missing directory) stay valid.
  • Deploy workflows unchanged. deploy-preview.yml and the other workflows reference no Node/npm/Vite steps (verified), so only ci.yml needed 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.


← Decision log index