@tmlmobilidade/fastify 20251202.1817.5

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.
@@ -0,0 +1,17 @@
1
+ import { FastifyReply, type FastifyRequest } from './fastify-service.js';
2
+ import { type ActionsOf, type Organization, type Permission, type User } from '@tmlmobilidade/types';
3
+ declare module 'fastify' {
4
+ interface FastifyRequest {
5
+ me: User;
6
+ organization: Organization;
7
+ permissions: Permission[];
8
+ }
9
+ }
10
+ /**
11
+ * Creates an authorization middleware that validates user authentication and permissions.
12
+ * @param scope The permission scope to check (optional).
13
+ * @param action The permission action(s) to check (optional).
14
+ * @param requireAll Whether all actions must be true or at least one must be true.
15
+ * @returns Fastify middleware function.
16
+ */
17
+ export declare function authorizationMiddleware<S extends Permission['scope']>(scope?: S, actions?: ActionsOf<S>[], requireAll?: boolean): (request: FastifyRequest, reply: FastifyReply<string>) => Promise<void>;
@@ -0,0 +1,59 @@
1
+ /* * */
2
+ import { HttpException, HttpStatus } from '@tmlmobilidade/consts';
3
+ import { AUTH_SESSION_COOKIE_NAME, authProvider } from '@tmlmobilidade/interfaces';
4
+ import { PermissionCatalog } from '@tmlmobilidade/types';
5
+ /**
6
+ * Creates an authorization middleware that validates user authentication and permissions.
7
+ * @param scope The permission scope to check (optional).
8
+ * @param action The permission action(s) to check (optional).
9
+ * @param requireAll Whether all actions must be true or at least one must be true.
10
+ * @returns Fastify middleware function.
11
+ */
12
+ export function authorizationMiddleware(scope, actions, requireAll = false) {
13
+ return async (request, reply) => {
14
+ //
15
+ //
16
+ // Extract the session token from request cookies
17
+ const sessionToken = request.cookies.session_token;
18
+ if (!sessionToken) {
19
+ return reply
20
+ .setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
21
+ .send({ data: 'Session token is missing', error: null, statusCode: HttpStatus.UNAUTHORIZED });
22
+ }
23
+ //
24
+ // Get user and permissions from cache or auth provider.
25
+ // Cache is per session token, and valid for 5 minutes.
26
+ // This reduces the number of calls to the auth provider.
27
+ try {
28
+ const userData = await authProvider.getUserFromSessionToken(sessionToken);
29
+ const permissionsData = await authProvider.getPermissionsFromSessionToken(sessionToken);
30
+ const organizationData = await authProvider.getOrganizationFromSessionToken(sessionToken);
31
+ if (!userData || !permissionsData || !organizationData) {
32
+ return reply
33
+ .setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
34
+ .send({ data: 'Session token is missing', error: null, statusCode: HttpStatus.UNAUTHORIZED });
35
+ }
36
+ request.me = userData;
37
+ request.permissions = permissionsData;
38
+ request.organization = organizationData;
39
+ }
40
+ catch (error) {
41
+ console.error('Authorization Middleware Error:', error);
42
+ return reply
43
+ .setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
44
+ .send({ data: 'Session token is missing', error: null, statusCode: HttpStatus.UNAUTHORIZED });
45
+ }
46
+ //
47
+ // Evaluate the retrieved permissions,
48
+ // if scope and actions are provided.
49
+ if (!scope)
50
+ return;
51
+ const permissionChecks = actions.map(action => PermissionCatalog.hasPermission(request.permissions, scope, action));
52
+ const isAllowed = requireAll
53
+ ? permissionChecks.every(Boolean) // all must be true
54
+ : permissionChecks.some(Boolean); // at least one must be true
55
+ if (!isAllowed)
56
+ throw new HttpException(HttpStatus.FORBIDDEN, `Insufficient permissions | User: ${request.me._id} | Scope: "${scope}" | Actions: [${actions.join(',')}]`);
57
+ //
58
+ };
59
+ }
@@ -0,0 +1,84 @@
1
+ import '@fastify/cors';
2
+ import '@fastify/cookie';
3
+ import '@fastify/multipart';
4
+ import { HttpResponse, WithPagination } from '@tmlmobilidade/utils';
5
+ import { type FastifyInstance as FastifyInstanceType, type FastifyReply as FastifyReplyType } from 'fastify';
6
+ import { type ContextConfigDefault, type FastifyBaseLogger, type FastifySchema, type FastifyServerOptions, type FastifyTypeProviderDefault, type RawReplyDefaultExpression, type RawRequestDefaultExpression, type RawServerBase, type RawServerDefault, type RouteGenericInterface } from 'fastify';
7
+ export { type FastifyRequest } from 'fastify';
8
+ export type FastifyReply<T> = FastifyReplyType<RouteGenericInterface, RawServerBase, RawRequestDefaultExpression<RawServerBase>, RawReplyDefaultExpression<RawServerBase>, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault, HttpResponse<T> | ReadableStream | WithPagination<HttpResponse<T>>>;
9
+ export type FastifyResponse<T> = FastifyReplyType<RouteGenericInterface & {
10
+ Reply: HttpResponse<T> | WithPagination<HttpResponse<T>>;
11
+ }, RawServerBase, RawRequestDefaultExpression<RawServerBase>, RawReplyDefaultExpression<RawServerBase>, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault, HttpResponse<T> | WithPagination<HttpResponse<T>>>;
12
+ export type FastifyInstance = FastifyInstanceType<RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, FastifyBaseLogger, FastifyTypeProviderDefault>;
13
+ /**
14
+ * FastifyServiceOptions interface defines the options for the Fastify server.
15
+ * It extends FastifyServerOptions and adds optional properties for origin and port.
16
+ */
17
+ export interface FastifyServiceOptions extends FastifyServerOptions {
18
+ /**
19
+ * The host on which the Fastify server will listen.
20
+ * If not provided, it defaults to '0.0.0.0'.
21
+ * @default '0.0.0.0'
22
+ */
23
+ host?: string;
24
+ /**
25
+ * The origin for CORS requests.
26
+ * Defaults to `true` if not provided.
27
+ * @default true
28
+ * @example 'https://example.com'
29
+ */
30
+ origin?: RegExp | string | true;
31
+ /**
32
+ * The port on which the Fastify server will listen.
33
+ * If not provided, it defaults to 5050.
34
+ * @default 5050
35
+ */
36
+ port?: number;
37
+ }
38
+ /**
39
+ * FastifyService is a singleton class that provides a Fastify server instance.
40
+ * It allows for setting up routes, plugins, and starting/stopping the server.
41
+ * This class is designed to be used as a service in a Node.js application.
42
+ * It uses the Fastify framework for building web applications and APIs.
43
+ */
44
+ export declare class FastifyService {
45
+ private static _instance;
46
+ readonly server: FastifyInstance;
47
+ private readonly options;
48
+ /**
49
+ * Creates an instance of FastifyService.
50
+ * @param options The options for the Fastify server.
51
+ */
52
+ private constructor();
53
+ /**
54
+ * Gets the singleton instance of FastifyService.
55
+ * @param options The options for the Fastify server.
56
+ * @return The singleton instance of FastifyService.
57
+ */
58
+ static getInstance(options?: FastifyServiceOptions): FastifyService;
59
+ /**
60
+ * Starts the Fastify server.
61
+ * @return A promise that resolves to the URL of the Fastify server.
62
+ * @throws Will throw an error if the server fails to start.
63
+ */
64
+ start(): Promise<string>;
65
+ /**
66
+ * Stops the Fastify server.
67
+ * @return A promise that resolves when the server is stopped.
68
+ */
69
+ stop(): Promise<void>;
70
+ /**
71
+ * Sets the URL of the Fastify server.
72
+ * @return The URL of the Fastify server.
73
+ */
74
+ private _setupDefaultRoutes;
75
+ /**
76
+ * Sets up hooks for the Fastify server including error handling and response processing.
77
+ */
78
+ private _setupHooks;
79
+ /**
80
+ * Sets up the plugins for the Fastify server.
81
+ * @return A promise that resolves when the plugins are set up.
82
+ */
83
+ private _setupPlugins;
84
+ }
@@ -0,0 +1,251 @@
1
+ /* * */
2
+ import '@fastify/cors';
3
+ import '@fastify/cookie';
4
+ import '@fastify/multipart';
5
+ /* * */
6
+ import fastifyCookie from '@fastify/cookie';
7
+ import fastifyCors from '@fastify/cors';
8
+ import oneLineLogger from '@fastify/one-line-logger';
9
+ import { HttpException, HttpStatus } from '@tmlmobilidade/consts';
10
+ import fastify from 'fastify';
11
+ const defaultFastifyServiceOptions = {
12
+ bodyLimit: 1024 * 1024 * 10, // 10MB
13
+ host: '0.0.0.0',
14
+ logger: true,
15
+ origin: true,
16
+ port: 5050,
17
+ routerOptions: {
18
+ ignoreTrailingSlash: true,
19
+ },
20
+ };
21
+ const loggerOptions = {
22
+ level: 'debug',
23
+ stream: oneLineLogger({
24
+ colorize: true, // nice colors,
25
+ colorizeObjects: true,
26
+ messageFormat(log, messageKey, _, extras) {
27
+ const c = extras.colors;
28
+ const palette = {
29
+ error: c.redBright,
30
+ highlight: c.yellowBright, // URLs / routes
31
+ message: c.whiteBright,
32
+ method: c.greenBright,
33
+ methodLabel: c.gray,
34
+ pipe: c.cyanBright,
35
+ reqId: c.cyanBright,
36
+ reqIdLabel: c.gray,
37
+ stack: c.red,
38
+ status: c.yellowBright,
39
+ statusLabel: c.gray,
40
+ timestamp: c.cyanBright,
41
+ };
42
+ const colorize = (text) => {
43
+ const urlPattern = /(https?:\/\/[^\s]+)/g;
44
+ const routePattern = /Route "(.+?)"/g;
45
+ const pathPattern = /([A-Z]+):\/[^\s]+/g;
46
+ return text
47
+ .replace(urlPattern, palette.highlight('$&'))
48
+ .replace(routePattern, (_, r) => palette.highlight(`Route "${r}"`))
49
+ .replace(pathPattern, palette.highlight('$&'));
50
+ };
51
+ const safe = (val, fallback = '') => typeof val === 'string' || typeof val === 'number' ? String(val) : fallback;
52
+ const formatMethod = (method) => {
53
+ if (!method)
54
+ return '-----';
55
+ if (method === 'GET' || method === 'PUT')
56
+ return `${method} `;
57
+ return method.padEnd(5, '-');
58
+ };
59
+ const timestamp = new Date(log.time).toLocaleString('pt-PT', {
60
+ day: '2-digit',
61
+ hour: '2-digit',
62
+ minute: '2-digit',
63
+ month: '2-digit',
64
+ second: '2-digit',
65
+ year: 'numeric',
66
+ });
67
+ const reqId = log.reqId ? safe(log.reqId).padEnd(10, ' ') : Array(10).fill('-').join('');
68
+ const statusCode = typeof log.res === 'object' && log.res && 'statusCode' in log.res ? safe(log.res.statusCode).padEnd(3, '-') : '---';
69
+ const method = typeof log.req === 'object' && log.req && 'method' in log.req ? formatMethod(log.req.method ?? '') : '-----';
70
+ // Extract error information
71
+ // Pino serializes errors, so log.err is an object with type, message, stack, etc.
72
+ const errorObj = log.err || log.error;
73
+ let errorMessage = safe(log[messageKey]);
74
+ let errorStack;
75
+ if (errorObj) {
76
+ // Pino serialized error object
77
+ errorMessage = errorObj.message || errorMessage;
78
+ errorStack = errorObj.stack;
79
+ }
80
+ else if (log[messageKey] instanceof Error) {
81
+ // Direct Error instance (shouldn't happen with Pino, but just in case)
82
+ errorMessage = log[messageKey].message || errorMessage;
83
+ errorStack = log[messageKey].stack;
84
+ }
85
+ const message = palette.message(colorize(errorMessage));
86
+ // Add stack trace on new lines, indented for readability
87
+ const stackTrace = errorStack ? `\n${palette.stack(errorStack.split('\n').map(line => ` ${line}`).join('\n'))}` : '';
88
+ const parts = [
89
+ palette.timestamp(timestamp),
90
+ palette.reqIdLabel(`reqId: ${palette.reqId(reqId)}`),
91
+ palette.statusLabel(`statusCode: ${palette.status(statusCode)}`),
92
+ palette.methodLabel(`Method: ${palette.method(method)}`),
93
+ message,
94
+ ];
95
+ return palette.pipe(parts.join(' | ')) + stackTrace;
96
+ },
97
+ }),
98
+ };
99
+ /**
100
+ * FastifyService is a singleton class that provides a Fastify server instance.
101
+ * It allows for setting up routes, plugins, and starting/stopping the server.
102
+ * This class is designed to be used as a service in a Node.js application.
103
+ * It uses the Fastify framework for building web applications and APIs.
104
+ */
105
+ export class FastifyService {
106
+ //
107
+ static _instance;
108
+ server;
109
+ options;
110
+ /**
111
+ * Creates an instance of FastifyService.
112
+ * @param options The options for the Fastify server.
113
+ */
114
+ constructor(options) {
115
+ const mergedOptions = { ...defaultFastifyServiceOptions, ...options };
116
+ this.server = fastify({ ...mergedOptions, logger: loggerOptions });
117
+ this.options = mergedOptions;
118
+ this._setupDefaultRoutes();
119
+ this._setupPlugins();
120
+ }
121
+ /**
122
+ * Gets the singleton instance of FastifyService.
123
+ * @param options The options for the Fastify server.
124
+ * @return The singleton instance of FastifyService.
125
+ */
126
+ static getInstance(options) {
127
+ if (!FastifyService._instance) {
128
+ // Create a new instance if it doesn't exist yet
129
+ FastifyService._instance = new FastifyService(options || {});
130
+ FastifyService._instance._setupHooks();
131
+ }
132
+ // Return the existing instance
133
+ return FastifyService._instance;
134
+ }
135
+ /**
136
+ * Starts the Fastify server.
137
+ * @return A promise that resolves to the URL of the Fastify server.
138
+ * @throws Will throw an error if the server fails to start.
139
+ */
140
+ async start() {
141
+ try {
142
+ const serverUrl = await this.server.listen({ host: this.options.host, port: this.options.port });
143
+ this.server.log.info(`Server is running at ${serverUrl}`);
144
+ this.server.log.info(`CORS enabled for origin: ${this.options.origin}`);
145
+ this.server.log.info(`Listening on ${this.options.host}:${this.options.port}`);
146
+ return serverUrl;
147
+ }
148
+ catch (error) {
149
+ this.server.log.error({ error, message: 'Error starting server.' });
150
+ process.exit(1);
151
+ }
152
+ }
153
+ /**
154
+ * Stops the Fastify server.
155
+ * @return A promise that resolves when the server is stopped.
156
+ */
157
+ async stop() {
158
+ try {
159
+ await this.server.close();
160
+ console.log('Fastify server stopped.');
161
+ }
162
+ catch (error) {
163
+ this.server.log.error({ err: error }, error instanceof Error ? error.message : 'Error stopping server');
164
+ process.exit(1);
165
+ }
166
+ }
167
+ /**
168
+ * Sets the URL of the Fastify server.
169
+ * @return The URL of the Fastify server.
170
+ */
171
+ _setupDefaultRoutes() {
172
+ this.server.get('/', (req, res) => {
173
+ res.send('Jusi was here!');
174
+ });
175
+ }
176
+ /**
177
+ * Sets up hooks for the Fastify server including error handling and response processing.
178
+ */
179
+ _setupHooks() {
180
+ /**
181
+ * Sets a global error handler for the Fastify server instance.
182
+ * This handler checks if the error is an instance of HttpException.
183
+ * If so, it sends a response with the appropriate status code and error message.
184
+ * This ensures consistent error responses for HTTP exceptions throughout the application.
185
+ */
186
+ this.server.setErrorHandler((error, _, reply) => {
187
+ // Log the error with full stack trace
188
+ const errorMessage = error instanceof Error ? error.message : 'Unhandled error';
189
+ this.server.log.error({ err: error }, errorMessage);
190
+ // Handle HttpException errors
191
+ if (error instanceof HttpException) {
192
+ reply
193
+ .status(error.statusCode)
194
+ .send({
195
+ data: undefined,
196
+ error: error.message,
197
+ statusCode: error.statusCode,
198
+ });
199
+ }
200
+ else {
201
+ reply
202
+ .status(HttpStatus.INTERNAL_SERVER_ERROR)
203
+ .send({
204
+ data: undefined,
205
+ error: 'Internal server error',
206
+ statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
207
+ });
208
+ }
209
+ });
210
+ /**
211
+ * Adds an 'onSend' hook to the Fastify server instance.
212
+ * This hook intercepts every outgoing response before it is sent.
213
+ * It parses the payload as a JSON object (assuming it matches the HttpResponse<T> structure),
214
+ * and sets the HTTP status code of the reply to the value of 'statusCode' in the payload,
215
+ * defaulting to HttpStatus.OK if not present.
216
+ * This ensures that the HTTP status code in the response matches the statusCode property
217
+ * in the application's response payload, providing consistent status handling.
218
+ */
219
+ this.server.addHook('onSend', (_, reply, payload, done) => {
220
+ try {
221
+ const payloadJson = JSON.parse(payload);
222
+ reply.code(payloadJson.statusCode ?? HttpStatus.OK);
223
+ }
224
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
225
+ catch (error) {
226
+ // Do nothing
227
+ }
228
+ finally {
229
+ done();
230
+ }
231
+ });
232
+ }
233
+ /**
234
+ * Sets up the plugins for the Fastify server.
235
+ * @return A promise that resolves when the plugins are set up.
236
+ */
237
+ async _setupPlugins() {
238
+ // CORS plugin
239
+ await this.server.register(fastifyCors, {
240
+ credentials: true,
241
+ methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
242
+ origin: this.options.origin,
243
+ });
244
+ // Cookie plugin
245
+ await this.server.register(fastifyCookie);
246
+ // Multipart plugin
247
+ // await this.server.register(fastifyMultipart, {
248
+ // limits: { fileSize: this.options.bodyLimit },
249
+ // });
250
+ }
251
+ }
@@ -0,0 +1,2 @@
1
+ export * from './authorization-middleware.js';
2
+ export * from './fastify-service.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './authorization-middleware.js';
2
+ export * from './fastify-service.js';
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@tmlmobilidade/fastify",
3
+ "version": "20251202.1817.5",
4
+ "author": {
5
+ "email": "iso@tmlmobilidade.pt",
6
+ "name": "TML-ISO"
7
+ },
8
+ "license": "AGPL-3.0-or-later",
9
+ "homepage": "https://github.com/tmlmobilidade/go#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/tmlmobilidade/go/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/tmlmobilidade/go.git"
16
+ },
17
+ "keywords": [
18
+ "public transit",
19
+ "tml",
20
+ "transportes metropolitanos de lisboa",
21
+ "go"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "scripts": {
33
+ "build": "tsc && resolve-tspaths",
34
+ "lint": "eslint ./src && tsc --noEmit",
35
+ "lint:fix": "eslint . --fix",
36
+ "watch": "tsc-watch --onSuccess 'resolve-tspaths'"
37
+ },
38
+ "dependencies": {
39
+ "@fastify/cookie": "11.0.2",
40
+ "@fastify/cors": "11.1.0",
41
+ "@fastify/multipart": "9.3.0",
42
+ "@fastify/one-line-logger": "^2.0.2",
43
+ "@tmlmobilidade/consts": "*",
44
+ "@tmlmobilidade/interfaces": "*",
45
+ "@tmlmobilidade/utils": "*",
46
+ "fastify": "5.6.2",
47
+ "pino": "^10.1.0",
48
+ "pino-pretty": "^13.1.3"
49
+ },
50
+ "devDependencies": {
51
+ "@tmlmobilidade/tsconfig": "*",
52
+ "@tmlmobilidade/types": "*",
53
+ "@types/node": "24.10.1",
54
+ "resolve-tspaths": "0.8.23",
55
+ "tsc-watch": "7.2.0",
56
+ "typescript": "5.9.3"
57
+ }
58
+ }