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.
| Route | Who registers it | What it answers |
|---|---|---|
/<main_route>/{Component?} | a loop over the app files, in routes/web.php | the 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 loop | one page's script and markup, fetched the first time that page is opened. Only used when lazy_pages is on |
/login, /processLogin, /logout | routes/web.php → AuthController | the 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/token | vsd/xauth-client | the bearer token VSApp asks for once at boot, because the page cannot read the sign-in cookies |
/report/{ticket}/{format?} | vsd/report-center | a rendered report, opened from a signed ticket — see Reports |
/<main_route>-{subs_code}/… | the tenanted loop, last in routes/web.php | the 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.
| Transport | Form | Needs | main_route becomes |
|---|---|---|---|
| path | example.com/dms-houxpress | nothing | the whole segment, dms-houxpress |
| host | houxpress.example.com/dms | wildcard DNS and a wildcard certificate | plain dms |
'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_routewith 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/dmsand 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
/processLoginwith the tenant gone and the password re-posted on refresh. - Logout returns to the tenant's login:
VSApp.logOut()sends itsmain_routeas?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_viewis untouched and still serves the untenanted route.
Failure semantics
| Situation | Behaviour | Why |
|---|---|---|
Unknown subs_code | 404 | never degrade to the untenanted surface — a typo would sign somebody in against whatever the bare name resolves to |
| Code not entitled to that app | 404 | the subscription's entitlement is the authority, not the URL: /solva-acme is refused unless Acme has Solva |
| XAuth unreachable | 404 on tenanted URLs | fail 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.
# LOCAL ONLY. Untenanted entry points redirect to /<app>-<code>.
DEV_SUBS_CODE=acme
| Request, signed out | Goes 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
| File | Role |
|---|---|
app/Support/TenantRoute.php | the path transport: splits the first segment on the first hyphen, then validates the code against the subscriptions entitled to that app |
app/Support/TenantHost.php | the host transport: exactly one label under the configured suffix, which is never derived from the request |
app/Support/DevTenant.php | DEV_SUBS_CODE: the guard and the redirect |
app/Http/Controllers/ShellController.php | asks the path first, the host second; 404 on anything unresolved |
routes/web.php | the per-app tenanted routes, registered last |
All five are template-owned files — vsa upgrade brings a newer version
of them into an existing project.