From 299375bcd37bfb43f4caea7c8c2e9dd02c883ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 20:55:06 +0200 Subject: [PATCH] Add core server Controller base, auth hook, and buildServer --- src/core/server/Controller.ts | 5 ++++ src/core/server/authHook.ts | 19 ++++++++++++++ src/core/server/buildServer.ts | 17 +++++++++++++ test/core/server/authHook.test.ts | 41 +++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 src/core/server/Controller.ts create mode 100644 src/core/server/authHook.ts create mode 100644 src/core/server/buildServer.ts create mode 100644 test/core/server/authHook.test.ts diff --git a/src/core/server/Controller.ts b/src/core/server/Controller.ts new file mode 100644 index 0000000..03df5af --- /dev/null +++ b/src/core/server/Controller.ts @@ -0,0 +1,5 @@ +import type { FastifyInstance } from 'fastify'; + +export abstract class Controller { + abstract register(app: FastifyInstance): void; +} diff --git a/src/core/server/authHook.ts b/src/core/server/authHook.ts new file mode 100644 index 0000000..b864d23 --- /dev/null +++ b/src/core/server/authHook.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export function registerAuthHook( + app: FastifyInstance, + secretKey: string, + exemptPaths: string[] = ['/health'] +): void { + app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + const path = request.url.split('?')[0]; + if (!secretKey || exemptPaths.includes(path)) { + return; + } + const header = request.headers.authorization ?? ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; + if (token !== secretKey) { + reply.code(401).send({ error: 'unauthorized' }); + } + }); +} diff --git a/src/core/server/buildServer.ts b/src/core/server/buildServer.ts new file mode 100644 index 0000000..a4fb5e5 --- /dev/null +++ b/src/core/server/buildServer.ts @@ -0,0 +1,17 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Controller } from './Controller'; +import { registerAuthHook } from './authHook'; + +export interface BuildServerOptions { + secretKey: string; + controllers: Controller[]; +} + +export function buildServer(options: BuildServerOptions): FastifyInstance { + const app = Fastify({ logger: false }); + registerAuthHook(app, options.secretKey); + for (const controller of options.controllers) { + controller.register(app); + } + return app; +} diff --git a/test/core/server/authHook.test.ts b/test/core/server/authHook.test.ts new file mode 100644 index 0000000..e8f4e24 --- /dev/null +++ b/test/core/server/authHook.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import Fastify from 'fastify'; +import { registerAuthHook } from '../../../src/core/server/authHook'; + +function buildApp(secretKey: string) { + const app = Fastify(); + registerAuthHook(app, secretKey); + app.get('/health', async () => ({ status: 'ok' })); + app.get('/recommendations', async () => ({ items: [] })); + return app; +} + +describe('registerAuthHook', () => { + it('allows all requests through when secretKey is empty', async () => { + const app = buildApp(''); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(200); + }); + + it('always exempts /health even when a secret is configured', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects requests without a matching bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ method: 'GET', url: '/recommendations' }); + expect(res.statusCode).toBe(401); + }); + + it('allows requests with the correct bearer token', async () => { + const app = buildApp('s3cret'); + const res = await app.inject({ + method: 'GET', + url: '/recommendations', + headers: { authorization: 'Bearer s3cret' }, + }); + expect(res.statusCode).toBe(200); + }); +});