portal_css (Portal Client Side Server)

Role

Customer-facing portal Express 5 server forked from vuer_css (its README literally says: “Keeped the core functionality from CSS”). Where vuer_css is the full waiting-room / videochat / self-service / Janus relay surface, portal_css is the slim portal flavour — only the screens a banking customer needs before entering an identification session: registration, login, password recovery, password change, personal-data change, strong customer authentication (SCA via SMS / email tokens), JWT handoff to downstream apps. No Janus relay, no waiting-room queue, no self-service flow engine. The /videochat route only exists as a thin redirect / lobby stub.

One-line summary

Slim sister-service of vuer_css for portal screens. Same DI / hook architecture, same Twig+React+Stylus pipeline, same @techteamer/mq RabbitMQ comms — but with only the auth/account routes and no business logic of its own. All real work is delegated to vuer_oss over RabbitMQ.

PropertyValue
Repogithub.com/TechTeamer/portal_css
Local path/Users/levander/coding/facekom/portal_css
Default branchdevel
External port30380 (k8s NodePort / nginx)
Internal HTTP port10383
Internal Socket.IO port10382
Container manifestportal-css.yml (see infrastructure)
JIRA prefixesFKITDEV-XXXX, FKQA-XXX
RuntimeNode.js >= 22.18.0
FrameworkExpress 5.1 + Socket.IO 4 + React 18 + Twig 3 + Stylus

1. Overview

portal_css was forked from vuer_css to give clients a portal landing experience — register, log in, recover or change password, change personal data, do SCA, then JWT-redirect to the actual identification site. The README for the project is one line: “Keeped the core functionality from CSS.” That phrasing is accurate: the engine, build pipeline, twig/dictionary/translation system, service container, and queue/RPC machinery are all carbon copies of vuer_css. The differences are entirely in the route surface and the service set.

What’s been removed vs. [[vuer_css]]:

  • Janus / WebRTC TransportPool and its session storage (no /transport-css queue, no RoomTransportSession, no SelfServiceTransportSession, no EchoTransportSession).
  • Waiting-room queue join logic, SocketService heartbeat, grace service.
  • Self-service flow engine, eMRTD reads, screenshot ML pipeline.
  • Disaster-mode hooks tied to videochat.
  • Compatibility / system-check infrastructure (a CompatibilityService shell remains, but no system-check page).

What’s added:

  • A PortalService with Redis-backed rate limits per customer for SMS and email token sends.
  • Strong-customer-authentication two-phase POST (/api/submit-login/api/submit-token).
  • A handful of POST endpoints for personal data change and password recovery / reset / change.
  • A CustomerDataService that caches what vuer_oss returns for the logged-in customer.
  • A simplified IpFilterService (in-memory only — see gotchas).

The customization layer (customization/ folder) is currently a stub on devel (just customizations.js and an empty branding-options.json); per-customer extensions live in dedicated branches like customization/instacash, customization/raiffeisen, customization/cib — see customization-branches and breakage-risks.


2. Stack

