diff --git a/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md b/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md index 097d3d88..484902a2 100644 --- a/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md +++ b/docs/audits/FASTIFY_CORE_AUDIT_AND_ROADMAP_2026-03-06.md @@ -1012,8 +1012,12 @@ The ecosystem finishes with fewer duplicate patterns and fewer partial migration - Scope: readiness support, requestId in error responses, configurable startup fatal-exit behavior, idempotent signal handling, optional plugin failure policy, wrapper tests - Commit: `acfad8a` - Status: Completed -- [ ] **Increment 2 — Phase 2 shared optional JWT hook and first consumer migration** +- [x] **Increment 2 — Phase 2 shared optional JWT hook and first consumer migration** - Scope: shared optional JWT context helper in `@bytelyst/fastify-core`, direct helper tests, `mcp-server` migrated off local `parseJwt`, obsolete local parser removed + - Commit: `cfca118` + - Status: Completed +- [ ] **Increment 3 — Shared JWT helper migration in platform-service** + - Scope: `platform-service` migrated from inline optional JWT parsing to `registerOptionalJwtContext`, bootstrap tests updated, shared-package and service validations passed - Commit: `PENDING_COMMIT_SHA` - Status: In progress diff --git a/services/platform-service/src/server.test.ts b/services/platform-service/src/server.test.ts index 8ec37349..fc326760 100644 --- a/services/platform-service/src/server.test.ts +++ b/services/platform-service/src/server.test.ts @@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const resolveSecretsMock = vi.fn(async () => undefined); const createServiceAppMock = vi.fn(); +const registerOptionalJwtContextMock = vi.fn(async () => undefined); const startServiceMock = vi.fn(async () => undefined); const loadProductCacheMock = vi.fn(async () => undefined); const initCosmosIfNeededMock = vi.fn(async () => undefined); const verifyTokenMock = vi.fn(async () => ({ sub: 'user_1', productId: 'lysnrai' })); const appMock = { - addHook: vi.fn(), register: vi.fn(async () => undefined), }; @@ -32,6 +32,7 @@ vi.mock('@bytelyst/config', () => ({ vi.mock('@bytelyst/fastify-core', () => ({ createServiceApp: createServiceAppMock, + registerOptionalJwtContext: registerOptionalJwtContextMock, startService: startServiceMock, })); @@ -67,7 +68,6 @@ describe('server bootstrap', () => { vi.resetModules(); vi.clearAllMocks(); createServiceAppMock.mockResolvedValue(appMock); - appMock.addHook.mockReset(); appMock.register.mockReset(); appMock.register.mockResolvedValue(undefined); }); @@ -79,38 +79,43 @@ describe('server bootstrap', () => { expect(initCosmosIfNeededMock).toHaveBeenCalledOnce(); expect(loadProductCacheMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce(); + expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); expect(appMock.register).toHaveBeenCalled(); expect(appMock.register.mock.calls.length).toBeGreaterThan(15); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' }); }); - it('onRequest hook sets jwtPayload when bearer token is valid', async () => { + it('registers optional jwt parsing with the shared fastify-core helper', async () => { await import('./server.js'); - const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; - expect(onRequestHook).toBeTypeOf('function'); - - const req = { headers: { authorization: 'Bearer token_abc' } } as { - headers: { authorization: string }; - jwtPayload?: unknown; - }; - await onRequestHook(req); - + const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [ + unknown, + { + verifyToken: (token: string) => Promise; + }, + ]; + const options = helperCall[1]; + expect(options).toBeDefined(); + expect(options.verifyToken).toBeTypeOf('function'); + await expect(options.verifyToken('token_abc')).resolves.toEqual({ + sub: 'user_1', + productId: 'lysnrai', + }); expect(verifyTokenMock).toHaveBeenCalledWith('token_abc'); - expect(req.jwtPayload).toEqual({ sub: 'user_1', productId: 'lysnrai' }); }); - it('onRequest hook ignores invalid bearer token errors', async () => { + it('passes through verifyToken rejections to the shared helper callback', async () => { verifyTokenMock.mockRejectedValueOnce(new Error('invalid token')); await import('./server.js'); - const onRequestHook = appMock.addHook.mock.calls.find(([name]) => name === 'onRequest')?.[1]; - const req = { headers: { authorization: 'Bearer broken' } } as { - headers: { authorization: string }; - jwtPayload?: unknown; - }; + const helperCall = registerOptionalJwtContextMock.mock.calls[0] as unknown as [ + unknown, + { + verifyToken: (token: string) => Promise; + }, + ]; + const options = helperCall[1]; - await expect(onRequestHook(req)).resolves.toBeUndefined(); - expect(req.jwtPayload).toBeUndefined(); + await expect(options.verifyToken('broken')).rejects.toThrow('invalid token'); }); }); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 96f5e52e..2e25dc85 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -20,7 +20,7 @@ await resolveSecrets([ LYSNR_SECRETS.AZURE_BLOB_ACCOUNT_KEY, ]); -import { createServiceApp, startService } from '@bytelyst/fastify-core'; +import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core'; import { productRoutes } from './modules/products/routes.js'; import { loadProductCache } from './modules/products/cache.js'; import { authRoutes } from './modules/auth/routes.js'; @@ -103,20 +103,11 @@ const app = await createServiceApp({ metrics: true, }); -// Parse JWT on every request (best-effort — doesn't block unauthenticated routes) import { verifyToken } from './modules/auth/jwt.js'; import type { JwtPayload } from './lib/request-context.js'; -app.addHook('onRequest', async req => { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return; - try { - const payload = await verifyToken(auth.slice(7)); - req.jwtPayload = payload as JwtPayload; - } catch { - // Token invalid/expired — leave jwtPayload undefined. - // Auth-required routes will handle this in their own validation. - } +await registerOptionalJwtContext(app, { + verifyToken: async token => (await verifyToken(token)) as JwtPayload, }); // Register route modules