Architecture
Two applications, one product: a Vue SPA and an Express BFF, shipped together as a single container and deployed once per operator.
The two applications
portal/ is a Vue 3.5 + TypeScript single-page application. portalBackend/ is an Express 4 + TypeScript server, and it is a BFF - Backend for Frontend: it exists only to serve this SPA, never as a general API. In production they are one Docker image (lf-portal-full-express): the built SPA static files are copied into the Express image, and Express serves both the static bundle and the /api surface.
Frontend shape
src/unified/app/- the primary feature area, ~45 domain folders (billing, charging, customer care, product catalog, dataflows, reports...). All new code goes here.src/commonComponents/- the internal design system (Button, DataTable, Modal...). Import only from the barrel@/commonComponents; deep imports are lint-banned.- Legacy layers -
src/components/andsrc/__new__/features/are migration leftovers; despite its name,__new__is the old intermediate area. Do not add to either. - State - Vuex is being retired in favor of Pinia (
src/stores/). New global state goes to Pinia; touching a Vuex module means migrating it. - Services layer - Axios-based clients under
unified/services/talk exclusively to portalBackend, never directly to upstream services.
The BFF's three jobs
- Authentication and sessions. Passport strategies (local, Azure AD, Okta) log the user in; the session lives server-side in MySQL (
express-session+connect-session-sequelize). The browser holds only a session cookie - no JWT. - Permission-gated reverse proxy.
routes.jsonmaps ~1,380 frontend URLs to ~115 upstream DNO services. Keys are matched as literal strings - there is no/:idsupport. Dynamic segments travel as query params (/xxxById?id=...) and are spliced into the upstream path viapathReplaceTokens. Upstream calls usually ride a service token, not the user's token. Both sides (frontend service + routes.json entry) must be wired, or the call 404s. - System of record for Portal-local state. Tenants, views, roles, permission assignments, and settings live in the Portal's own MySQL through Sequelize. Business data (customers, orders, bills) never lives here - that is the DNO services' job, and the DNO backend knows nothing about Portal users or permissions.
The permission model
Access is layered, and every layer checks independently:
| Layer | Gate |
|---|---|
| Tenant | A View (module switch) must be enabled for the tenant before its permissions exist for roles |
| Role | Read/Write permission keys (e.g. OffersRead) are assigned to roles, roles to users |
| Sidebar + router | isUserAllowed(...keys) - OR over keys |
| Widgets | Each v-if evaluated alone - no inheritance, partial pages are normal |
| API proxy | routes.json permissions array - OR again; UI passing never implies API passing |
Write keys forceEnable their Read pair; clearing Read forceDisables Write. AND inside one permission array is not supported - use a composite key or split routes.
Next: Deployment - how this image reaches staging and production.