Skip to content

The Surfaces

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

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.

The shape of the route table

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/enroll is declared before app and carries no guard. instanceGuard sends 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' with children: [] — it exists only to run rootRedirectGuard, which returns a UrlTree and 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 — operationskanban, salescustomers, productionparts, peopleemployees, insightsreports. 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.

What guards what

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.

The four build configurations

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 on demoApiInterceptor (which answers requests from in-browser fixtures) and demoOnlyGuard, and makes rootRedirectGuard land on /welcome. apiUrl and hubUrl are deliberately empty strings so that any request escaping the mock layer fails loudly instead of reaching a real host.
  • mobileShell — turns on instanceGuard, shellAuthGuard, the InstanceService app initializer and apiBaseInterceptor.

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.

Serving the built bundle

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.

Traps in the build configuration

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.

The seam with forge-api

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.ts is 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.

Clone this wiki locally