fastify-txstate 3.6.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,22 @@
1
- /// <reference types="node" />
2
- /// <reference types="node" />
3
1
  import { type FastifyDynamicSwaggerOptions } from '@fastify/swagger';
4
2
  import { type FastifySwaggerUiOptions } from '@fastify/swagger-ui';
5
3
  import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
6
- import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type FastifyLoggerOptions, type FastifyBaseLogger, type RawServerDefault } from 'fastify';
4
+ import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type FastifyBaseLogger, type RawServerDefault } from 'fastify';
5
+ import pino from 'pino';
7
6
  import http from 'node:http';
8
7
  import type http2 from 'node:http2';
9
8
  type ErrorHandler = (error: Error, req: FastifyRequest, res: FastifyReply) => Promise<void>;
9
+ /**
10
+ * Get the base URL for this API. Uses PUBLIC_URL if set, otherwise derives
11
+ * from the request's protocol and hostname.
12
+ */
13
+ export declare function apiBaseUrl(req: FastifyRequest): string;
14
+ /**
15
+ * Get the base URL for the UI that this API serves. Uses UI_URL if set,
16
+ * otherwise assumes the API lives at a subpath (e.g. /api) and the UI is
17
+ * one level up. Falls back to the API base URL if there's no parent path.
18
+ */
19
+ export declare function uiBaseUrl(req: FastifyRequest): string;
10
20
  export interface FastifyTxStateAuthInfo {
11
21
  /**
12
22
  * The primary identifier for the user that is making the request, after processing
@@ -27,6 +37,13 @@ export interface FastifyTxStateAuthInfo {
27
37
  * If all else fails, you can sha256 the session token with a salt.
28
38
  */
29
39
  sessionId: string;
40
+ /**
41
+ * The date that the session was created, if available. This is useful for considering
42
+ * tokens before a certain date as invalid. For instance, if you want a logout action
43
+ * to invalidate all tokens created until that point, you can compare this field against
44
+ * the last time they logged out.
45
+ */
46
+ sessionCreatedAt?: Date;
30
47
  /**
31
48
  * Some authentication systems allow administrators to impersonate regular users, so that
32
49
  * they can see what that user sees and troubleshoot issues. We still want to log the administrator
@@ -45,6 +62,39 @@ export interface FastifyTxStateAuthInfo {
45
62
  * this field can help log requests that are authenticated with the other application's token.
46
63
  */
47
64
  clientId?: string;
65
+ /**
66
+ * A string that designates the current session as one that has limited authorization. The application will
67
+ * be responsible for checking this field and restricting appropriately.
68
+ *
69
+ * For example, a user who authenticated via non-standard mechanism might be given a scope of 'altlogin' and
70
+ * only a portion of the application's functionality would be available to them.
71
+ */
72
+ scope?: string;
73
+ /**
74
+ * The token or key that was used to authenticate the request. This is useful for
75
+ * making sub-requests to other APIs that can authenticate with the same token.
76
+ */
77
+ token: string;
78
+ /**
79
+ * The issuer configuration for the token, if applicable. This helps you generate
80
+ * a proper logout url in multi-issuer environments.
81
+ */
82
+ issuerConfig?: IssuerConfig;
83
+ /**
84
+ * The OAuth access token, if available. This is useful when your API needs to make
85
+ * requests to the provider's APIs on behalf of the user (e.g. Google Drive, Microsoft
86
+ * Graph). Only populated when using cookie-based OAuth with a provider that returns
87
+ * an access token during the code exchange.
88
+ */
89
+ accessToken?: string;
90
+ }
91
+ export interface IssuerConfig {
92
+ iss: string;
93
+ url?: string;
94
+ publicKey?: string;
95
+ secret?: string;
96
+ validateUrl?: URL;
97
+ logoutUrl?: URL;
48
98
  }
49
99
  export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
50
100
  https?: http2.SecureServerOptions;
