VSA Monolith Framework

Routes & tenancy

A VSA app registers very few server routes, because after the first document the page router owns every address. This page is the whole list, the rule that shapes it, and how one deployment serves many subscribers.

The routes a project registers

Everything below is in routes/web.php, routes/api/*.php or published by a vsd/* package. Nothing else serves HTML.

RouteWho registers itWhat it answers
/<main_route>/{Component?}a loop over the app files, in routes/web.phpthe shell document for that app, with the component to open. A signed-out visitor gets the app's guest_view — or its login_view under a tenant
/<main_route>/_page/{Component}the same loopone page's script and markup, fetched the first time that page is opened. Only used when lazy_pages is on
/login, /processLogin, /logoutroutes/web.php → AuthControllerthe only full page loads in the product. An app may add its own pair (/mac-login) — register it above the tenanted route
/api/<feature>/…one file per feature under routes/api/the page's data. Guarded by auth.api, and the controller still calls AuthGate::verifyAuth
/api/vsx-sec/tokenvsd/xauth-clientthe bearer token VSApp asks for once at boot, because the page cannot read the sign-in cookies
/report/{ticket}/{format?}vsd/report-centera rendered report, opened from a signed ticket — see Reports
/<main_route>-{subs_code}/…the tenanted loop, last in routes/web.phpthe same shell, for one subscriber. Everything below is about this one

main_route is the app file's own key by default and may differ from it (activehr's is ahr). Every URL the app builds spells the main_route, so the tenanted route is registered for both spellings.

Why a screen is not a route

Moving between screens is a router call, not a request. VSPageRouter builds every address as exactly three parts — origin / main_route / Component — and finds the component as the segment after main_route. That is the contract the rest of this page follows from: whatever identifies the tenant has to be inside main_route, not beside it.

One deployment, many subscribers

A VSA product is SaaS: the login screen must know which company before the password is checked, because XAuth matches the local login name inside that subscription. So the subscriber is in the URL, in one of two transports.

TransportFormNeedsmain_route becomes
pathexample.com/dms-houxpressnothingthe whole segment, dms-houxpress
hosthouxpress.example.com/dmswildcard DNS and a wildcard certificateplain dms
config/vsa.php
'tenant_domain'    => env('TENANT_DOMAIN', ''),        // blank = host transport OFF
'tenant_transport' => env('TENANT_TRANSPORT', 'both'), // both | path | host

both (the default) registers the path route always and reads the host too once the suffix is set; the path wins when a request carries both, because it is explicit in the URL. host does not register the path route at all, so /dms-houxpress then 404s.

Why the path form is one segment

/{subs_code}/{app} looks tidier and is wrong: the router rewrites the address as origin/app/Component, so the tenant survives a deep link and disappears on the first menu click — easy to miss in a quick test. A subs code that happens to equal the app key breaks the parse outright. /{app}-{subs_code} survives boot, menu clicks, Back/Forward and deep links, because the whole segment is main_route.

One route per app, never a catch-all

The tenanted route is registered per app, carrying that app file's middleware. A single {app_tenant} catch-all runs no app middleware, which is how staff once opened a merchant-only portal on its tenanted URL. It is registered last, so any plain route whose path starts with <head>- — /mac-login — must sit above it or it is read as app mac, code login, and 404s.

What carries the tenant from there

  • The shell overrides main_route with the resolved segment before anything reads it, so the meta tag, the menus and the router agree. The sidebar is built with it too — a sidebar that re-derived its own would link back to /dms and drop the tenant on the first click.
  • Login renders a hidden subs_code, because the POST leaves the tenanted URL behind, and forwards it to the console with the credentials.
  • A failed login redirects back to the tenanted login with an error code (Post/Redirect/Get). Rendering the form as the POST's response left the address bar on /processLogin with the tenant gone and the password re-posted on refresh.
  • Logout returns to the tenant's login: VSApp.logOut() sends its main_route as ?from=, and the controller accepts only a well-formed {app}-{code} for an app this project hosts, as a same-origin path.
  • The app file gains login_view — what a signed-out visitor gets on a tenanted URL. guest_view is untouched and still serves the untenanted route.

Failure semantics

SituationBehaviourWhy
Unknown subs_code404never degrade to the untenanted surface — a typo would sign somebody in against whatever the bare name resolves to
Code not entitled to that app404the subscription's entitlement is the authority, not the URL: /solva-acme is refused unless Acme has Solva
XAuth unreachable404 on tenanted URLsfail closed; untenanted routes are unaffected

404 rather than a distinguishable error, so the route cannot be used to enumerate which companies exist.

Local development: never type the code by hand

One .env key sends every untenanted entry point to the tenanted one, so a local run exercises the real path — validation, the hidden subs_code, the router keeping the tenant — instead of a shortcut production never takes.

.env
# LOCAL ONLY. Untenanted entry points redirect to /<app>-<code>.
DEV_SUBS_CODE=acme
Request, signed outGoes to
/, /login, /logout/dms-acme — the login form, code prefilled
/dms/StockOnHandComponent/dms-acme/StockOnHandComponent — the deep link is kept
/, signed in/dms-acme/DashboardComponent

It redirects, never injects, and it is guarded three times: the value is blanked in config outside APP_ENV=local, honoured only on a loopback or .test/.localhost host, and read through one detector (DevTenant::isLocal()) so a copied .env cannot change a real deployment. Use the code the local auth database has — local and production subscriber data diverge, and a wrong code is a fail-closed 404.

Where the pieces are

FileRole
app/Support/TenantRoute.phpthe path transport: splits the first segment on the first hyphen, then validates the code against the subscriptions entitled to that app
app/Support/TenantHost.phpthe host transport: exactly one label under the configured suffix, which is never derived from the request
app/Support/DevTenant.phpDEV_SUBS_CODE: the guard and the redirect
app/Http/Controllers/ShellController.phpasks the path first, the host second; 404 on anything unresolved
routes/web.phpthe per-app tenanted routes, registered last

All five are template-owned files — vsa upgrade brings a newer version of them into an existing project.