LayerTechnology
RuntimeNode.js >= 22.18.0
WebExpress 5.1, Socket.IO 4
UI engineReact 18 + custom MVC engine (client/engine/*) inherited from vuer_css
TemplatingTwig 3 (engines/twig/render-server.js)
StylingStylus CleanCSS
Buildesbuild (page bundles) + Browserify (React externals) + Stylus compiler
SessionsRedis (async-redis + connect-redis), with iOS 12 sameSite workaround
Messaging@techteamer/mq v7 (RabbitMQ AMQPS)
Authcsurf (deprecated), helmet, cookie-parser, express-session, jsonwebtoken
Logginglog4js channels (portal, express, session, csp) + appenders (console / file / syslog / papertrail)
Observability@sentry/browser on the client + /api/sentry collector route on the server
TypeScriptToolchain landed (FKITDEV-8538 / PR #683) — no .ts files yet

Three pipelines, one repo

bin/script/script.compiler.js esbuild for page scripts (*.script.js). bin/script/external.compiler.js Browserify for React externals. bin/style/style.compiler.js Stylus CleanCSS for *.style.styl. Watch mode: bin/watch/watch.js (chokidar + LiveReload).


3. Repo layout (annotated)

portal_css/
├── bin/                       Build & dev tooling
│   ├── build/build.js         Master build orchestrator
│   ├── script/                esbuild + Browserify compilers
│   ├── style/style.compiler.js Stylus + CleanCSS
│   └── watch/watch.js         chokidar + LiveReload (port 11080)
│
├── client/                    Browser-side code
│   ├── engine/                Custom MVC: View, Template, Controller, Radio, Service, Action
│   ├── features/              auth.js + socket/SocketService.js + sentry transport
│   ├── react/                 react-loader.js, react-page-context, hooks
│   ├── ui/
│   │   ├── pages/             One folder per portal page (login, registration, ...)
│   │   ├── layouts/           default, kiosk, etc.
│   │   ├── elements/          Reusable UI elements
│   │   └── states/            ActiveState, ClosedState, ... CSS-class state classes
│   ├── resources/             colors / breakpoints / global translations
│   └── utils/                 Misc browser helpers
│
├── config/
│   ├── dev.json               Default / developer config
│   └── docker.json            Container-mode overrides
│
├── customization/             Empty / stub on `devel`
│   ├── customizations.js      No-op entry point
│   ├── branding-options.json  Empty defaults
│   ├── listeners/             Per-hook listener modules (deployed via branches)
│   ├── server/                Server-side overrides (deployed via branches)
│   └── ui/                    UI overrides (deployed via branches)
│
├── engines/
│   ├── build/                 Build helpers
│   ├── translator/            Dictionary engine (.trans.js modules)
│   ├── twig/                  Twig setup + custom filters
│   │                          - timestamp, filesize, documentType
│   └── util/                  Shared utility helpers
│
├── server/                    Server-side code
│   ├── server.js              -> in repo root, see Section 4
│   ├── bootstrap/connection/  rabbitmq.js (RabbitMQ connection + RPC client wiring)
│   ├── logger.js              log4js setup (4 channels, 4 appender types)
│   ├── queue/                 RPC client / RPC server / queue client classes
│   ├── service/               PortalService, BrandingService, CustomerDataService,
│   │                          IpFilterService, SocketService, CompatibilityService
│   ├── service_container.js   DI container + ServiceBus (WildEmitter)
│   ├── socket/socket-server.js Socket.IO modules: client, auth, pagevisit
│   ├── SocketTokenStorage.js  JWT signed for 2-min handshake
│   ├── util/                  Misc helpers
│   └── web/
│       ├── WebServer.js       Full middleware chain + setupRoutes
│       ├── Template.js        res.render wrapper, hook injection
│       ├── routes.js          Page + API route table
│       ├── routes/            Per-page endpoint files (*.endpoint.js)
│       ├── api/               POST handler files (submit-*.js)
│       ├── helper/            Per-route helpers
│       └── middleware/        IP filter, kiosk, locale, etc.
│
├── test/                      Currently minimal
│   ├── jest.config.js
│   ├── lib/                   Test helpers
│   └── coverage/
│
├── web/                       Compiled output (do NOT edit)
│   ├── css/
│   ├── js/
│   └── branding/
│
├── server.js                  Process entry point (see Section 4)
├── config.js                  Config loader + safe accessor
├── babel.config.js            @babel/preset-typescript included
├── tsconfig.json              noEmit + erasableSyntaxOnly
├── package.json
└── README.md

Devel state of customization/

On devel, customization/ contains only the no-op entry stub. Each customer onboarding ships a long-lived branch (customization/instacash, customization/raiffeisen, customization/cib, …) that adds files into this folder. Merging devel into a customization branch is the danger surface tracked in breakage-risks and customization-branches.


4. Entry point — server.js

server.js is the process entry. It mirrors vuer_css’s startup but with a smaller dependency list.

server.js
├── process error handlers (uncaughtException, unhandledRejection, exit, warning)
├── signal handlers (SIGINT -> 130, SIGTERM -> 143)
├── process.env.TZ = 'Etc/UTC'
├── NODE_TLS_REJECT_UNAUTHORIZED = '0'  (when settings.allowSelfSignedCerts)
├── config.loaded
├── connectRabbitMQ()
├── setupQueue()              -> serviceContainer.queue + queueServer.* + queueClient.*
├── setupServices()           -> serviceContainer.service.*
├── setupCustomizations()     -> customization/customizations.js (no-op on devel)
├── StartWebServer()          -> new WebServer(...) chain -> app.listen(10383)
└── StartSocketServer()       -> io.listen(10382)

Key behaviour:

  • Any failed step logs and calls process.exit(2). Supervisor / k8s liveness restart in 1-2 s.
  • Logger is require()d inside error handlers (lazy) to avoid circular deps.
  • The promise chain is sequential — the order matters because services depend on queue.* already being on the container.

Signal & TLS knobs match vuer_css

Same Etc/UTC forcing, same self-signed cert bypass via settings.allowSelfSignedCerts, same exit codes. If you’ve debugged this on vuer_css the muscle memory transfers directly.


5. Service Container + ServiceBus

File: server/service_container.js

A plain object { emitter } populated with services / clients / servers as the boot chain progresses. The emitter is a ServiceBus extending WildEmitter, supporting three primitives:

PrimitiveMethodSemantics
HooksaddHook(name, fn) / callHooks(name, ...args)Multiple registrations, run via Promise.all. Used for augmentation.
callOnlyHook(name, ...args)Only the first-registered handler is called. Subsequent registrations are silently ignored.
OverridesregisterOverride(name, fn) / callOverride(name, args, defaultFn)Last argument is the default implementation; if a customization registered an override, it runs instead. Used for replacement.
EventsNative WildEmitterStandard pub/sub.

This is the extension primitive that the customization layer leans on. Every important lifecycle point fires a hook that a customization branch can attach to:

HookFired byWhat customizations can do
queuesetupQueueAdd RPC clients, queue clients, queue servers
servicessetupServicesRegister additional services on the container
middlewaresWebServer.setupRoutes (early)Insert middleware into the stack
routesWebServer.setupRoutesAdd bespoke routes
socketsocket-server.jsAdd Socket.IO event modules
template:custom-content:${page}Template.render()Inject per-page template variables
webrtc:hd-constraintsTemplate.render()Override WebRTC constraint payload
config:socketio-settingsTemplate.render()Override Socket.IO client settings
customer:loginsubmit-login / submit-tokenRun after a successful login
customer:registrationsubmit-registrationRun after a successful registration
customer:data-changesubmit-personal-dataRun after personal-data change

Overrides used in routes:

  • routes:landing — override the GET / handler.
  • routes:videochat — override the GET /videochat handler.

callOnlyHook foot-gun

If two listeners both addHook('foo', ...), then callOnlyHook('foo', ...) will silently ignore the second. Branches that both want to own the hook will trample each other quietly.


6. WebServer

File: server/web/WebServer.js

WebServer builds the Express app and is constructed exactly once during startup. Its setup chain is:

new WebServer(...)
├── setupLocale()                   express-locale + cookie locale
├── setupRedis()                    async-redis client (sessions + ratelimits)
├── setupSession()                  connect-redis session store + iOS 12 fix
├── setupTwig()                     engines/twig/render-server.js
├── setupRoutes()                   server/web/routes.js -> registers everything
├── setupCSPReportViolation()       /csp-report endpoint
├── setup405Handler()               method not allowed handler
├── setup404Handler()               page not found handler
└── setupErrorHandler()             top-level error handler

Middleware chain (in order)

  1. Host header validation — drops requests where req.get('host') is not in config.hosts.
  2. CSP noncecrypto.randomUUID() per request, attached to res.locals.cspNonce.
  3. UA parseua-parser-js; populates req.browser with isIOS, isChrome, isSafari, etc.
  4. Helmet — security headers, configurable.
  5. No-cache — adds Cache-Control: no-store, no-cache, must-revalidate.
  6. CSP — per-path CSP rules, uses the per-request nonce.
  7. Body parsersurlencoded + json (25 MB) + dedicated CSP-report JSON parser.
  8. Cookie parser.
  9. Locale middleware — cookie + querystring locale override.
  10. Redis session middleware — connect-redis store, iOS 12 sameSite=false workaround.
  11. Twig template engine — registered via setupTwig().
  12. csurf — CSRF protection cookie-based.
  13. RoutessetupRoutes().
  14. CSP report endpoint — collects violations.
  15. 405 handler.
  16. 404 handler.
  17. Error handler.

Browser detection

req.browser is fully populated from UA parse. Used by:

  • CSP middleware (skipped for IE / older Safari).
  • Session middleware (iOS 12 sameSite: false override).
  • Templates (passed to client via data-browser-info attribute).

CSP nonce flow

Nonce res.locals.cspNonce Twig template injects nonce="{{ cspNonce }}" on <script> tags Helmet’s CSP header references 'nonce-{{cspNonce}}'.

findRoute() helper

WebServer.findRoute(path, method) scans the Express app router and returns the matching layer. Used by the customization layer to inspect / wrap existing routes.

Express 5 _router → router rename

Express 5 renamed the internal property app._router to app.router. WebServer.findRoute() reads this property and was broken twice by the upgrade:

  • First fix: PR #670 (initial Express 5 work).
  • Second fix: PR #689 (commit e1fcbd57) — fix: fix express protected router property on WebServer. If you grep new code for app._router, rewrite to app.router. Same gotcha applies to any custom branch that reads the internal router.

Setup helpers

MethodPurpose
setupLocale()Wires locale package + supported locales from config
setupRedis()Creates async-redis client, attaches to container
setupSession()connect-redis store + iOS 12 sessionStore.generate monkey-patch
setupTwig()Calls engines/twig/render-server.js to register filters & functions
setupRoutes()Loads server/web/routes.js, fires middlewares + routes hooks
setupCSPReportViolation()POST /csp-report log4js csp channel
findRoute()Helper for customization layer to find existing route layer
setup405Handler()Method-not-allowed for known paths
setup404Handler()Renders 404 template
setupErrorHandler()Top-level error handler, returns templated error or JSON

7. Routes

File: server/web/routes.js

RouteMethodPurpose
/GETLanding page; if a JWT is present, performs customerAuthWithToken and may redirect
/videochatGETVideochat lobby stub
/loginGETLogin page
/api/submit-loginPOSTFirst step of two-phase login (returns {require: 'strong_authentication'} when SCA needed)
/api/submit-tokenPOSTSecond step — verifies SMS / email token
/api/submit-resend-tokenPOSTRe-sends the SCA token
/password-changeGETPassword change form (logged-in customer)
/api/submit-password-changePOSTChange password
/password-recovery/:token?/:lang?GETPassword recovery (with optional token deep-link)
/api/submit-password-recoveryPOSTInitiate password recovery email
/password-resetGETPassword reset form
/api/submit-password-resetPOSTSet new password from recovery token
/logoutGETDestroy session, redirect to landing
/registrationGETRegistration form
/api/submit-registrationPOSTSubmit registration
/personal-dataGETPersonal data change form
/api/submit-personal-dataPOSTSubmit personal data update
/ui-kitGETUI kit demo (dev only)
/api/sentryPOSTSentry collector endpoint

Routes are registered through:

emitter.callHooks('routes', app, { csrfProtection, requireSession })
emitter.callOverride('routes:landing', { req, res, next, ... }, defaultLandingHandler)
emitter.callOverride('routes:videochat', { req, res, next, ... }, defaultVideochatHandler)

CSRF protection is applied to most POST handlers; /api/sentry is exempt (it’s a side-channel error reporter).

No submit-feedback / submit-appointment / submit-self-service-*

Those exist in vuer_css but were dropped here. The CSRF-bypass on submit-appointment documented under security-audit is not present in portal_css.


8. Services

8.1 PortalService

File: server/service/PortalService.js

Strong-auth token issuance + password recovery email. Uses Redis to enforce per-customer rate limits.

MethodPurpose
sendSmsToken(customerId, phoneNumber)Calls PortalRPCClient.generateToken then sendSmsTemplate. Increments customer-sms:${id}/* keys.
sendEmailToken(customerId, email)Calls generateToken then sendEmailTemplate. Increments customer-email:${id}/* keys.
sendPasswordRecoveryEmail(customerId, email)Issues a recovery token and emails it.
smsTokenLimitExceeded(customerId)Returns true if Redis counters exceed the configured threshold.
emailTokenLimitExceeded(customerId)Same for email.

Rate-limit Redis keys:

  • customer-sms:${customerId}/${windowKey}
  • customer-email:${customerId}/${windowKey}

Where windowKey slices time into windows (configurable). Configured throttle thresholds live in config.portal.*.

8.2 BrandingService

File: server/service/BrandingService.js

Loads customization/branding-options.json, merges over defaults, validates that referenced branding files actually exist on disk.

MethodPurpose
setupCustomizations()Loads branding-options.json and overlays config defaults.
brandingFileExists(relPath)Returns true if a referenced branding file exists. Used at startup for fail-fast validation.

8.3 CustomerDataService

File: server/service/CustomerDataService.js

Redis-backed cache for whatever vuer_oss returns for the logged-in customer.

PropertyValue
Keycustomer-data:${customerId}
Default TTL600 s (10 min)
Invalidation flagreq.session.forceRedisCache
MethodPurpose
get(customerId)Returns cached customer data if present, otherwise RPC-fetches via GetCustomerRPCClient and caches.
invalidate(customerId)Deletes the Redis key. Triggered when req.session.forceRedisCache is set.

8.4 IpFilterService

File: server/service/IpFilterService.js

Per-IP throttling. In-memory only — there is no Redis persistence in this service.

MethodPurpose
createIpFilter(tag, opts)Returns middleware that blocks if IP is currently filtered.
createIpMonitorAndFilter(tag, opts)Returns middleware that monitors and filters in one step.
monitorIp(ip, tag)Records an attempt for an IP+tag pair.
releaseIp(ip, tag)Clears the filter for an IP+tag.
isIpFiltered(ip, tag)Boolean check.
isFilteringOn(tag)Boolean check for whether filtering is configured for tag.

Internal: a setInterval (1 s) cleanup loop garbage-collects expired entries.

In-memory only

Multi-node portal deployments will see inconsistent throttling because each pod has its own in-memory state. An attacker round-robining across replicas can multiply their effective rate by replica count. If the deployment scales horizontally, this needs Redis backing — see tech-debt.

8.5 SocketService

File: server/service/SocketService.js

Light wrapper over the Socket.IO server. Provides findClient(predicate), filterClients(predicate). No heartbeat (the heartbeat in vuer_css is for waiting-room customers, which doesn’t exist here).

8.6 CompatibilityService

Browser-compat rule check skeleton inherited from vuer_css. Currently underused — there’s no system-check page on the portal, so this is mostly dead weight kept for parity / customization.


9. Template helper + Twig

server/web/Template.js

Static helper:

Template.render(req, res, templatePath, variables, dictionaryOverrides)

It wraps res.render and threads through:

  • cspNonce (per-request).
  • browser (UA-parsed object).
  • csrfToken (req.csrfToken()).
  • socketToken (signed JWT, 2-min expiry — see 10. Socket layer).
  • dict (Dictionary instance for the requested locale; supports dictionaryOverrides).
  • locales (supported + default).

Before rendering it fires three hooks:

emitter.callHooks(`template:custom-content:${pageName}`, { req, res })
emitter.callHooks('webrtc:hd-constraints', { req, res })
emitter.callHooks('config:socketio-settings', { req, res })

The first lets a customization branch inject per-page template variables; the others let it override WebRTC and Socket.IO client settings.

engines/twig/render-server.js

Twig setup. Registers custom filters and functions:

Filter / functionPurpose
timestampFormats epoch ms to a locale string.
filesizeFormats byte counts (kB / MB / GB).
documentTypeMaps internal document type code to localized label.
nameOrder (function)Locale-aware family-name / given-name ordering.

Translation system

Same as vuer_cssengines/translator/Dictionary.js loads *.trans.js modules, which are functions that call dict.define({...}) with { en, hu } per key. Bilingual EN+HU is mandatory on every new string.


10. Socket layer

File: server/socket/socket-server.js

Socket.IO v4. Loads three event modules by default:

  • client — connection lifecycle.
  • auth — JWT handshake.
  • pagevisit — record page-visit IDs.

Then fires the socket hook so customization branches can register additional modules.

server/SocketTokenStorage.js

PropertyValue
AlgorithmJWT (jsonwebtoken)
Secretconfig.jwt.secret (shared)
Expiry2 minutes
Payload{ sessionId }

The token is generated server-side inside Template.render() and embedded in the page as <body data-socketToken="...">. Browser reads it on connect, sends as auth:auth to the Socket.IO server, which calls verifyToken(jwt) -> sessionStore.get(sessionId) and attaches the session.

Shared JWT secret

Same jwt.secret for all socket tokens. Compromise of this secret allows forging socket auth tokens. See security-audit.


11. RabbitMQ / RPC layer

Bootstrap: server/bootstrap/connection/rabbitmq.js

This file defines which RPC clients / RPC servers / queue clients are wired up. The portal flavour is a subset of vuer_css’s queue inventory.

RPC clients (portal vuer_oss)

Queue nameClassPurpose
rpc-vuer-portalPortalRPCClientThe big one — see method table below
rpc-customer-portal-dataPortalDataRPCClientPer-customer portal data (display fields, branding)
rpc-jwt-authJwtAuthRPCClientJWT authentication
rpc-get-customerGetCustomerRPCClientCustomer record lookup
rpc-openhoursOpenHoursRPCClientOpen hours (used to gate live-call links)
rpc-openhours-calendarsOpenHoursCalendarsRPCClientCalendar selection
background-room-exportBackgroundRoomExportRPCClientRoom data export — kept from CSS

RPC servers (portal receives)

Queue nameClassPurpose
rpc-portal-vuerPortalRPCServerReceives commands from vuer_oss directed at the portal layer

PortalRPCClient methods

The single most important RPC client in the project.

MethodReturns / Purpose
getLoginFields()Which fields the login form should render (per-tenant config)
authLoginCredentials(credentials)Validates username/password; returns { customerId, requireStrongAuth, deliveryChannel, ... }
findCustomerId(query)Resolve a customer record by search keys
generateRedirectToken(customerId)One-shot token used for cross-portal handoff
createCustomer(payload)Registration
updateCustomerData(customerId, payload)Personal data change
currentToken(customerId, channel)Returns currently outstanding token (if any)
generateToken(customerId, channel)Generates a new SCA token
verifyToken(customerId, token)Verifies a submitted SCA token
sendEmailTemplate(customerId, template, vars)Renders + sends an email via vuer_oss mailer
sendSmsTemplate(customerId, template, vars)Renders + sends an SMS via vuer_oss SMS layer
clientErrorLog(payload)Forward client-side /api/sentry reports to the central error log

Cross-link

Full RabbitMQ topology (queue ACLs, consumer counts, AMQPS client cert path, error queue handling) lives in rabbitmq-communication. The patterns there apply identically to portal_css.


12. Three call-graph traces

12.1 App startup

node server.js
└── require('./config').loaded
    └── connectRabbitMQ()
        └── setupQueue(serviceContainer)
            ├── new PortalRPCClient(...)
            ├── new PortalDataRPCClient(...)
            ├── new JwtAuthRPCClient(...)
            ├── new GetCustomerRPCClient(...)
            ├── new OpenHoursRPCClient(...)
            ├── new OpenHoursCalendarsRPCClient(...)
            ├── new BackgroundRoomExportRPCClient(...)
            ├── new PortalRPCServer(...)
            └── emitter.callHooks('queue', serviceContainer)
        └── setupServices(serviceContainer)
            ├── new PortalService(...)
            ├── new BrandingService(...).setupCustomizations()
            ├── new CustomerDataService(...)
            ├── new IpFilterService(...)
            ├── new SocketService(...)
            ├── new CompatibilityService(...)
            └── emitter.callHooks('services', serviceContainer)
        └── setupCustomizations()
            └── require('./customization/customizations.js')(serviceContainer)
        └── StartWebServer()
            └── new WebServer(serviceContainer)
                ├── setupLocale()
                ├── setupRedis()
                ├── setupSession()
                ├── setupTwig()
                ├── setupRoutes()           -> emitter.callHooks('middlewares', ...)
                │                              -> emitter.callHooks('routes', ...)
                ├── setupCSPReportViolation()
                ├── setup405Handler()
                ├── setup404Handler()
                └── setupErrorHandler()
            └── app.listen(10383)
        └── StartSocketServer()
            └── io.listen(10382)
                └── socket-server.setupEvents(io)
                    -> client / auth / pagevisit modules + emitter.callHooks('socket', io)

12.2 POST /api/submit-login (full SCA flow)

POST /api/submit-login   (csurf -> ipFilter('login') -> handler)
└── handler
    ├── portal.authLoginCredentials(creds)         [RPC: rpc-vuer-portal]
    │   <- { customerId, requireStrongAuth, deliveryChannel: 'sms'|'email' }
    └── if requireStrongAuth:
        ├── portal.smsTokenLimitExceeded(customerId) | emailTokenLimitExceeded(...)
        ├── portal.sendSmsToken(customerId, phone)  | sendEmailToken(customerId, email)
        │   ├── PortalRPCClient.generateToken(...)
        │   └── PortalRPCClient.sendSmsTemplate / sendEmailTemplate(...)
        ├── req.session.temporaryCustomerId = customerId
        ├── req.session.tokenSentAt = Date.now()
        └── res.json({ require: 'strong_authentication', deliveryChannel })

POST /api/submit-token   (csurf -> ipFilter('submit-token') -> handler)
└── handler
    ├── reads req.session.temporaryCustomerId
    ├── portal.verifyToken(temporaryCustomerId, body.authToken)   [RPC]
    ├── on success:
    │   ├── req.session.customerId = temporaryCustomerId
    │   ├── delete req.session.temporaryCustomerId
    │   ├── delete req.session.tokenSentAt
    │   └── emitter.callOnlyHook('customer:login', { req, res, customerId })
    └── res.json({ ok: true, redirect: '/' })

12.3 GET /login (page render)

GET /login   (csurf -> handler)
└── login.endpoint(req, res, next)
    ├── if req.session.customerId: res.redirect('/')
    ├── portal.getLoginFields()          [RPC: rpc-vuer-portal]
    │   <- { fields: [...], legalLinks: {...} }
    └── Template.render(req, res, 'login.twig', { fields, legalLinks })
        ├── attaches cspNonce / browser / csrfToken
        ├── attaches socketToken (jwt.sign({sessionId}, secret, { expiresIn: '2m' }))
        ├── emitter.callHooks('template:custom-content:login', ...)
        ├── emitter.callHooks('webrtc:hd-constraints', ...)
        ├── emitter.callHooks('config:socketio-settings', ...)
        └── res.render(...)              -> Twig engine -> HTML

13. Customization layer

On devel, customization/ is a stub. The interesting case is the per-customer branches.

A typical customization branch (e.g. customization/instacash, customization/raiffeisen, customization/cib) layers in:

LayerExamples
Queue / RPC clientsCustom customization/server/queue/rpc_client/*.js registered via 'queue' hook
ServicesCustom service classes registered via 'services' hook
MiddlewareInserted via 'middlewares' hook
RoutesInserted via 'routes' hook; existing routes overridden via registerOverride('routes:landing', fn) etc.
Listenerscustomization/listeners/<name>.js plugged into hook bus
UI pagescustomization/ui/pages/<page>/... overrides — instacash uses ic-* prefix (e.g. ic-landing, ic-login)
Brandingcustomization/ui/branding/colors.styl, font.styl, asset swaps under branding/
Translationscustomization/resources/translations.js overlay

There are 80+ active customization branches — see customization-branches for the inventory and breakage-risks for the merge-conflict hotspots between devel and the long-lived branches.

Devel-to-customization merge surface

Any change in server/web/routes.js, server/web/WebServer.js, server/service_container.js, or server.js is a high-risk merge target. Customization branches subclass / monkey-patch these, so renaming hooks, reordering middleware, or changing function signatures will cause silent regressions in deployed branches.


14. TypeScript migration (FKITDEV-8538, PR #683)

Status as of 2026-05-07: infrastructure-only landing. Zero .ts files in the repo.

What landed:

  • tsconfig.json with noEmit: true, erasableSyntaxOnly: true, CommonJS target, include covering server/**/*.ts, client/**/*.ts, customization/**/*.ts.
  • babel.config.js includes @babel/preset-typescript.
  • ESLint integration via @typescript-eslint/eslint-plugin.
  • Jest configured to use babel-jest for .ts transforms.

Constraints (from erasableSyntaxOnly):

ForbiddenReason
enumNot erasable; must use as const objects + union types instead
namespace (with values)Not erasable; use modules
Parameter properties (constructor(public x: number))Not erasable
import = require(...) / export =Use ES modules
Decorators with runtime semanticsNot erasable

This is the same constraint that landed in vuer_oss under FKITDEV-8535 / FKITDEV-8538. If you’ve migrated code there, the same rules apply here — see the equivalent migration notes.

Why “infrastructure-only”?

Babel’s @babel/preset-typescript strips types but does not type-check. tsc --noEmit does the type-check. CI runs both:

  • babel (or esbuild via babel-jest) compiles .ts to .js at runtime — purely erasure.
  • tsc --noEmit enforces type correctness against tsconfig.json.

The split lets us start adopting .ts incrementally without touching the runtime pipeline.


15. Recent activity (latest 5 commits on devel)

CommitSubject
019f1705chore(FKQA-321): maintenance/devel (#690)
e1fcbd57fix: fix express protected router property on WebServer (#689) — Express 5 _router → router rename fix
70983d3echore(FKQA-320): twig update (#687)
47616db7BREAKING(FKITDEV-8538): Portal css TS support (#683)
50150d20chore(FKQA-319): devel maintenance (#684)

47616db7 is the TS toolchain landing (Section 14). e1fcbd57 is the second Express 5 router-rename fix; the first was PR #670.


16. Notable gotchas

Express 5 _router -> router rename

Already broke WebServer.findRoute() twice (PR #670 and PR #689 / commit e1fcbd57). Any new code that reads app._router will break under Express 5 — use app.router instead.

iOS 12 mobile Safari session cookie

setupSession() monkey-patches sessionStore.generate to override sameSite to false for iOS 12. Without this, the session cookie is dropped silently on first request. Same workaround as vuer_css.

CSRF token storage

csurf is configured to store the token in a cookie, not the session. This is intentional (allows separate same-site behaviour) but means the token persists across sessions; rotate explicitly on login.

Two-phase strong auth state machine

First POST returns { require: 'strong_authentication' } and seeds req.session.temporaryCustomerId + req.session.tokenSentAt. The customer is not yet logged in at this point. The second POST validates authToken and only then sets req.session.customerId. Don’t read customerId from the session in the SCA dispatch path.

IpFilterService is in-memory only

Per-pod state. Multi-replica deploys will see inconsistent throttling and effectively higher rate limits per IP than configured. Move to Redis if scaling horizontally — see tech-debt.

Twig dictionary uses custom Dictionary.load()

Translations come from *.trans.js files via engines/translator/Dictionary.jsnot standard i18next / format.js. Don’t reach for community Twig i18n filters.

csurf deprecated upstream

The package is still wired but flagged on npm. Modernization target — see tech-debt.

CSP-violation logger is an unthrottled noise amplifier

See ASSICASH-71 — InstaCash CSS log noise triage; identified unthrottled CSP-violation logger at WebServer.js as a noise amplifier.

Bilingual mandatory

Every new UI string requires both en and hu translations. CI will not catch a missing locale at translate time, but the runtime fallback may surface English in a Hungarian-only deployment.

Cache busting

Page bundles use a randomizer suffix in the URL (?{randomizer}). In production this is the server-start timestamp; in dev it’s per-request, so every reload picks up changes immediately.


17. Open questions

Customization listener / server scope

What’s the expected lifetime / re-entrancy of customization/listeners/* modules? Are they singletons, per-request, per-route?

/videochat route

What does the stub actually render in devel? Is there any production deployment that uses it, or is it kept solely as the override target for routes:videochat?

Socket auth full flow

Does the portal use Socket.IO at all in production today? (No videochat, no waiting room.) If not, the socketToken machinery is dead weight for portal-only customers.

Dictionary internals

Where do dictionary overrides for portal-specific keys live? Is there a deployment-time merge between vuer_css-shared dicts and portal-specific dicts?

Email / SMS service ownership

PortalRPCClient.sendEmailTemplate / sendSmsTemplate calls vuer_oss. Which vuer_oss service owns the actual delivery (SMTP / SMS gateway)? Per-tenant config?

OpenHours actual usage

The OpenHours RPC client is wired but the portal has no waiting-room route. Where, if anywhere, does the portal apply open-hours logic?

Sentry DSN config source

Where is the Sentry DSN sourced — branding-options, config, env? Is the /api/sentry server-side route the only egress, or does the browser hit Sentry directly when CSP allows?

Recovery token TTL / single-use

Is the password-recovery token single-use? What’s the TTL? Where is it enforced — vuer_oss, Redis, or DB?

Redis HA strategy

Sessions, customer-data cache, rate-limit counters are all in Redis. What’s the failure mode if Redis is unavailable? Does the portal degrade gracefully or 500 on every request?

Test coverage scope

test/ is currently sparse. Which areas are actually exercised? Is there a CI gate beyond the build?