@@ -82,39 +132,24 @@ export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
82
132
  declare module 'fastify' {
83
133
  interface FastifyRequest {
84
134
  auth?: FastifyTxStateAuthInfo;
135
+ originChecker?: OriginChecker;
85
136
  }
86
137
  interface FastifyReply {
87
138
  extraLogInfo: any;
88
139
  }
89
140
  }
90
- export declare const devLogger: {
91
- level: string;
92
- info: (msg: any) => void;
93
- error: {
94
- (...data: any[]): void;
95
- (message?: any, ...optionalParams: any[]): void;
96
- };
97
- debug: {
98
- (...data: any[]): void;
99
- (message?: any, ...optionalParams: any[]): void;
100
- };
101
- fatal: {
102
- (...data: any[]): void;
103
- (message?: any, ...optionalParams: any[]): void;
104
- };
105
- warn: {
106
- (...data: any[]): void;
107
- (message?: any, ...optionalParams: any[]): void;
108
- };
109
- trace: {
110
- (...data: any[]): void;
111
- (message?: any, ...optionalParams: any[]): void;
112
- };
113
- silent: (msg: any) => void;
114
- child(bindings: any, options?: any): any;
115
- };
116
- export declare const prodLogger: FastifyLoggerOptions;
117
- export type FastifyInstanceTyped = FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, FastifyBaseLogger, JsonSchemaToTsProvider>;
141
+ export declare const devLogger: pino.Logger<never, boolean>;
142
+ export declare const prodLogger: pino.Logger<never, boolean>;
143
+ export type FastifyInstanceTyped = FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse, FastifyBaseLogger, JsonSchemaToTsProvider>;
144
+ export declare class OriginChecker {
145
+ protected validOrigins: Record<string, boolean>;
146
+ protected validOriginHosts: Record<string, boolean>;
147
+ protected validOriginSuffixes: Set<string>;
148
+ setValidOrigins(origins: string[]): void;
149
+ setValidOriginHosts(hosts: string[]): void;
150
+ setValidOriginSuffixes(suffixes: string[]): void;
151
+ check(hostname: string, requestHostname?: string): boolean;
152
+ }
118
153
  export type TxServer = Server;
