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_cssis the full waiting-room / videochat / self-service / Janus relay surface,portal_cssis 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/videochatroute only exists as a thin redirect / lobby stub.
One-line summary
| Property | Value |
|---|---|
| Repo | github.com/TechTeamer/portal_css |
| Local path | /Users/levander/coding/facekom/portal_css |
| Default branch | devel |
| External port | 30380 (k8s NodePort / nginx) |
| Internal HTTP port | 10383 |
| Internal Socket.IO port | 10382 |
| Container manifest | portal-css.yml (see infrastructure) |
| JIRA prefixes | FKITDEV-XXXX, FKQA-XXX |
| Runtime | Node.js >= 22.18.0 |
| Framework | Express 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-cssqueue, noRoomTransportSession, noSelfServiceTransportSession, noEchoTransportSession). - 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
CompatibilityServiceshell 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_ossreturns 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
| Layer | Technology |
|---|---|
| Runtime | Node.js >= 22.18.0 |
| Web | Express 5.1, Socket.IO 4 |
| UI engine | React 18 + custom MVC engine (client/engine/*) inherited from vuer_css |
| Templating | Twig 3 (engines/twig/render-server.js) |
| Styling | Stylus → CleanCSS |
| Build | esbuild (page bundles) + Browserify (React externals) + Stylus compiler |
| Sessions | Redis (async-redis + connect-redis), with iOS 12 sameSite workaround |
| Messaging | @techteamer/mq v7 (RabbitMQ AMQPS) |
| Auth | csurf (deprecated), helmet, cookie-parser, express-session, jsonwebtoken |
| Logging | log4js channels (portal, express, session, csp) + appenders (console / file / syslog / papertrail) |
| Observability | @sentry/browser on the client + /api/sentry collector route on the server |
| TypeScript | Toolchain 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. Mergingdevelinto 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/UTCforcing, same self-signed cert bypass viasettings.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:
| Primitive | Method | Semantics |
|---|---|---|
| Hooks | addHook(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. | |
| Overrides | registerOverride(name, fn) / callOverride(name, args, defaultFn) | Last argument is the default implementation; if a customization registered an override, it runs instead. Used for replacement. |
| Events | Native WildEmitter | Standard 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:
| Hook | Fired by | What customizations can do |
|---|---|---|
queue | setupQueue | Add RPC clients, queue clients, queue servers |
services | setupServices | Register additional services on the container |
middlewares | WebServer.setupRoutes (early) | Insert middleware into the stack |
routes | WebServer.setupRoutes | Add bespoke routes |
socket | socket-server.js | Add Socket.IO event modules |
template:custom-content:${page} | Template.render() | Inject per-page template variables |
webrtc:hd-constraints | Template.render() | Override WebRTC constraint payload |
config:socketio-settings | Template.render() | Override Socket.IO client settings |
customer:login | submit-login / submit-token | Run after a successful login |
customer:registration | submit-registration | Run after a successful registration |
customer:data-change | submit-personal-data | Run after personal-data change |
Overrides used in routes:
routes:landing— override theGET /handler.routes:videochat— override theGET /videochathandler.
callOnlyHook foot-gun
If two listeners both
addHook('foo', ...), thencallOnlyHook('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)
- Host header validation — drops requests where
req.get('host')is not inconfig.hosts. - CSP nonce —
crypto.randomUUID()per request, attached tores.locals.cspNonce. - UA parse —
ua-parser-js; populatesreq.browserwithisIOS,isChrome,isSafari, etc. - Helmet — security headers, configurable.
- No-cache — adds
Cache-Control: no-store, no-cache, must-revalidate. - CSP — per-path CSP rules, uses the per-request nonce.
- Body parsers —
urlencoded+json(25 MB) + dedicated CSP-report JSON parser. - Cookie parser.
- Locale middleware — cookie + querystring locale override.
- Redis session middleware — connect-redis store, iOS 12 sameSite=false workaround.
- Twig template engine — registered via
setupTwig(). - csurf — CSRF protection cookie-based.
- Routes —
setupRoutes(). - CSP report endpoint — collects violations.
- 405 handler.
- 404 handler.
- 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: falseoverride). - Templates (passed to client via
data-browser-infoattribute).
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 → routerrenameExpress 5 renamed the internal property
app._routertoapp.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 forapp._router, rewrite toapp.router. Same gotcha applies to any custom branch that reads the internal router.
Setup helpers
| Method | Purpose |
|---|---|
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
| Route | Method | Purpose |
|---|---|---|
/ | GET | Landing page; if a JWT is present, performs customerAuthWithToken and may redirect |
/videochat | GET | Videochat lobby stub |
/login | GET | Login page |
/api/submit-login | POST | First step of two-phase login (returns {require: 'strong_authentication'} when SCA needed) |
/api/submit-token | POST | Second step — verifies SMS / email token |
/api/submit-resend-token | POST | Re-sends the SCA token |
/password-change | GET | Password change form (logged-in customer) |
/api/submit-password-change | POST | Change password |
/password-recovery/:token?/:lang? | GET | Password recovery (with optional token deep-link) |
/api/submit-password-recovery | POST | Initiate password recovery email |
/password-reset | GET | Password reset form |
/api/submit-password-reset | POST | Set new password from recovery token |
/logout | GET | Destroy session, redirect to landing |
/registration | GET | Registration form |
/api/submit-registration | POST | Submit registration |
/personal-data | GET | Personal data change form |
/api/submit-personal-data | POST | Submit personal data update |
/ui-kit | GET | UI kit demo (dev only) |
/api/sentry | POST | Sentry 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-appointmentdocumented under security-audit is not present inportal_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.
| Method | Purpose |
|---|---|
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.
| Method | Purpose |
|---|---|
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.
| Property | Value |
|---|---|
| Key | customer-data:${customerId} |
| Default TTL | 600 s (10 min) |
| Invalidation flag | req.session.forceRedisCache |
| Method | Purpose |
|---|---|
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.
| Method | Purpose |
|---|---|
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; supportsdictionaryOverrides).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 / function | Purpose |
|---|---|
timestamp | Formats epoch ms to a locale string. |
filesize | Formats byte counts (kB / MB / GB). |
documentType | Maps internal document type code to localized label. |
nameOrder (function) | Locale-aware family-name / given-name ordering. |
Translation system
Same as vuer_css —
engines/translator/Dictionary.jsloads*.trans.jsmodules, which are functions that calldict.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
| Property | Value |
|---|---|
| Algorithm | JWT (jsonwebtoken) |
| Secret | config.jwt.secret (shared) |
| Expiry | 2 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.secretfor 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 name | Class | Purpose |
|---|---|---|
rpc-vuer-portal | PortalRPCClient | The big one — see method table below |
rpc-customer-portal-data | PortalDataRPCClient | Per-customer portal data (display fields, branding) |
rpc-jwt-auth | JwtAuthRPCClient | JWT authentication |
rpc-get-customer | GetCustomerRPCClient | Customer record lookup |
rpc-openhours | OpenHoursRPCClient | Open hours (used to gate live-call links) |
rpc-openhours-calendars | OpenHoursCalendarsRPCClient | Calendar selection |
background-room-export | BackgroundRoomExportRPCClient | Room data export — kept from CSS |
RPC servers (portal receives)
| Queue name | Class | Purpose |
|---|---|---|
rpc-portal-vuer | PortalRPCServer | Receives commands from vuer_oss directed at the portal layer |
PortalRPCClient methods
The single most important RPC client in the project.
| Method | Returns / 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:
| Layer | Examples |
|---|---|
| Queue / RPC clients | Custom customization/server/queue/rpc_client/*.js registered via 'queue' hook |
| Services | Custom service classes registered via 'services' hook |
| Middleware | Inserted via 'middlewares' hook |
| Routes | Inserted via 'routes' hook; existing routes overridden via registerOverride('routes:landing', fn) etc. |
| Listeners | customization/listeners/<name>.js plugged into hook bus |
| UI pages | customization/ui/pages/<page>/... overrides — instacash uses ic-* prefix (e.g. ic-landing, ic-login) |
| Branding | customization/ui/branding/colors.styl, font.styl, asset swaps under branding/ |
| Translations | customization/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, orserver.jsis 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.jsonwithnoEmit: true,erasableSyntaxOnly: true, CommonJS target,includecoveringserver/**/*.ts,client/**/*.ts,customization/**/*.ts.babel.config.jsincludes@babel/preset-typescript.- ESLint integration via
@typescript-eslint/eslint-plugin. - Jest configured to use
babel-jestfor.tstransforms.
Constraints (from erasableSyntaxOnly):
| Forbidden | Reason |
|---|---|
enum | Not 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 semantics | Not 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.tsto.jsat runtime — purely erasure.tsc --noEmitenforces type correctness againsttsconfig.json.
The split lets us start adopting .ts incrementally without touching the runtime pipeline.
15. Recent activity (latest 5 commits on devel)
| Commit | Subject |
|---|---|
019f1705 | chore(FKQA-321): maintenance/devel (#690) |
e1fcbd57 | fix: fix express protected router property on WebServer (#689) — Express 5 _router → router rename fix |
70983d3e | chore(FKQA-320): twig update (#687) |
47616db7 | BREAKING(FKITDEV-8538): Portal css TS support (#683) |
50150d20 | chore(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 -> routerrenameAlready broke
WebServer.findRoute()twice (PR #670 and PR #689 / commite1fcbd57). Any new code that readsapp._routerwill break under Express 5 — useapp.routerinstead.
iOS 12 mobile Safari session cookie
setupSession()monkey-patchessessionStore.generateto overridesameSitetofalsefor iOS 12. Without this, the session cookie is dropped silently on first request. Same workaround as vuer_css.
CSRF token storage
csurfis 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 seedsreq.session.temporaryCustomerId+req.session.tokenSentAt. The customer is not yet logged in at this point. The second POST validatesauthTokenand only then setsreq.session.customerId. Don’t readcustomerIdfrom 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.jsfiles viaengines/translator/Dictionary.js— not standard i18next / format.js. Don’t reach for community Twig i18n filters.
csurfdeprecated upstreamThe 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
enandhutranslations. 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?
/videochatrouteWhat does the stub actually render in
devel? Is there any production deployment that uses it, or is it kept solely as the override target forroutes:videochat?
Socket auth full flow
Does the portal use Socket.IO at all in production today? (No videochat, no waiting room.) If not, the
socketTokenmachinery 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/sendSmsTemplatecallsvuer_oss. Whichvuer_ossservice 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/sentryserver-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?
18. Related
- FaceKom — platform overview
- vuer_css — upstream sibling (near-identical engine)
- vuer_oss — sole business-logic source via RabbitMQ
- vuer_cv — computer vision service
- vuer_docker — container manifests
- infrastructure — port
30380, containerportal-css.yml - rabbitmq-communication — RPC topology and queue ACLs
- security-audit — security findings (some apply, e.g. shared JWT secret)
- tech-debt —
csurfmodernization, in-memory IpFilter - authentication — JWT / SCA flows
- customization-branches — per-customer branch inventory
- breakage-risks — merge-conflict hotspots between
develand customization branches