-
Notifications
You must be signed in to change notification settings - Fork 0
The Surfaces
Forge's several entry points — the employee shell, the phone web UI, the native shell, the kiosk display, the customer portal and the anonymous acceptance page — are one Angular application. App Surfaces on the hub explains what each one is for, who authenticates how, and why a phone gets redirected. This page is the other half: how src/app/app.routes.ts and the four build configurations actually produce them.
app.routes.ts is a single flat array, and its order is load-bearing. It reads top to bottom as: anonymous routes, then the root resolver, then the guarded employee shell, then the sibling surfaces, then a catch-all.
login, sso/callback, welcome, setup, setup/:token anonymous
portal anonymous, own layout + session
accept/:token anonymous, one order
'' (pathMatch: 'full') → rootRedirectGuard resolves, renders nothing
'' → authGuard, mobileRedirectGuard the employee shell and everything under it
m → authGuard phone web UI
app/enroll no guard — must be reachable unenrolled
app → instanceGuard, shellAuthGuard
display/shop-floor kiosk
dev-tools, __render-form utility
** → redirectTo: '' back through the resolver
Three ordering facts that are easy to break:
-
app/enrollis declared beforeappand carries no guard.instanceGuardsends an un-enrolled native shell to/app/enroll, so if enrolment sat inside the guarded branch the device could never get in. -
The empty path appears twice. The first entry is
pathMatch: 'full'withchildren: []— it exists only to runrootRedirectGuard, which returns aUrlTreeand never renders. The second is the real shell. Reversing them makes the root path render an empty dashboard shell instead of resolving a landing route. -
The catch-all redirects to
'', not to a 404 page. An unknown URL therefore re-enters the root resolver and lands wherever that user would normally land. There is no not-found screen by design.
Inside the shell branch, the sidebar's group headers get explicit redirectTo entries — operations → kanban, sales → customers, production → parts, people → employees, insights → reports. Groups are organisational, not routable, and without these a typed or bookmarked group name renders a blank shell. Add a group to the nav tree and you must add its redirect here too; nothing links the two structures automatically.
| Guard | Applied to | Does |
|---|---|---|
rootRedirectGuard |
'' (full match) |
Demo build → /welcome; otherwise defers to LayoutService.getDefaultRoute() for the landing rules the hub documents |
authGuard |
the shell, /m, and the kiosk preview route |
Requires a Forge session |
mobileRedirectGuard |
the shell only | Phone → /m, unless sessionStorage.preferDesktop; /account and /onboarding are exempt |
roleGuard('Admin', …) |
most feature branches | Client-side role filter |
capabilityGuard('CAP-…') |
selected branches | Client-side capability filter |
instanceGuard + shellAuthGuard
|
/app |
Native shell only: requires an enrolled instance; a device marked shared passes on the device credential alone and never carries a user session |
lockGuard |
children of the /app shell |
The native local lock |
mobileScreenGuard('CAP-MOBILE-…') |
individual /app screens |
A screen whose capability is off is unreachable by URL and falls back to Account; an unknown capability snapshot fails open and lets the server refuse |
demoOnlyGuard |
/welcome |
Demo-only pages redirect to the dashboard in a real build |
Two routes deliberately carry no guard and ship in every build. __render-form is a headless renderer the server-side document pipeline drives; dev-tools is an inert loading-state demo. Neither reads or writes tenant data, but both are worth knowing about before you conclude that "every route is guarded" — and dev-tools in particular is a development affordance that has never been excluded from the production configuration.
display/shop-floor also has no route guard: the kiosk authenticates with a device token that kioskTokenInterceptor attaches, and workers identify per action. Its preview child is behind authGuard and flagged through static route data, because that one renders mock data for training and must never do the real display's clear-on-entry.
One angular.json project, four configurations, distinguished almost entirely by which environment file is swapped in.
| Configuration | Invoked by | Output | Environment file | Service worker | Budgets |
|---|---|---|---|---|---|
production (default) |
npm run build |
dist/forge-ui/browser |
environment.prod.ts |
Yes (ngsw-config.json) |
Yes |
development |
npm run watch, npm start
|
dist/forge-ui/browser |
environment.ts |
No | No |
mobile |
npm run build:mobile |
dist/forge-ui-mobile |
environment.mobile.ts |
No | Yes |
demo |
ng build --configuration=demo |
dist/forge-ui-demo |
environment.demo.ts |
No | Yes |
Each environment file is three booleans and two URLs, and those three booleans are how one bundle becomes four products:
-
production— only gates Angular's own dev-mode behaviour. -
demoMode— turns ondemoApiInterceptor(which answers requests from in-browser fixtures) anddemoOnlyGuard, and makesrootRedirectGuardland on/welcome.apiUrlandhubUrlare deliberately empty strings so that any request escaping the mock layer fails loudly instead of reaching a real host. -
mobileShell— turns oninstanceGuard,shellAuthGuard, theInstanceServiceapp initializer andapiBaseInterceptor.
apiUrl is /api/v1 in every configuration except development, which names an absolute localhost API. In production the SPA and the API are same-origin because the nginx image in this repo proxies /api/, /hubs/, the signing service and the log service to their containers — the browser only ever sees one host. The native shell keeps the relative form too, and apiBaseInterceptor prefixes the enrolled instance's origin plus device headers at request time; nothing in the mobile build names a host, which is what lets one binary serve every install. See Mobile and Offline for the enrolment story.
nginx.conf in this repo is what the production image runs, and two things in it are worth understanding before you edit it.
The SPA fallback is try_files $uri $uri/ /index.html, which is the only requirement any reverse proxy in front of Forge has: unknown paths must return index.html. All six surfaces are the same origin and the same bundle, so there is nothing to split upstream. robots.txt is answered before the fallback on purpose — otherwise crawlers get index.html with a 200 and no crawl directives at all.
add_header in a child scope silently discards every parent-scope add_header. The config therefore repeats the full security header set (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy) inside every location block that adds any header of its own. If you add a location and give it one header, you have just dropped the whole security posture for that path. Locations that add nothing — the proxied ones — inherit correctly. Operator-facing guidance on what to expose is Hardening a Production Install.
The service worker is provided unconditionally but only built for production. app.config.ts calls provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode() }), while angular.json sets serviceWorker only on the production configuration. The mobile and demo outputs therefore contain no ngsw-worker.js yet still attempt to register one. It is harmless in the native shell, where registration is unavailable anyway, but it is a failed request on a demo deployment and it is not what the configuration reads as.
The translation catalogs are not in any service-worker asset group. ngsw-config.json prefetches the app shell (index.html, CSS, JS) and lazily caches media and fonts; /assets/i18n/*.json matches none of those patterns, and the data groups only cover /api/v1/**. A genuinely cold offline load has the bundle and no strings. See i18n.
proxy.conf.json does nothing on a host dev box. It is wired into ng serve and forwards to compose service names, which only resolve inside the containerised dev server. On a host machine the absolute URL in environment.ts wins, so a proxy edit that appears to have no effect has exactly no effect. The hub's Developer Setup covers this from the setup side.
capacitor.config.ts points webDir at dist/forge-ui-mobile/browser, one level below the outputPath in angular.json — the README's build-configuration table lists the output as dist/forge-ui-mobile, which is the Angular output path, not the directory Capacitor syncs.
Every URL this SPA calls is hand-written in a service under features/*/services/ or shared/services/. There is no generated client and no shared schema. Two mechanisms in this repo mirror server-side facts by hand, and both drift silently:
-
shared/capability/capability-endpoint-registry.tsis an order-sensitive prefix list mapping/api/v1/...paths to capability codes, mirroring the controller-level[RequiresCapability]attributes in forge-api. It exists so a request for a disabled capability never leaves the browser. Its own unit test checks the resolver's matching behaviour, not that the list still matches the server; a controller-level attribute added on the API side is simply missing here until someone notices, and the only symptom is a 403 in devtools instead of a silent no-op. Method-level gates are intentionally not mirrored. - Route strings in services are checked by a dedicated nightly test that parses forge-api's controller sources — see Testing § Contract drift, and forge-api for the routing conventions it assumes.
SignalR hubs are reached through hubUrl and SignalrService.getOrCreateConnection(hubPath), which owns reconnection and a manual retry after withAutomaticReconnect gives up. In production those connections traverse the same nginx /hubs/ proxy as everything else.
forge-ui · Apache 2.0 · built by Armory Works — this wiki covers the frontend; product-level truth lives on the Forge wiki.
Peer repos