119
154
  export default class Server {
120
155
  protected config: FastifyTxStateOptions & {
@@ -129,9 +164,8 @@ export default class Server {
129
164
  } | undefined>;
130
165
  protected shuttingDown: boolean;
131
166
  protected sigHandler: (signal: any) => void;
132
- protected validOrigins: Record<string, boolean>;
133
- protected validOriginHosts: Record<string, boolean>;
134
- protected validOriginSuffixes: Set<string>;
167
+ protected originChecker: OriginChecker;
168
+ protected swaggerEndpoint: string | undefined;
135
169
  app: FastifyInstanceTyped;
136
170
  constructor(config?: FastifyTxStateOptions & {
137
171
  http2?: true;
@@ -150,6 +184,4 @@ export default class Server {
150
184
  }): Promise<void>;
151
185
  close(softSeconds?: number): Promise<void>;
152
186
  }
153
- export * from './analytics';
154
- export * from './error';
155
- export * from './unified-auth';
187
+ export {};
package/lib/server.js ADDED
@@ -0,0 +1,440 @@
1
+ import { Ajv } from 'ajv';
2
+ import swagger from '@fastify/swagger';
3
+ import swaggerUI from '@fastify/swagger-ui';
4
+ import { validatedResponse } from '@txstate-mws/fastify-shared';
5
+ import ajvErrors from 'ajv-errors';
6
+ import ajvFormats from 'ajv-formats';
7
+ import { fastify } from 'fastify';
8
+ import fs from 'node:fs';
9
+ import pino from 'pino';
10
+ import http from 'node:http';
11
+ import { Writable } from 'node:stream';
12
+ import { clone, destroyNulls, isBlank, isNotBlank, omit, set, sleep, stringifyDates, toArray } from 'txstate-utils';
13
+ import { HttpError, ValidationError, ValidationErrors, fstValidationToMessage } from "./error.js";
14
+ /**
15
+ * Get the base URL for this API. Uses PUBLIC_URL if set, otherwise derives
16
+ * from the request's protocol and hostname.
17
+ */
18
+ export function apiBaseUrl(req) {
19
+ if (isNotBlank(process.env.PUBLIC_URL))
20
+ return process.env.PUBLIC_URL.replace(/\/$/v, '');
21
+ return req.protocol + '://' + req.hostname;
22
+ }
23
+ /**
24
+ * Get the base URL for the UI that this API serves. Uses UI_URL if set,
25
+ * otherwise assumes the API lives at a subpath (e.g. /api) and the UI is
26
+ * one level up. Falls back to the API base URL if there's no parent path.
27
+ */
28
+ export function uiBaseUrl(req) {
29
+ if (isNotBlank(process.env.UI_URL))
30
+ return process.env.UI_URL.replace(/\/$/v, '');
31
+ const base = apiBaseUrl(req);
32
+ return new URL('..', base + '/').toString().replace(/\/$/v, '');
33
+ }
34
+ /* eslint-disable no-console -- devLogger intentionally writes to console */
35
+ export const devLogger = pino({
36
+ level: 'info'
37
+ }, new Writable({
38
+ write(chunk, _encoding, callback) {
39
+ const obj = JSON.parse(String(chunk));
40
+ if (obj.req)
41
+ console.info(`${obj.req.method} ${obj.req.url}`);
42
+ else if (obj.res)
43
+ console.info(`${obj.res.statusCode} - ${obj.responseTime}`);
44
+ else if (obj.err)
45
+ console.error(obj.err);
46
+ else
47
+ console.info(obj.msg || obj);
48
+ callback();
49
+ }
50
+ }));
51
+ /* eslint-enable no-console */
52
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call -- pino serializer params are typed as any */
53
+ export const prodLogger = pino({
54
+ level: 'info',
55
+ serializers: {
56
+ req(req) {
57
+ return {
58
+ method: req.method,
59
+ url: req.url.replace(/(?<param>token|unifiedJwt)=[\w.]+/iv, '$<param>=redacted'),
60
+ remoteAddress: req.ip,
61
+ traceparent: req.headers.traceparent
62
+ };
63
+ },
64
+ res(res) {
65
+ return {
66
+ statusCode: res.statusCode,
67
+ url: res.request?.url.replace(/(?<param>token|unifiedJwt)=[\w.]+/iv, '$<param>=redacted'),
68
+ length: Number(toArray(res.getHeader?.('content-length'))[0]),
69
+ ...omit(res.extraLogInfo, 'auth'),
70
+ auth: omit(res.request?.auth ?? res.extraLogInfo?.auth ?? {}, 'token', 'accessToken', 'issuerConfig')
71
+ };
72
+ }
73
+ }
74
+ });
75
+ export class OriginChecker {
76
+ validOrigins = {};
77
+ validOriginHosts = {};
78
+ validOriginSuffixes = new Set();
79
+ setValidOrigins(origins) {
80
+ this.validOrigins = origins.reduce((acc, origin) => ({ ...acc, [origin]: true }), {});
81
+ }
82
+ setValidOriginHosts(hosts) {
83
+ this.validOriginHosts = hosts.reduce((acc, host) => ({ ...acc, [host]: true }), {});
84
+ }
85
+ setValidOriginSuffixes(suffixes) {
86
+ this.validOriginSuffixes.clear();
87
+ for (const s of suffixes)
88
+ this.validOriginSuffixes.add(s.replace(/^\./v, ''));
89
+ }
90
+ check(hostname, requestHostname) {
91
+ try {
92
+ const parsed = new URL(hostname.includes('://') ? hostname : 'https://' + hostname);
93
+ if (this.validOrigins[parsed.origin])
94
+ return true;
95
+ if (requestHostname && parsed.hostname === requestHostname)
96
+ return true;
97
+ if (this.validOriginHosts[parsed.hostname])
98
+ return true;
99
+ const parts = parsed.hostname.split('.');
100
+ for (let i = 0; i < parts.length; i++) {
101
+ if (this.validOriginSuffixes.has(parts.slice(i).join('.')))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ }
111
+ export default class Server {
112
+ config;
113
+ https = false;
114
+ errorHandlers = [];
115
+ healthMessage;
116
+ healthCallback;
117
+ shuttingDown = false;
118
+ sigHandler;
119
+ originChecker = new OriginChecker();
120
+ swaggerEndpoint;
121
+ app;
122
+ constructor(config = {}) {
123
+ this.config = config;
124
+ try {
125
+ const key = fs.readFileSync('/securekeys/private.key');
126
+ const cert = fs.readFileSync('/securekeys/cert.pem');
127
+ config.https = {
128
+ ...config.https,
129
+ allowHTTP1: true,
130
+ key,
131
+ cert,
132
+ minVersion: 'TLSv1.2'
133
+ };
134
+ config.http2 = true;
135
+ this.https = true;
136
+ }
137
+ catch (e) {
138
+ this.https = false;
139
+ delete config.https;
140
+ }
141
+ if (typeof config.logger === 'undefined' && !config.loggerInstance) {
142
+ config.loggerInstance = process.env.NODE_ENV === 'development'
143
+ ? devLogger
144
+ : prodLogger;
145
+ }
146
+ if (process.env.TRUST_PROXY != null) {
147
+ if (['true', '1'].includes(process.env.TRUST_PROXY))
148
+ config.trustProxy = true;
149
+ else
150
+ config.trustProxy = process.env.TRUST_PROXY;
151
+ }
152
+ config.ajv = { ...config.ajv, mode: undefined, plugins: [...(config.ajv?.plugins ?? []), ajvErrors, [ajvFormats, { mode: 'fast' }]], customOptions: { ...config.ajv?.customOptions, allErrors: true, strictSchema: false, coerceTypes: true } };
153
+ this.healthCallback = config.checkHealth;
154
+ this.app = fastify(config);
155
+ this.app.addHook('onRoute', route => {
156
+ if (!route.schema?.body)
157
+ return;
158
+ const missingResponse = route.schema.response == null;
159
+ const response400 = set(validatedResponse.properties.messages, 'description', 'Basic validation failure. This means that the UI provided input that failed validation as defined in the openapi specification published by the API. The UI is at fault and should be re-coded to avoid sending invalid data.');
160
+ let newSchema = set(route.schema ?? {}, 'response.400', response400);
161
+ const response422 = set(validatedResponse, 'description', 'Validation failure. This means that the user provided an invalid object. The user should be shown their error so that they can correct it.');
162
+ newSchema = set(newSchema, 'response.422', response422);
163
+ if (missingResponse) {
164
+ newSchema.response['200'] = {
165
+ description: 'Success. Return type has not been specified.',
166
+ type: 'object'
167
+ };
168
+ }
169
+ route.schema = newSchema;
170
+ });
171
+ this.app.addHook('preValidation', (req, res, done) => {
172
+ if (req.body != null && req.routeOptions.schema?.body)
173
+ destroyNulls(req.body);
174
+ done();
175
+ });
176
+ // use Ajv to validate responses instead of @fastify/json-fast-stringify since ajv does
177
+ // a better job with recursive types and we don't want to have different behavior between
178
+ // input and output validation
179
+ const ajv = new Ajv(config.ajv.customOptions);
180
+ for (const pluginConfig of config.ajv.plugins ?? []) {
181
+ const [plugin, opts] = toArray(pluginConfig);
182
+ plugin(ajv, opts); // eslint-disable-line @typescript-eslint/no-unsafe-call -- plugin type comes from fastify's ajv config
183
+ }
184
+ this.app.setSerializerCompiler(route => {
185
+ const schema = route.schema;
186
+ const validate = schema == null ? ajv.compile({ type: 'object' }) : ajv.compile(schema); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- schema can be undefined at runtime
187
+ return data => {
188
+ /**
189
+ * Ajv unfortunately treats optional properties as non-nullable, so they're allowed to
190
+ * be undefined but not allowed to be null. Worse, with `coerceTypes`, null will be converted
191
+ * to empty string or 0 or false. This is silly behavior, so we're converting all nulls to
192
+ * undefined before we validate.
193
+ */
194
+ if (schema != null)
195
+ destroyNulls(stringifyDates(data)); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- schema can be undefined at runtime
196
+ if (!validate(data))
197
+ throw new Error('Output validation failed. ' + validate.errors?.[0].instancePath + ': ' + validate.errors?.[0].message);
198
+ return JSON.stringify(data);
199
+ };
200
+ });
201
+ this.app.addHook('onRequest', (req, res, done) => {
202
+ res.extraLogInfo = {};
203
+ req.originChecker = this.originChecker;
204
+ done();
205
+ });
206
+ if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
207
+ this.originChecker.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
208
+ this.originChecker.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
209
+ this.originChecker.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
210
+ this.app.addHook('onRequest', async (req, res) => {
211
+ if (!req.headers.origin)
212
+ return;
213
+ const passed = (req.headers.origin === 'null'
214
+ ? process.env.NODE_ENV === 'development'
215
+ : this.originChecker.check(req.headers.origin, req.hostname)) || !!config.checkOrigin?.(req);
216
+ if (!passed) {
217
+ await res.status(403).send('Origin check failed. Suspected XSRF attack.');
218
+ return await res;
219
+ }
220
+ else {
221
+ void res.header('Access-Control-Allow-Origin', req.headers.origin);
222
+ void res.header('Access-Control-Max-Age', '600'); // ask browser to skip pre-flights for 10 minutes after a yes
223
+ if (req.headers['access-control-request-method'])
224
+ void res.header('access-control-allow-methods', req.headers['access-control-request-method']);
225
+ if (req.headers['access-control-request-headers'])
226
+ void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
227
+ }
228
+ });
229
+ this.app.options('*', async (req, res) => {
230
+ await res.send();
231
+ });
232
+ }
233
+ if (config.authenticate) {
234
+ const authenticatedMethods = {
235
+ GET: true,
236
+ POST: true,
237
+ PUT: true,
238
+ PATCH: true,
239
+ DELETE: true
240
+ };
241
+ this.app.addHook('onRequest', async (req, res) => {
242
+ if (!authenticatedMethods[req.method] || isBlank(req.routeOptions.url) || req.routeOptions.url === '/health' || req.routeOptions.url === '/.uaService' || (this.swaggerEndpoint && req.routeOptions.url.startsWith(this.swaggerEndpoint)))
243
+ return;
244
+ try {
245
+ req.auth = await config.authenticate(req);
246
+ }
247
+ catch (e) {
248
+ await res.status(401).send('Failed to authenticate.');
249
+ return await res;
250
+ }
251
+ });
252
+ }
253
+ this.app.addHook('onSend', this.https && process.env.NODE_ENV !== 'development'
254
+ ? async (_, resp) => {
255
+ void resp.removeHeader('X-Powered-By');
256
+ void resp.header('Strict-Transport-Security', 'max-age=31536000');
257
+ if (resp.getHeader('content-type') === 'text/html')
258
+ void resp.type('text/html; charset=utf-8');
259
+ }
260
+ : async (_, resp) => {
261
+ void resp.removeHeader('X-Powered-By');
262
+ if (resp.getHeader('content-type') === 'text/html')
263
+ void resp.type('text/html; charset=utf-8');
264
+ });
265
+ this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
266
+ this.app.setErrorHandler(async (err, req, res) => {
267
+ req.log.warn(err);
268
+ for (const errorHandler of this.errorHandlers) {
269
+ if (!res.sent)
270
+ await errorHandler(err, req, res);
271
+ }
272
+ if (!res.sent) {
273
+ if (err instanceof ValidationError) {
274
+ await res.status(err.statusCode).send({ success: false, messages: [{ message: err.message, path: err.path, type: err.type ?? 'error' }] });
275
+ }
276
+ else if (err instanceof ValidationErrors) {
277
+ await res.status(err.statusCode).send({ success: false, messages: err.errors });
278
+ }
279
+ else if (err instanceof HttpError) {
280
+ await res.status(err.statusCode).send(err.message);
281
+ }
282
+ else if (err.code === 'FST_ERR_VALIDATION') {
283
+ const developerErrors = [];
284
+ const userErrors = [];
285
+ for (const v of err.validation ?? []) {
286
+ if (v.keyword === 'errorMessage') {
287
+ for (const ov of v.params.errors) {
288
+ if (['type', 'additionalProperties', 'minProperties'].includes(ov.keyword))
289
+ developerErrors.push({ ...ov, message: v.message });
290
+ else if (ov.keyword === 'required')
291
+ userErrors.push({ ...ov, message: 'This field is required.' });
292
+ else
293
+ userErrors.push({ ...ov, message: v.message });
294
+ }
295
+ }
296
+ else if (['type', 'additionalProperties', 'minProperties'].includes(v.keyword))
297
+ developerErrors.push(v);
298
+ else if (v.keyword === 'required')
299
+ userErrors.push({ ...v, message: 'This field is required.' });
300
+ else
301
+ userErrors.push(v);
302
+ }
303
+ if (userErrors.length)
304
+ await res.status(422).send({ success: false, messages: userErrors.map(fstValidationToMessage) });
305
+ else
306
+ await res.status(400).send(developerErrors.map(fstValidationToMessage));
307
+ }
308
+ else if (err.statusCode) {
309
+ await res.status(err.statusCode).send(new HttpError(err.statusCode).message);
310
+ }
311
+ else {
312
+ await res.status(500).send('Internal Server Error.');
313
+ }
314
+ }
315
+ });
316
+ this.app.get('/health', { logLevel: 'warn' }, async (req, res) => {
317
+ if (this.shuttingDown) {
318
+ res.log.warn('Returning 503 on /health because we are shutting down/restarting.');
319
+ void res.status(503);
320
+ return 'MAINTENANCE';
321
+ }
322
+ else if (this.healthMessage) {
323
+ res.log.error(this.healthMessage);
324
+ void res.status(500);
325
+ return this.healthMessage;
326
+ }
327
+ else if (this.healthCallback) {
328
+ const resp = await this.healthCallback();
329
+ const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp?.status, resp?.message];
330
+ if (!!msg || !!status) {
331
+ res.log.error(resp, 'Health check callback failed.');
332
+ void res.status(status ?? 500);
333
+ return msg ?? 'FAIL';
334
+ }
335
+ }
336
+ return 'OK';
337
+ });
338
+ this.sigHandler = () => {
339
+ this.close().then(() => {
340
+ process.exit();
341
+ }).catch((e) => { this.app.log.error(e, 'Error during shutdown'); });
342
+ };
343
+ process.on('SIGTERM', this.sigHandler);
344
+ process.on('SIGINT', this.sigHandler);
345
+ }
346
+ async start(port) {
347
+ const customPort = port ?? parseInt(process.env.PORT ?? '0', 10);
348
+ await this.app.ready();
349
+ if (this.swaggerEndpoint)
350
+ this.app.swagger();
351
+ if (customPort) {
352
+ await this.app.listen({ port: customPort, host: '0.0.0.0' });
353
+ }
354
+ else if (this.https) {
355
+ // redirect 80 to 443
356
+ http.createServer((req, res) => {
357
+ res.writeHead(301, { Location: 'https://' + (req.headers.host?.replace(/:\d+$/v, '') ?? '') + (req.url ?? '') });
358
+ res.end();
359
+ }).listen(80);
360
+ await this.app.listen({ port: 443, host: '0.0.0.0' });
361
+ }
362
+ else {
363
+ await this.app.listen({ port: 80, host: '0.0.0.0' });
364
+ }
365
+ }
366
+ addErrorHandler(handler) {
367
+ this.errorHandlers.push(handler);
368
+ }
369
+ setUnhealthy(message) {
370
+ this.healthMessage = message;
371
+ }
372
+ setHealthy() {
373
+ this.healthMessage = undefined;
374
+ }
375
+ setValidOrigins(origins) {
376
+ this.originChecker.setValidOrigins(origins);
377
+ }
378
+ setValidOriginHosts(hosts) {
379
+ this.originChecker.setValidOriginHosts(hosts);
380
+ }
381
+ setValidOriginSuffixes(suffixes) {
382
+ this.originChecker.setValidOriginSuffixes(suffixes);
383
+ }
384
+ async swagger(opts) {
385
+ let openapi = opts?.openapi ?? {};
386
+ if (this.config.authenticate != null) {
387
+ openapi = set(openapi, 'components.securitySchemes', {
388
+ unifiedAuth: {
389
+ type: 'http',
390
+ scheme: 'bearer',
391
+ bearerFormat: 'JWT',
392
+ description: `Enter a token obtained from the TxState Unified Authentication service. An easy way to do
393
+ this is log into this application and use dev tools to pull your token from the Authorization header.`
394
+ }
395
+ });
396
+ // Apply the security globally to all operations
397
+ openapi.security = [{ unifiedAuth: [] }];
398
+ }
399
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return -- findRefs traverses arbitrary JSON schema objects */
400
+ function findRefs(obj, id) {
401
+ if (obj == null)
402
+ return undefined;
403
+ if (obj.$id?.length)
404
+ id = obj.$id;
405
+ if (obj.$ref === '#' && id?.length) {
406
+ obj.type = 'string';
407
+ obj.enum = [id];
408
+ delete obj.$ref;
409
+ }
410
+ else {
411
+ for (const val of Object.values(obj)) {
412
+ if (typeof val === 'object' && !(val instanceof Date))
413
+ findRefs(val, id);
414
+ }
415
+ }
416
+ return obj;
417
+ }
418
+ await this.app.register(swagger, {
419
+ openapi,
420
+ transform(transformArgs) {
421
+ const newSchema = findRefs(clone(transformArgs.schema));
422
+ return { ...transformArgs, schema: newSchema };
423
+ }
424
+ });
425
+ /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
426
+ this.swaggerEndpoint = opts?.path ?? opts?.ui?.routePrefix ?? '/docs';
427
+ await this.app.register(swaggerUI, { ...opts?.ui, routePrefix: this.swaggerEndpoint });
428
+ }
429
+ async close(softSeconds) {
430
+ if (typeof softSeconds === 'undefined')
431
+ softSeconds = parseInt(process.env.LOAD_BALANCE_TIMEOUT ?? '0', 10);
432
+ process.removeListener('SIGTERM', this.sigHandler);
433
+ process.removeListener('SIGINT', this.sigHandler);
434
+ if (softSeconds) {
435
+ this.shuttingDown = true;
436
+ await sleep(softSeconds);
437
+ }
438
+ await this.app.close();
439
+ }
440
+ }
@@ -1,5 +1,5 @@
1
- import { type FastifyReply, type FastifyRequest } from 'fastify';
2
- import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from '.';
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from './server.ts';
3
3
  export interface IssuerConfigRaw extends Omit<IssuerConfig, 'validateUrl' | 'logoutUrl'> {
4
4
  validateUrl?: string;
5
5
  logoutUrl?: string;