import type { Store } from '../db/store.ts'; import { createAuthState, handleLogin, handleLogout, type AuthState, type TokenRegistry, } from '../handlers/auth.ts'; import { handleCommitEvent, type CommitEventCtx } from '../handlers/commitEvent.ts'; import { handleDataLogon, handleDataLogoff, handleMoreRows } from '../handlers/dataLogon.ts'; import { handleHeartbeat } from '../handlers/heartbeat.ts'; import { handleJsonSchemaRequest } from '../handlers/jsonSchema.ts'; import { handleMetaRequest } from '../handlers/meta.ts'; import { handleRequestReply } from '../handlers/requestReply.ts'; import { handleResourcesRequest } from '../handlers/resources.ts'; import type { GenesisMessage, MockServerConfig } from '../types.ts'; import type { Connection } from './connection.ts'; import { isLegacyFidelity } from './fidelity.ts'; import { MESSAGE_TYPE, ERROR_CODE, isCommitEvent, isRequestReply } from './messageTypes.ts'; import { missingMessageTypeNack, missingSourceRefNack, websocketForbiddenNack } from './msgNack.ts'; // Message types a connection may send before authenticating when the OPT-IN // auth gate (config.auth.requireAuth = true) is enabled. Everything else // (data subscriptions, request-replies, metadata, commit events) then // requires the connection to have logged in over WS — or to present a // SESSION_AUTH_TOKEN this server issued (over either transport). // RESOURCES_REQUEST is allowed pre-auth so resource discovery can't deadlock // a client that fetches it before (or while) logging in. MORE_ROWS, like // DATA_LOGOFF, can only act on a subscription the connection already opened // through the gate (logout clears them), so it answers with its own precise // 404 rather than a NOT_LOGGED_ON echo — except under fidelity: 'legacy', // which keeps the 15.47 gate (see isPreAuth). // // The gate is OFF by default because real clients can't be assumed to // satisfy it: browser frame captures show that after logging in over // foundation-auth's HTTP transport, the client sends NO SESSION_AUTH_TOKEN // on any WebSocket message (session storage's authToken is never populated // by that flow, so message.ts's stamp serializes to nothing) — a default-on // gate would reject every datasource with NOT_LOGGED_ON. Enable it only for // clients that log in over WS (or stamp tokens on WS traffic). const PRE_AUTH_MESSAGE_TYPES = new Set([ MESSAGE_TYPE.EVENT_LOGIN_AUTH, MESSAGE_TYPE.EVENT_LOGOUT, MESSAGE_TYPE.HEARTBEAT_PING, MESSAGE_TYPE.DATA_LOGOFF, MESSAGE_TYPE.RESOURCES_REQUEST, ]); function isPreAuth(messageType: string, config: MockServerConfig): boolean { if (messageType === MESSAGE_TYPE.MORE_ROWS) return !isLegacyFidelity(config); return PRE_AUTH_MESSAGE_TYPES.has(messageType); } function ensureAuthenticated( message: GenesisMessage, connection: Connection, tokens: TokenRegistry, ): boolean { if (connection.authenticated) return true; const token = message.SESSION_AUTH_TOKEN; const username = typeof token === 'string' ? tokens.get(token) : undefined; if (username !== undefined) { connection.authenticated = true; connection.userName = username; connection.sessionAuthToken = token; return true; } return false; } // Error reply for unauthenticated traffic, shaped per message family the same // way each handler's own error paths are (REP_* for REQ_*, *_NACK for events, // QUERY_UPDATE for DATA_LOGON, echoed MESSAGE_TYPE for META/JSON_SCHEMA). export function replyNotLoggedOn(message: GenesisMessage, connection: Connection): void { const type = message.MESSAGE_TYPE; const ERROR = [{ CODE: ERROR_CODE.NOT_LOGGED_ON, TEXT: 'Not logged on' }]; if (isRequestReply(type)) { connection.reply(message, { MESSAGE_TYPE: `REP_${type.replace(/^REQ_/, '')}`, ERROR }); return; } if (type === MESSAGE_TYPE.DATA_LOGON) { connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE, ERROR }); return; } if (isCommitEvent(type)) { // Generic, not `${type}_NACK` — see the note in messageTypes.ts. connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK, ERROR }); return; } connection.reply(message, { MESSAGE_TYPE: type, ERROR }); } // Kotlin's isNullOrBlank on GenesisSet.getString, which reads any value as text. const isBlank = (value: unknown) => value === undefined || value === null || String(value).trim() === ''; export function routeMessage( inbound: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store, ctx: CommitEventCtx, auth: AuthState = createAuthState(), ): void { let message = inbound; // A socket opened with a sessionAuthToken cookie (auth.cookieAuthentication) // is authenticated by it, so the router refuses to log it in or out, and // stamps the cookie's token on everything else, over whatever the client // sent (CookieHandler.kt:165-176 @v8.15.29). const cookieToken = connection.cookieSessionAuthToken; if (cookieToken !== undefined) { const type = message.MESSAGE_TYPE; if (type === MESSAGE_TYPE.EVENT_LOGIN_AUTH || type === MESSAGE_TYPE.EVENT_LOGOUT) { return connection.reply(message, websocketForbiddenNack(type), false); } message = { ...message, SESSION_AUTH_TOKEN: cookieToken }; } // GSF refuses a message with no SOURCE_REF or MESSAGE_TYPE, a heartbeat // included, before its client handler sees it (so LAST_SENT and // LAST_RECEIVED stay as they were); the 15.47 mock routed it anyway. if (!isLegacyFidelity(config)) { if (isBlank(message.SOURCE_REF)) return connection.send(missingSourceRefNack(), false); if (isBlank(message.MESSAGE_TYPE)) return connection.reply(message, missingMessageTypeNack(), false); } // Adopt token auth on every message (not just gated ones) so e.g. a // HEARTBEAT_PONG's IS_AUTHENTICATED reflects an HTTP-issued token straight // away; the gate itself only applies to data traffic. ensureAuthenticated(message, connection, auth.tokens); if (message.MESSAGE_TYPE !== MESSAGE_TYPE.HEARTBEAT_PING) connection.noteReceived(message); const requireAuth = config.auth?.requireAuth ?? false; if (requireAuth && !isPreAuth(message.MESSAGE_TYPE, config) && !connection.authenticated) { return replyNotLoggedOn(message, connection); } switch (message.MESSAGE_TYPE) { case MESSAGE_TYPE.EVENT_LOGIN_AUTH: return handleLogin(message, connection, config, auth, store, ctx.broadcastTableChange); case MESSAGE_TYPE.EVENT_LOGOUT: return handleLogout(message, connection, config, auth, store, ctx.broadcastTableChange); case MESSAGE_TYPE.HEARTBEAT_PING: return handleHeartbeat(message, connection, config, auth); case MESSAGE_TYPE.DATA_LOGON: handleDataLogon(message, connection, config, store); return connection.noteLogon(message); case MESSAGE_TYPE.DATA_LOGOFF: return handleDataLogoff(message, connection, config); case MESSAGE_TYPE.MORE_ROWS: return handleMoreRows(message, connection, config); case MESSAGE_TYPE.META_REQUEST: return handleMetaRequest(message, connection, config, store); case MESSAGE_TYPE.JSON_SCHEMA_REQUEST: return handleJsonSchemaRequest(message, connection, config, store); case MESSAGE_TYPE.RESOURCES_REQUEST: return handleResourcesRequest(message, connection, config); default: if (isRequestReply(message.MESSAGE_TYPE)) return handleRequestReply(message, connection, config, store); if (isCommitEvent(message.MESSAGE_TYPE)) return handleCommitEvent(message, connection, config, store, ctx); console.warn(`[mock-server] Unhandled MESSAGE_TYPE: ${message.MESSAGE_TYPE}`); } }