// Shared types for the mock-server protocol engine. Deliberately loose where // a real Genesis payload's shape is "whatever the project defines" (row data, // event DETAILS) — Record there is the honest type, not a cop-out. import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Store } from './db/store.ts'; import type { NackErrorInput } from './protocol/errors.ts'; export type Row = Record; export interface GenesisMessage { MESSAGE_TYPE: string; SOURCE_REF?: string; DETAILS?: Row; [key: string]: any; } // The Genesis dictionary field types (global.genesis.dictionary.Field.Type). export type GenesisFieldType = | 'STRING' | 'ENUM' | 'INT' | 'SHORT' | 'LONG' | 'DOUBLE' | 'BIGDECIMAL' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'NANO_TIMESTAMP' | 'RAW'; // One column of a table, as the project's tables dictionary declares it // (`field("NAME", STRING(100)).notNull()`). META_REQUEST and // JSON_SCHEMA_REQUEST are built from these when a table declares them; see the // README's "Declared schema". export interface FieldDef { name: string; type: GenesisFieldType | NestedFieldType; // Default true, as in the dictionary (`.notNull()` makes it false). Primary // key and generated fields default to false. nullable?: boolean; // ENUM values, in dictionary order. values?: string[]; // The dictionary `.default(...)`. A non-null field with a default is // optional in event schemas. default?: unknown; // STRING column size (`STRING(100)`): MAX_LENGTH / maxLength. maxSize?: number; // A BIGDECIMAL column's scale (`BIGDECIMAL(20, 2)` in the dictionary): // values are rounded HALF_UP to it when written and sent as strings with // exactly that many decimals ("15.00000"). Default 5, GSF's SQL default // (BigDecimalSqlPrecisionConfig). A view's derived BIGDECIMAL has no column // and goes out with its own decimals unless this says otherwise. See the // README's "Value encoding". scale?: number; // `metadata { minLength = 0 }`: MIN_LENGTH / minLength. minLength?: number; // An INT, SHORT, LONG or DOUBLE column's `metadata { min = 0; max = 100 }`: // MIN / MAX in event META and minimum / maximum in the event's JSON schema, // which an inbound event is validated against. Unset: the type's own range. min?: number; max?: number; // Replaces the DESCRIPTION / description the real server fills with the // field's JVM class (kotlin.String, org.joda.time.DateTime, ...). Leave it // unset for DATE/DATETIME: foundation-forms recognises dates partly by that // class name. description?: string; // Defaults to the name in title case ('COUNTERPARTY_ID' -> 'Counterparty Id'). title?: string; // Whether an event payload may leave it out (OPTIONAL, and not in the JSON // schema's `required`). The real server makes a field optional only when // its class property has a default. Unset: a table field is optional when // it is nullable, generated or has a default (the generated DAO defaults // all of those); a custom DTO field only when it has a `default` — set // `optional: true` for a Kotlin default the metadata can't show, such as // `val enabled: Boolean? = true`. optional?: boolean; // A DTO field (EventSchemaOverride.fields) that holds more than one value: // an ARRAY's items or a MAP's values (unset: Any), and whether the ARRAY is // a Kotlin Set (`unique`) rather than a List; an OBJECT's class name and its // own fields, declared as here. Set and List items, map values and nested // classes are described and checked as GSF does (src/protocol/dtoTypes.ts). items?: FieldTypeDef; unique?: boolean; className?: string; fields?: FieldDef[]; // An ENUM's class, for a DTO field whose enum isn't a generated one // (ENUM_CLASS and DESCRIPTION; default: the generated DAO enum's name). enumClass?: string; // A DTO field that comes from a DAO the class unwraps (@JsonUnwrapped): // described with the DAO's annotations, as a table event's fields are // (TITLE, lengths, ranges, GENESIS_TYPE), and checked against them. dao?: boolean; } // A DTO field's type when it isn't a single value: a Kotlin List or Set, a // Map (string keys), a nested class, or Any. export type NestedFieldType = 'ARRAY' | 'MAP' | 'OBJECT' | 'ANY'; // The type of an ARRAY's items or a MAP's values. export type FieldTypeDef = Omit; // A field the database fills on insert when the record leaves it out (a // supplied value is kept). Metadata marks it NULLABLE:false, OPTIONAL:true // and leaves it out of `required`. A SEQUENCE mints values like the key's // sequence (`prefix`, else the table's sequencePrefix, in its // sequenceFormat); an AUTO_INCREMENT counts up from the highest seeded value // (1 when there is none). A table with `sequencePrefix` gets its key // generated as a SEQUENCE without declaring it here. export interface GeneratedFieldDef { field: string; kind: 'SEQUENCE' | 'AUTO_INCREMENT'; prefix?: string; } // The verbs of the generic CRUD events (see TableDef.events). export type CrudEventVerb = 'INSERT' | 'MODIFY' | 'DELETE'; // A table or dataserver-query index (`indices { unique("A") }`). FIELDS is // sent space-separated (`'A B'`). export interface IndexDef { name: string; fields: string[]; unique?: boolean; } export interface TableDef { // A single field, or several for composite-keyed tables (e.g. PRICE keyed // by [RFQ_ID, SIDE]). Composite rows are identified by the fields' values // joined with '|' (see Store.rowKey) — no synthetic key column needed, and // nothing extra shows up in grids. Field values therefore must not contain // '|' themselves. Sequence generation requires a single pkField. pkField: string | string[]; sequencePrefix?: string; sequenceWidth?: number; // How generated pk values look. See SequenceFormat. sequenceFormat?: SequenceFormat; rows?: Row[]; // Declared columns, in dictionary order. When present they are the schema: // metadata, JSON schemas and dataserver rows follow them (missing values go // out as null), even with no rows at all. When absent, field metadata is // inferred from the rows (src/db/metadata.ts). fields?: FieldDef[]; generated?: GeneratedFieldDef[]; // Table indices. The generic CRUD events NACK DUPLICATE_KEY on a unique // one, and a unique index over exactly the key fields names the primary // key (else '_BY_'). A dataserver query only reports the // indices on its own QueryDef.indexes, as the real dataserver does. indexes?: IndexDef[]; // Registers generic CRUD event handlers for this table — // EVENT_
_INSERT, EVENT_
_MODIFY, EVENT_
_DELETE — the // ones Genesis Create generates (entityDb.insert / modify / delete, no // onValidate): insert the record (generating sequence and auto-increment // fields, GENERATED listing them all; DUPLICATE_KEY on a taken key), modify // or delete it by its primary key (RECORD_NOT_FOUND), ack VALIDATE without // writing, push the change live. They are listed in RESOURCES and described // by META_REQUEST / JSON_SCHEMA_REQUEST like any registered event. An // eventHandlers entry of the same name wins. See the README's "Generic CRUD // events". events?: CrudEventVerb[]; // `audit = details(…, sequence = "XA")` in the dictionary: the table gets a //
_AUDIT table (its fields,
_AUDIT_ID from the audit sequence, // AUDIT_EVENT_TYPE / _DATETIME / _TEXT / _USER), and every write an event // handler makes to it adds a row there. Serve it with a requestReplies entry // like any table (`requestFields` the table's key, for GSF's // BY_UNDERLYING_ID). See the README's "Audit tables". audit?: boolean | AuditDef; } export interface AuditDef { // The audit sequence (`sequence = "XA"`), for
_AUDIT_ID. Default 'AU'. sequencePrefix?: string; } // 'prefix' (the default): sequencePrefix + a counter padded to sequenceWidth // ('TR001'). 'genesis': the platform's id format (genesis-db IdStrategy.java) // — the counter padded to 15 digits, the sequence id (sequencePrefix, two // characters in a GSF dictionary), then location 'LO' and system '1': // '000000000000001TRLO1'. export type SequenceFormat = 'prefix' | 'genesis'; // A join's key: `[sourceField, joinedField]` — the field of the row the join // starts from that must equal the field of the joined table (GPAL // `on(TRADE.COUNTERPARTY_ID to COUNTERPARTY { COUNTERPARTY_ID })`). With the // join's `fromAlias`, the source is that table's row; without one, the view // row built so far — the base row's columns, overlaid by the selects of the // joins before — so a join can also key on an earlier join's column, as // chained joins were written before `fromAlias`. export type JoinFieldLink = [string, string]; // The same as an object, whose source may be any table joined before this // one (GPAL `.and(TRADE { INSTRUMENT_ID } to IP { BID_PRICE })` inside a // join nested under INSTRUMENT). `fromAlias` defaults to the join's own. export interface JoinAliasedFieldLink { fromAlias?: string; from: string; to: string; } // A joined-table field that must equal a constant (GPAL // `and(ALT_INSTRUMENT_ID.ALTERNATE_TYPE to "BLOOMBERG")`), compared with ===: // give it the column's own type (a number for an INT, not "1"). export interface JoinValueLink { to: string; value: unknown; } export type JoinLink = JoinFieldLink | JoinAliasedFieldLink | JoinValueLink; // INNER drops a view row when the join finds no match; OUTER (GSF's default) // keeps it, with the join's columns null. export type JoinType = 'INNER' | 'OUTER'; // What a `where` predicate and a derived field see beside the rows: every // table of the view row by its alias (the base table's, then each join's // joined so far), undefined for an OUTER join that found no match. export interface ViewRowSources { tables: Record; } // One `joining(...)` of a GSF view. See the README's "Views: the join model". export interface JoinDef { table: string; // The table's name inside this view (GPAL `TABLE withAlias "ALIAS"`): what // a nested join's `fromAlias` and the derived fields' `tables` call it. // Defaults to `table`. A table joined twice, or the base table joined to // itself, needs one to be read by name: without one, a join whose table // name is taken still joins, but goes by no name (as before aliases). alias?: string; // The table the join starts from: the base table (the default) or an // earlier join's alias — a nested join (GPAL `.joining(...)` inside a join). fromAlias?: string; // One key pair, or several (GPAL `on(...).and(...)`): field pairs, field // links from another table of the view, constants. All of them must hold. on: JoinFieldLink | JoinLink[]; // Default 'OUTER', as in GSF. type?: JoinType; // GSF's `backwardsJoin = true`: a change to this join's table is pushed to // subscribed views. Without it (GSF's default) only changes to the base // table are, and the client sees a joined table's new values when the // view row's base row next changes. Ignored under fidelity: 'legacy', // where every join's changes are pushed. backwardsJoin?: boolean; // The view columns this join adds: { [viewField]: joinedTableField } — // `NAME withAlias "COUNTERPARTY_NAME"` is { COUNTERPARTY_NAME: 'NAME' }, // `NAME withPrefix "CP"` is { CP_NAME: 'NAME' }, a plain `INSTRUMENT.NAME` // is { NAME: 'NAME' }. select?: Record; // Extra join predicate beyond `on` (not a GSF construct: GSF expresses // these as constant links) — receives the candidate join row, the view row // so far (the base row's columns and the selects of the joins before this // one) and the tables joined so far, e.g. side-specific joins like // (price, rfq) => price.SIDE === 'SALES'. The first candidate (in table // order) that passes is the match. where?: (joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean; } // Keeps only base rows with NO match in `table` (e.g. USER_WITHOUT_DETAIL: // users lacking a USER_DETAIL row). `on` and `where` match as on a join // starting from the base table. Not a GSF view construct: a change to the // anti-joined table is always pushed. export interface AntiJoinDef { table: string; on: JoinFieldLink | JoinLink[]; where?: (joinRow: Row, baseRow: Row, sources: ViewRowSources) => boolean; } // A GSF view (`view("NAME", TABLE) { joins { } fields { } }`). See the // README's "Views: the join model". export interface ViewDef { base: string; // The base table's name inside this view, for `fromAlias` and the derived // fields' `tables`. Defaults to `base`. baseAlias?: string; pkField?: string | string[]; // Applied in order: a join can start from an earlier one (`fromAlias`). joins?: JoinDef[]; antiJoins?: AntiJoinDef[]; // Aliased base-table columns, { [viewField]: baseField } (GPAL // `TRADE.PRICE withAlias "TRADE_PRICE"`), added beside the base columns. // List the view's `fields` to expose only the aliased name. select?: Record; // Computed columns. `row` is the view row before its derived fields (base // columns and every join's select); `tables` holds each table's source row // by alias, for inputs the view doesn't expose (GPAL `withInput`). derived?: Record any>; // Types of the `derived` fields (`derivedField("TOTAL", DOUBLE)`): a type, // or a full field definition without its name. Columns from the base table // and from `joins[].select` take their declared types from their source // tables; a derived field with no entry here is inferred from the rows. derivedTypes?: Record>; // The view's fields { } block: the only columns it exposes, in this order // (base, joined and derived alike). Unset: every base column, then the // base `select`, then each join's select, then the derived fields. Keep the // key fields in it — they are the ROW_REF. fields?: string[]; } // The authenticated identity a resource-level auth hook sees. userName is // undefined for unauthenticated connections (possible when requireAuth is // off, or pre-login). export interface RequestingUser { userName?: string; profile: string[]; permission: string[]; } // Per-resource permissioning, mirroring what dataserver auth maps and // hideFields do on the real server: rowFilter drops rows the user must not // see (applied to snapshots, live pushes, and REQ_ replies); hideFields // strips columns from rows AND from META_REQUEST field metadata, so e.g. // sales users never see the trader side of mirrored columns. export interface ResourceAuthDef { rowFilter?: (row: Row, user: RequestingUser) => boolean; hideFields?: string[] | ((user: RequestingUser) => string[]); } export interface QueryDef { source: string; // Overrides the TYPE of named fields, declared or inferred. fieldTypes?: Record; // The query's `fields { }` block: the only columns it exposes, in this // order, in rows and in META_REQUEST. Names the source doesn't have are // dropped from metadata. RECORD_ID and TIMESTAMP reach dataserver rows only // when named here. fields?: string[]; // The query's `indices { }` block, reported as META_REQUEST INDEXES. With // none, INDEXES is the default [{NAME: null, FIELDS: 'RECORD_ID'}]. indexes?: IndexDef[]; // A dataserver GSF registers in code on its older engine (dataserver2, // e.g. StatusDataserver's ALL_PROCESSES_STATUS): its META has no INDEXES. legacyEngine?: boolean; // Server-side where-clause equivalent, applied before any client // CRITERIA_MATCH — to both the DATA_LOGON snapshot and live pushes. Use it // for things the real query's filter/where clause does (e.g. keep template // rows out of ALL_QTE_MAIN_ENTRIES, CANCELLED out of the blotter). filter?: (row: Row) => boolean; auth?: ResourceAuthDef; } // Details a request-reply resolver receives: the keyed REQUEST fields, the // full message, the requesting user, and the live store. export interface RequestReplyResolverCtx { request: Row; message: GenesisMessage; user: RequestingUser; store: Store; // The server's whole config, as it is when the request comes: a reply that // depends on what else the server serves (reporting's // ALL_REPORT_DATASOURCES lists its request servers) reads it here. config: MockServerConfig; } export interface RequestReplyDef { // Either serve rows from a table/view (`source`, optionally narrowed by // `filter`/REQUEST fields/CRITERIA_MATCH), or compute the reply yourself // (`resolver`) — for request servers that calculate rather than read (date // calculations, holiday validate/roll, premium schedules, composite // lookups). A resolver receives the REQUEST fields and returns the REPLY // rows verbatim; throw NackError for a protocol-correct error reply (a // MSG_NACK). When both are set, resolver wins. With resolver only, set // `metadataRows` (a representative sample reply) or `fieldTypes` so // META_REQUEST can still derive REPLY_FIELD metadata. source?: string; resolver?: (ctx: RequestReplyResolverCtx) => Row[]; metadataRows?: Row[]; fieldTypes?: Record; // The request server's `filter { }` / `where { }` clause. With `filter` or // `auth`, a criteria-only server reads past the rows they drop, and // NEXT_OFFSET says where it stopped (README: Request servers). filter?: (row: Row) => boolean; auth?: ResourceAuthDef; // `criteriaOnlyRequest = true` in the request server's config. REQUEST must // then be present (an object; its fields are ignored), DETAILS.OFFSET pages, // DETAILS.ORDER_BY sorts by fields, and every REP_ carries MORE_ROWS (plus // NEXT_OFFSET while there are more). META_REQUEST reports REQUEST_FIELD: [], // CRITERIA_ONLY_REQUEST: true, SORTABLE_FIELDS and CRITERIA_FIELDS. Without // it, REQUEST's fields select rows (exact values, `*` wildcards, // FIELD_FROM/FIELD_TO ranges; an array runs each) and OFFSET is ignored. // See the README's "Request servers". criteriaOnly?: boolean; // The request block's fields (REQUEST_FIELD, and the JSON schema's REQUEST). // Defaults to the source's primary key, as for a GPAL requestReply(TABLE). requestFields?: string[]; // The reply block (`reply { }` in GPAL): REP_ rows carry exactly these // columns, in this order, null when unset, and so do REPLY_FIELD and the JSON // schema's REPLY. Names a declared source doesn't have are dropped. // RECORD_ID and TIMESTAMP are sent only when named here. Unset: every column // of the source followed by RECORD_ID and TIMESTAMP, as GSF sends a table // entity. Ignored with a resolver. fields?: string[]; // `config { maxRows = N }`: the most rows a REP_ carries when the request // names no DETAILS.MAX_ROWS. Default 10000 (RequestReply.DEFAULT_MAXIMUM_ROWS). rowLimit?: number; // The order a `source` server's rows come in, when it isn't the primary // key's: a Kotlin reply that reads an index, or reads it backwards // (getRangeFromEnd). Ignored with a resolver or criteriaOnly. order?: (a: Row, b: Row) => number; // A typed request server, `requestReply`, whose Kotlin builds the // reply (with a `resolver`): META describes its classes as GSF's // CustomReqRep does, and its replies are capped and typed as GSF's are // (see TypedRequestReply). typed?: TypedRequestReply; } // A typed request server's request and reply classes: META answers in the // GPAL shape (DETAILS.FIELD for the request class, DETAILS.REPLY for the // reply class, each field described as an event DTO's is), its REP_ carries // PARAMETRIC_TYPE (the reply class), CRITERIA_MATCH filters the reply rows, // and at most DETAILS.MAX_ROWS of them go out (default `rowLimit`, else // 10000), with no paging (AbstractCustomReqRep.kt @v8.15.29). The fields are // DTO fields, as an event schema's, listed in the order GSF reports them: // by name, but for an unwrapped class's. export interface TypedRequestReply { request: { className: string; fields: FieldDef[] }; reply: { className: string; fields: FieldDef[] }; // The META DETAILS a real server sends, as it is, for classes these fields // can't describe whole (a sealed class's subtypes and their constants, a // DAO's annotations): the fields then only describe the JSON schema. meta?: Row; } // How META_REQUEST / JSON_SCHEMA_REQUEST describe an event whose name doesn't // follow EVENT_
_: the table (or view) its DETAILS take, and // whether only the key fields are sent (a delete). export interface EventSchemaOverride { table?: string; pkOnly?: boolean; fieldTypes?: Record; // The event's own DETAILS class, for an event that takes a custom DTO // (`eventHandler`) rather than a table row. Described as the real // server describes a non-table class: no titles, lengths, ranges or // genesis types; `nullable`, `default` and `optional` as declared. A // nullable field is still mandatory unless it has a default or // `optional: true` (Kotlin `val x: T?` must be sent; `val x: T? = null` // needn't be). `table` then only names where its ENUM columns come from. fields?: FieldDef[]; // The DTO's class name, reported as the event's DESCRIPTION. className?: string; } export interface ResourceItem { RESOURCE_NAME: string; RESOURCE_TYPE: 'DATASERVER' | 'REQUEST_SERVER' | 'EVENT_HANDLER'; } export interface AuthUser { username: string; password?: string; profile?: string[]; permission?: string[]; // The user's USER record. Its FIRST_NAME and LAST_NAME are the login ack's // USER_DETAILS (null when unset). Under fidelity: 'legacy' the whole record // is sent as the ack's USER instead. user?: Row; // The login ack's USER: what an auth-preferences `loginAck { }` block adds // per user. GSF sends USER: null without one, and so does the mock when // this is unset. Not used under fidelity: 'legacy'. loginAck?: Row; // USER.STATUS, which a login checks as auth's does: DISABLED fails as a // wrong password does (INCORRECT_CREDENTIALS), PASSWORD_EXPIRED and // PASSWORD_RESET answer PASSWORD_EXPIRED whatever the password, and any // other status but ENABLED LOCKED_ACCOUNT once the password is right. // Unset: ENABLED. status?: string; } // Where logins find their users instead of AuthConfig.users, and how they // decide and record one: the platform preset's reads auth's USER, // PROFILE_USER and RIGHT_SUMMARY tables and writes what auth's login writes // (USER_LOGIN_AUDIT, the lockout counter, USER's ONLINE and LAST_LOGIN), so a // user an event adds, disables, re-profiles or gives a new password logs in // as the store now says, as on the real server. export interface AuthDirectory { // The user a session names, as the store holds them now (their profiles, // rights and USER_DETAILS); undefined when there's none. A silent login // (event-login-details) and every token check read it. user(username: string, store: Store): AuthUser | undefined; // A credentials login (EVENT_LOGIN_AUTH with a USER_NAME and a PASSWORD): // the user, or why it fails. `password` is undefined for a login no // authenticator takes (no PASSWORD or no USER_NAME, and no refresh token), // which fails NO_COMPATIBLE_PROVIDERS whatever this answers: record it. login(username: string, password: string | undefined, ctx: LoginCtx): AuthUser | LoginFailure; // A refresh-token login as the session's user; undefined when no session // has the token. refresh(username: string | undefined, ctx: LoginCtx): AuthUser | LoginFailure; // A session's logout. `online`: whether the user still has another. logout(username: string, online: boolean, ctx: LoginCtx): void; } // Why a login fails: auth's AuthFailure. export type LoginFailure = | 'INCORRECT_CREDENTIALS' | 'PASSWORD_EXPIRED' | 'LOCKED_ACCOUNT' | 'MAX_TRIES_REACHED' | 'UNKNOWN_ACCOUNT' | 'NO_COMPATIBLE_PROVIDERS'; // What a login runs with. export interface LoginCtx { store: Store; broadcastTableChange: BroadcastTableChangeFn; // DETAILS.HOSTNAME as GSF's router sets it on a login or logout: the // client's HOSTNAME header, else its address ("/127.0.0.1"). hostname: string; // The message's own USER_NAME, whom what the login writes is audited as; // null without one. messageUser: string | null; } // GSF's router `cookieAuthentication { }` block (genesis-router // CookieAuthenticationDefinition). An unset field takes the value from the // block GSF ships in genesis-router.kts: httpOnly true, secure false, // sameSite 'Lax', path '/'. export interface CookieAuthenticationOptions { httpOnly?: boolean; secure?: boolean; sameSite?: 'Strict' | 'Lax' | 'None'; path?: string; domain?: string; // true: no Max-Age, so the browser drops the cookies when it closes. sessionCookies?: boolean; } // A login ack DETAILS.PRODUCT entry: an installed product and its version. export interface ProductDetails { NAME: string; VERSION: string; } // Entry served from GET gwf/sso/list, matching foundation-auth's // IDENTITY_PROVIDER DTO (services/idp.ts): { ID, DESCRIPTION, TYPE }. export interface SsoIdentityProvider { ID: string; DESCRIPTION?: string; TYPE?: string; [key: string]: any; } export interface AuthConfig { // The users who can log in. With a `directory`, logins find users there // instead, and this only names the user `httpAutoLogin: true` logs in as. users?: AuthUser[]; directory?: AuthDirectory; // Served from GET gwf/sso/list as { IDPS: [...] }. foundation-auth's login // screen fetches this whenever sso.toggled is on, shows a red "Failed to // fetch identity providers" AND hides the credentials form if the fetch // fails, and rejects an empty list too ("Identity provider list empty") — // so when unset, the mock serves one non-functional placeholder provider // to keep the login screen rendering. Clicking it navigates to the real // SSO login URL template, which the mock does not implement. ssoIdentityProviders?: SsoIdentityProvider[]; // auth-preferences heartbeatIntervalSecs: the login ack's // DETAILS.HEARTBEAT_INTERVAL_SECONDS. Unset: 0, as GSF sends without one // (30 under fidelity: 'legacy', which sends it as HEARTBEAT_INTERVAL_SECS). heartbeatIntervalSecs?: number; // auth-preferences sessionTimeoutMins (GSF default 30): the ack's // DETAILS.SESSION_TIMEOUT_MINS and the sessionAuthToken cookie's Max-Age. sessionTimeoutMins?: number; // auth-preferences refreshTokenExpirationMins (GSF default 7200): the ack's // DETAILS.REFRESH_TOKEN_EXPIRATION_MINS and the refreshAuthToken and // sessionId cookies' Max-Age. refreshTokenExpirationMins?: number; // The login ack's DETAILS.PRODUCT: the products the server runs. Unset: // auth 8.15.5 and genesis 8.15.29, the versions this mock follows. products?: ProductDetails[]; // The router's cookie authentication. ON by default, as GSF's // genesis-router.kts ships it (`cookieAuthentication { enabled = true }`); // `false` opts out, and fidelity: 'legacy' defaults it off. When on: // - an HTTP login sets the sessionAuthToken, refreshAuthToken, sessionId // and mfaCode cookies, a failed one clears refreshAuthToken, and logout // clears all four; // - the HTTP endpoints read the session from the sessionAuthToken cookie; // - a WebSocket upgrade without a live sessionAuthToken cookie gets HTTP // 401, and every message on the socket is authenticated by that cookie; // - EVENT_LOGIN_AUTH and EVENT_LOGOUT over the socket get MSG_NACK 403. // Off: no cookies at all, as on a GSF router without the block, so an HTTP // request names its session in a SESSION_AUTH_TOKEN header (under // fidelity: 'legacy', the mock's MOCK_SESSION_ID cookie), and the socket // logs in with EVENT_LOGIN_AUTH. `true` (or unset) uses the shipped block's // cookie attributes; an object overrides them. cookieAuthentication?: boolean | CookieAuthenticationOptions; // A username to always ack gwf/event-login-details as (skips the login // screen entirely), or `true` to use the first configured user. A request // without a live session gets a new one, as if that user had logged in // (with cookieAuthentication on, the login cookies are set too). httpAutoLogin?: string | true; // OPT-IN (default false): when true, WS data traffic (DATA_LOGON, REQ_*, // META/JSON_SCHEMA, commit events) is rejected with a NOT_LOGGED_ON error // until the connection logs in — or presents a SESSION_AUTH_TOKEN this // server issued over either transport. Off by default because clients // using foundation-auth's HTTP login send no SESSION_AUTH_TOKEN on WS // messages at all (verified from browser frame captures), so a default-on // gate rejects their every datasource. Enable only for clients that log in // over WS. requireAuth?: boolean; } export interface BroadcastFn { (resourceName: string, operation: 'INSERT' | 'MODIFY' | 'DELETE', row: Row): void; } export interface BroadcastTableChangeFn { (tableName: string, operation: 'INSERT' | 'MODIFY' | 'DELETE', pk: string): void; } export interface EventHandlerCtx { connection: import('./protocol/connection.ts').Connection; store: Store; message: GenesisMessage; // The server's whole config, as it is when the event comes: a handler that // depends on what else the server holds (a table's unique indexes, the // request servers a report reads) reads it here. config: MockServerConfig; broadcast: BroadcastFn; broadcastTableChange: BroadcastTableChangeFn; // `VALIDATE: true` on the message: the client asks whether the event would // succeed. GSF then runs only the handler's onValidate and answers its NACK, // or an EVENT_ACK with GENERATED: [] — nothing is written. A handler that // writes should run its checks and return before writing when this is set; // the generic CRUD events do. The engine rolls back whatever it writes // anyway, and acks with GENERATED: [] whatever the handler returns (see // handlers/commitEvent.ts; not under fidelity: 'legacy'). validate: boolean; // `IGNORE_WARNINGS: true` on the message: warnings the handler returns // don't stop the event. The engine applies it: outside VALIDATE, returned // warnings commit the handler's writes and ACK when this is set, and roll // them back and NACK when it isn't. So write, then return the warnings: // a handler that returns them before writing (as advised before this // flag was modelled) now ACKs an ignored warning without writing. Read it // to skip work that only matters when the event will commit, or before // throwing NackError.warning(...), which always NACKs. Always false under // fidelity: 'legacy', which doesn't model it. ignoreWarnings: boolean; // Runs `callback` once the event has committed, before its ACK goes out // (a push it makes still follows the ACK); never when it NACKs or only // validates. Called after that (from a callback, or kept for later), it // runs at once if the event committed. For state a handler keeps outside // the store, which the store's rollback can't undo. Under fidelity: // 'legacy', which keeps every write, at once. afterCommit(callback: () => void): void; } // `warnings` (a non-empty list) is a GSF onValidate's warning-only // EventNack: without IGNORE_WARNINGS, or under VALIDATE, the reply is an // EVENT_NACK carrying them in WARNING with an empty ERROR, and nothing the // handler wrote is kept; with IGNORE_WARNINGS it is an EVENT_ACK with // `generated` (no WARNING), and the writes are kept. Errors are thrown // (NackError), never returned: a thrown NackError NACKs with its errors and // any warnings it carries, and rolls back too. // // `reply` is a typed GSF handler's own reply (`eventHandler` whose // Out isn't EventReply, e.g. auth's CreateMfaSecretAck): sent as it is, with // its own MESSAGE_TYPE, in place of the EVENT_ACK, under VALIDATE too (the // handler returns its validate reply then). A NACK of its own type is a // NackError with `messageType`. export type EventHandlerResult = { generated?: Row[]; warnings?: NackErrorInput[]; reply?: Row; } | void; export type EventHandler = (details: Row, ctx: EventHandlerCtx) => EventHandlerResult; // Extra HTTP route served by the engine's shared http.Server — for project // endpoints the protocol doesn't cover (e.g. GET pbi/configs). Routes are // tried FIRST, before the built-in login/SSO handlers, so they can also // override those. A string path matches pathname.endsWith(path) (same // convention as the built-ins, which tolerates any gateway prefix); a RegExp // is tested against the full pathname. export interface HttpRouteDef { method?: string; path: string | RegExp; handler: (req: IncomingMessage, res: ServerResponse, ctx: HttpRouteCtx) => void | Promise; } // What a route handler can reach: the store and the live pushes, as an event // handler does, and who the request's session belongs to (none without one). export interface HttpRouteCtx { store: Store; broadcast: BroadcastFn; broadcastTableChange: BroadcastTableChangeFn; userName?: string; // Runs an event as `userName`, as a socket's would run (schema check, // handler, transaction, audit), and gives back its reply: how GSF's router // answers an event POSTed over HTTP. With auth.requireAuth, an event with // no user NACKs NOT_LOGGED_ON, as on the socket. commitEvent(message: GenesisMessage, userName: string | undefined): Row; } export interface OnMessageCtx { connection: import('./protocol/connection.ts').Connection; store: Store; broadcast: BroadcastFn; broadcastTableChange: BroadcastTableChangeFn; } // Inbound-message hook, called for every parsed WS message BEFORE routing. // Return a message (possibly rewritten — e.g. normalizing a CRITERIA_MATCH // dialect) to continue routing with it, `false` if the hook fully handled // the message itself (reply via ctx.connection.reply), or nothing to route // the original unchanged. export type OnMessageHook = ( message: GenesisMessage, ctx: OnMessageCtx, ) => GenesisMessage | false | void; // How closely the wire envelopes follow the real server. 'gsf' (the default) // matches GSF 8.15.x: LOGON_ACK before the snapshot, dataserver paging // (MAX_ROWS / MAX_VIEW / MORE_ROWS), LOGOFF_ACK, FEATURE_ACK / // JSON_SCHEMA_FEATURE_ACK, the router's MSG_NACK for unknown resources and // missing/duplicate subscriptions, and auth 8.15.5's login ack, login NACK // (HTTP 200) and HEARTBEAT_PONG VERBOSE fields. 'legacy' restores the envelopes this engine // sent up to 15.47 (every row in one snapshot, no LOGON_ACK/LOGOFF_ACK, typed // UNKNOWN_RESOURCE replies that RESOLVE the client's pending promise where // MSG_NACK rejects it) — an escape hatch for consumers built against those, // to be removed in a later release. export type FidelityMode = 'gsf' | 'legacy'; // How a live MODIFY's row is written under fidelity 'gsf'. 'diff' (the // default) is GSF's: only the fields that changed since the client was last // sent the row, a field that became null as null, plus DETAILS. 'full' sends // the whole row, for a client that can't merge a partial row yet. Either // way a MODIFY that changes nothing the client sees isn't sent. Fidelity // 'legacy' always sends whole rows. export type LiveModifyMode = 'diff' | 'full'; export interface MockServerConfig { // Defaults to 'gsf'. See FidelityMode and the README's "Wire fidelity". fidelity?: FidelityMode; // Defaults to 'diff'. See LiveModifyMode and the README's "Dataserver paging". liveModify?: LiveModifyMode; tables?: Record; views?: Record; queries?: Record; requestReplies?: Record; eventHandlers?: Record; // Fallback for commit events with no registered handler — lets a project // with many events adopt incrementally (e.g. `() => ({})` acks everything // unhandled) instead of enumerating ack-only handlers. Unregistered events // get the router's MSG_NACK 404 when this is unset (EVENT_NACK with // UNKNOWN_RESOURCE under fidelity: 'legacy'). defaultEventHandler?: EventHandler; eventSchemas?: Record; auth?: AuthConfig; // RESOURCES_REQUEST_ACK's list, in full: replaces the one derived from // queries, requestReplies and events (handlers/resources.ts). resources?: ResourceItem[]; // Listed after the derived resources, skipping any already there (same // RESOURCE_NAME and RESOURCE_TYPE): resources served by processes this // config doesn't model, such as the platform's (presets' platformPreset). // Ignored when `resources` is set. additionalResources?: ResourceItem[]; httpRoutes?: HttpRouteDef[]; onMessage?: OnMessageHook; productName?: string; // The Genesis application name, as in the generated enum classes the real // server names in event metadata // (global.genesis.gen.dao.enums..
.). Unset: the // segment is left out. Nothing a client does depends on it. appName?: string; } // A META_REQUEST FIELD / REQUEST_FIELD / REPLY_FIELD entry. export interface FieldMetadata { NAME: string; TYPE: string; VALID_VALUES?: string[]; } // Minimal shape both `ws`'s WebSocket and Bun's ServerWebSocket satisfy — // Connection only needs to send text frames and check whether it still can. export interface SocketLike { readyState: number; send(data: string): void; }