From 9a3d0e8c48dbee495d11a616a6a12b4d849ac09c Mon Sep 17 00:00:00 2001 From: Antharya Date: Sun, 31 May 2026 15:24:50 +0530 Subject: [PATCH 1/6] feat(auth): add JWT token revocation with Redis blocklist --- apps/backend/src/__tests__/logout.test.ts | 381 ++++++++++++++++++++++ apps/backend/src/app.ts | 21 +- apps/backend/src/routes/auth.ts | 70 +++- apps/backend/src/utils/jwt.ts | 26 ++ 4 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/__tests__/logout.test.ts create mode 100644 apps/backend/src/utils/jwt.ts diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts new file mode 100644 index 00000000..06245541 --- /dev/null +++ b/apps/backend/src/__tests__/logout.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import jwtPlugin from '@fastify/jwt'; +import cookiePlugin from '@fastify/cookie'; + +import { authRoutes } from '../routes/auth.js'; +import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TEST_JWT_SECRET = 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // ≥ 32 chars +const USER_ID = 'user-test-001'; +const USERNAME = 'testuser'; + +// ─── Mock Redis factory ─────────────────────────────────────────────────────── + +function createMockRedis() { + return { + exists: vi.fn().mockResolvedValue(0), + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + }; +} + +type MockRedis = ReturnType; + +// ─── App factory ───────────────────────────────────────────────────────────── +// +// Builds an isolated Fastify instance that mirrors the production authenticate +// decorator (blocklist check → jwtVerify) without needing a real database or +// Redis server. All dependencies are replaced with vitest mocks. + +async function buildTestApp(mockRedis: MockRedis): Promise { + const app = Fastify({ logger: false }); + + // Real JWT plugin — lets us sign and verify actual tokens in tests. + await app.register(jwtPlugin, { secret: TEST_JWT_SECRET }); + // Real cookie plugin — needed by extractRawJwt's cookie fallback path. + await app.register(cookiePlugin); + + // Minimal Prisma stub. The logout route does not touch the database, but + // authRoutes also registers /dev-login and /auth/me which reference + // app.prisma at request time (never reached by these tests). + app.decorate('prisma', { + user: { findUnique: vi.fn().mockResolvedValue(null) }, + } as any); + + // Mock Redis — injected so the authenticate decorator and logout handler + // can interact with it without a real Redis server. + app.decorate('redis', mockRedis as any); + + // Authenticate decorator — mirrors production logic in app.ts: + // 1. Extract raw JWT. + // 2. Check blocklist in Redis (inner try/catch — Redis failure is non-fatal). + // 3. Call jwtVerify() (outer try/catch — invalid JWT → 401). + app.decorate('authenticate', async function (request: any, reply: any) { + try { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await mockRedis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch { + // Redis failure — fail open, proceed to jwtVerify + } + } + await request.jwtVerify(); + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + await app.register(authRoutes, { prefix: '/auth' }); + + // Generic protected route — used to test the authenticate middleware + // independently of the logout handler. + app.get('/protected', { + preHandler: [(app as any).authenticate], + }, async () => ({ ok: true })); + + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function bearerHeader(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +// ─── DELETE /auth/logout ────────────────────────────────────────────────────── + +describe('DELETE /auth/logout', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — returns logged-out message and clears the token cookie', async () => { + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + + // Cookie must be cleared — Set-Cookie header should zero the token value. + const setCookie = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + expect(cookieStr).toMatch(/token=;/); + }); + + it('blocks the token in Redis with a positive TTL', async () => { + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(mockRedis.set).toHaveBeenCalledOnce(); + + const [key, value, exFlag, ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(key).toBe(blocklistKey(token)); + expect(value).toBe('1'); + expect(exFlag).toBe('EX'); + // TTL should be close to 30 days in seconds (allow 60s of test execution slack). + expect(ttl).toBeGreaterThan(30 * 24 * 60 * 60 - 60); + expect(ttl).toBeLessThanOrEqual(30 * 24 * 60 * 60); + }); + + it('uses the correct blocklist key derived from the token signature', async () => { + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + // Key must be a deterministic sha256 hash, never the raw JWT. + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + expect(key).not.toContain(token); + }); + + it('401 — rejects request with no token (unauthenticated)', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('401 — rejects request with a malformed token', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader('not.a.valid.jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('still returns 200 if Redis write fails (non-fatal)', async () => { + mockRedis.set.mockRejectedValueOnce(new Error('Redis connection lost')); + + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // Logout must succeed even when Redis is down — cookie is still cleared. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a second logout attempt with an already-revoked token', async () => { + // After the first logout the token is in the blocklist (exists returns 1). + mockRedis.exists.mockResolvedValue(1); + + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // The authenticate preHandler catches the revoked token before the handler runs. + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + // Redis write must NOT be called — handler never ran. + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); + +// ─── authenticate middleware — blocklist behaviour ──────────────────────────── + +describe('authenticate middleware', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — allows a valid non-revoked token', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('401 — rejects a revoked token with "Token has been revoked"', async () => { + mockRedis.exists.mockResolvedValue(1); // token is in the blocklist + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + }); + + it('200 — continues to allow access when Redis check throws (fail-open)', async () => { + mockRedis.exists.mockRejectedValueOnce(new Error('Redis timeout')); + const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + // Redis failure must not cause a false 401 — JWT expiry is still the guard. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a malformed token with "Unauthorized"', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader('not-a-jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); + + it('401 — rejects a request with no token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); + + it('401 — rejects a token signed with the wrong secret', async () => { + // Sign with a different secret — jwtVerify will fail. + const wrongApp = Fastify({ logger: false }); + await wrongApp.register(jwtPlugin, { secret: 'totally-different-secret-xxxxx' }); + const badToken = wrongApp.jwt.sign({ id: USER_ID }); + await wrongApp.close(); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(badToken), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); +}); + +// ─── blocklistKey utility ───────────────────────────────────────────────────── + +describe('blocklistKey', () => { + it('produces a consistent key for the same token', () => { + const token = 'header.payload.signature'; + expect(blocklistKey(token)).toBe(blocklistKey(token)); + }); + + it('produces different keys for different signatures', () => { + expect(blocklistKey('h.p.sig1')).not.toBe(blocklistKey('h.p.sig2')); + }); + + it('always starts with "blocklist:" followed by 64 hex chars', () => { + const key = blocklistKey('h.p.anysignature'); + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + }); + + it('produces the same key regardless of header or payload content', () => { + // Two tokens with different claims but the same signature produce the same key. + // (Unlikely in practice, but documents the hash-of-signature contract.) + const key1 = blocklistKey('differentHeader.differentPayload.SAME_SIG'); + const key2 = blocklistKey('anotherHeader.anotherPayload.SAME_SIG'); + expect(key1).toBe(key2); + }); +}); + +// ─── extractRawJwt utility ──────────────────────────────────────────────────── + +describe('extractRawJwt', () => { + function makeRequest(overrides: Partial<{ authorization: string; cookies: Record }>) { + return { + headers: { authorization: overrides.authorization }, + cookies: overrides.cookies ?? {}, + } as any; + } + + it('returns token from Authorization: Bearer header', () => { + const req = makeRequest({ authorization: 'Bearer my.jwt.token' }); + expect(extractRawJwt(req)).toBe('my.jwt.token'); + }); + + it('returns token from cookie when no Authorization header', () => { + const req = makeRequest({ cookies: { token: 'cookie.jwt.token' } }); + expect(extractRawJwt(req)).toBe('cookie.jwt.token'); + }); + + it('prefers Authorization header over cookie', () => { + const req = makeRequest({ + authorization: 'Bearer header.jwt.token', + cookies: { token: 'cookie.jwt.token' }, + }); + expect(extractRawJwt(req)).toBe('header.jwt.token'); + }); + + it('returns null when neither header nor cookie is present', () => { + const req = makeRequest({}); + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when Authorization header is not Bearer', () => { + const req = makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }); + expect(extractRawJwt(req)).toBeNull(); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 06b87205..6fa4242f 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -23,6 +23,7 @@ import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; import { validateEnv } from './utils/validateEnv.js'; import { teamRoutes } from './routes/team.js'; +import { extractRawJwt, blocklistKey } from './utils/jwt.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -88,9 +89,27 @@ export async function buildApp():Promise { await app.register(redisPlugin); } // ─── Auth Decorator ─── + // Checks the Redis blocklist before calling jwtVerify so that a logged-out + // token is rejected immediately even if it has not yet expired. + // The blocklist check is skipped when Redis is not registered (test env). app.decorate('authenticate', async function (request: any, reply: any) { try { - // Ensure the verified payload is assigned to `request.user` like the original plugin. + if (app.hasDecorator('redis')) { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await app.redis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch (redisErr) { + // Redis is unavailable — fail open to avoid an outage on every + // authenticated request. The JWT expiry is still the safety net. + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); + } + } + } + // Assign verified payload to request.user (upstream addition). const payload = await request.jwtVerify(); if (payload) request.user = payload; } catch (error) { diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..eb548a61 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { randomBytes } from 'crypto'; import { encrypt } from '../utils/encryption.js'; -import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -290,4 +291,71 @@ export async function authRoutes(app: FastifyInstance) { reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); + + // ─── Secure Logout — blocklists the token in Redis ─── + // + // Requires a valid JWT so that only the token's owner can revoke it. + // The token signature is hashed and stored in Redis with a TTL equal to the + // token's remaining lifetime, so the entry self-cleans when the JWT expires. + // + // Tradeoff: if Redis is down the block write is skipped (non-fatal), but the + // token will still expire naturally based on its exp claim. + + app.delete('/logout', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const raw = extractRawJwt(request); + + if (raw && app.hasDecorator('redis')) { + // jwt.decode() skips signature verification — safe here because the + // authenticate preHandler above already called jwtVerify() successfully. + const payload = app.jwt.decode<{ exp?: number }>(raw); + const exp = payload?.exp; + + if (exp) { + const ttl = exp - Math.floor(Date.now() / 1000); + if (ttl > 0) { + try { + await app.redis.set(blocklistKey(raw), '1', 'EX', ttl); + } catch (err) { + // Non-fatal: log and continue. The token will expire on its own. + app.log.warn({ err }, 'Redis blocklist write failed during logout — token will expire naturally'); + } + } + } + } + + reply.clearCookie('token', { path: '/' }); + return { message: 'Logged out' }; + }); +} + +function generateState(): string { + return randomBytes(32).toString('hex'); +} + +function buildOAuthState(clientState: string, mobileRedirectUri: string): string { + if (!clientState) { + return generateState(); + } + if (clientState.startsWith('mobile_') && mobileRedirectUri) { + const encodedRedirect = Buffer.from(mobileRedirectUri, 'utf8').toString('base64url'); + return `${clientState}.${encodedRedirect}.${generateState()}`; + } + return `${clientState}.${generateState()}`; +} + +function getMobileRedirectUri(state?: string): string | null { + if (!state?.startsWith('mobile_')) { + return null; + } + const encodedRedirect = state.split('.')[1]; + if (!encodedRedirect) { + return null; + } + try { + return Buffer.from(encodedRedirect, 'base64url').toString('utf8'); + } catch { + return null; + } } diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts new file mode 100644 index 00000000..b9e41791 --- /dev/null +++ b/apps/backend/src/utils/jwt.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto'; +import type { FastifyRequest } from 'fastify'; + +/** + * Extract the raw JWT string from a Fastify request. + * Precedence: Authorization: Bearer header → `token` cookie. + * Returns null if neither is present. + */ +export function extractRawJwt(request: FastifyRequest): string | null { + const auth = request.headers.authorization; + if (auth?.startsWith('Bearer ')) return auth.slice(7); + return request.cookies?.token ?? null; +} + +/** + * Compute the Redis blocklist key for a raw JWT. + * + * Only the signature segment (third JWT segment) is hashed. The signature is + * unique per token because it is an HMAC over the header + payload, so it + * identifies the token without storing any claims in Redis. SHA-256 of the + * signature also means the Redis key leaks nothing if Redis is compromised. + */ +export function blocklistKey(rawJwt: string): string { + const sig = rawJwt.split('.')[2] ?? rawJwt; + return `blocklist:${createHash('sha256').update(sig).digest('hex')}`; +} From 16838b6a6782c0ab3aea6161a6b10acf26c1a298 Mon Sep 17 00:00:00 2001 From: Antharya Date: Tue, 2 Jun 2026 11:51:50 +0530 Subject: [PATCH 2/6] imporved version fix(ci): resolve mobile lint/test failures and improve auth coverage fix(ci): restore selective CI script and stabilize app test env --- apps/backend/src/__tests__/analytics.test.ts | 11 +- apps/backend/src/__tests__/app.test.ts | 9 +- apps/backend/src/__tests__/cards.test.ts | 3 +- apps/backend/src/__tests__/event.test.ts | 8 +- apps/backend/src/__tests__/follow.test.ts | 2 +- apps/backend/src/__tests__/logout.test.ts | 330 +++++++++++++++++- .../backend/src/__tests__/oauth-scope.test.ts | 8 +- apps/backend/src/__tests__/profiles.test.ts | 4 +- apps/backend/src/__tests__/public.test.ts | 10 +- apps/backend/src/__tests__/team.test.ts | 7 +- .../backend/src/__tests__/validateEnv.test.ts | 1 + apps/backend/src/app.ts | 20 +- apps/backend/src/env.ts | 2 +- apps/backend/src/plugins/prisma.ts | 3 +- apps/backend/src/plugins/redis.ts | 3 +- apps/backend/src/routes/analytics.ts | 8 +- apps/backend/src/routes/auth.ts | 47 ++- apps/backend/src/routes/cards.ts | 47 +-- apps/backend/src/routes/connect.ts | 18 +- apps/backend/src/routes/event.ts | 25 +- apps/backend/src/routes/follow.ts | 8 +- apps/backend/src/routes/nfc.ts | 5 +- apps/backend/src/routes/profiles.ts | 36 +- apps/backend/src/routes/public.ts | 91 +---- apps/backend/src/routes/team.ts | 24 +- apps/backend/src/services/authService.ts | 2 +- apps/backend/src/services/cardService.ts | 14 +- apps/backend/src/services/profileService.ts | 17 +- apps/backend/src/services/publicService.ts | 10 +- apps/backend/src/utils/encryption.ts | 2 +- apps/backend/src/utils/error.util.ts | 3 +- apps/backend/src/utils/jwt.ts | 5 +- apps/backend/src/utils/slug.ts | 4 +- apps/backend/src/utils/validators.ts | 2 +- .../__mocks__/react-native-camera-kit.js | 9 + .../react-native-draggable-flatlist.js | 14 + .../__mocks__/react-native-view-shot.js | 13 + apps/mobile/jest.config.js | 8 + apps/mobile/src/screens/EventsScreen.tsx | 2 +- apps/mobile/src/screens/ScanScreen.tsx | 2 +- apps/mobile/src/screens/TeamDetailScreen.tsx | 1 - apps/mobile/src/screens/TeamsScreen.tsx | 4 +- packages/shared/eslint.config.js | 23 ++ packages/shared/package.json | 2 + pnpm-lock.yaml | 6 + 45 files changed, 593 insertions(+), 280 deletions(-) create mode 100644 apps/mobile/__mocks__/react-native-camera-kit.js create mode 100644 apps/mobile/__mocks__/react-native-draggable-flatlist.js create mode 100644 apps/mobile/__mocks__/react-native-view-shot.js create mode 100644 packages/shared/eslint.config.js diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..e6f6b607 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -1,3 +1,6 @@ +import Fastify, { + type FastifyInstance, +} from 'fastify'; import { describe, it, @@ -7,13 +10,11 @@ import { vi, } from 'vitest'; -import Fastify, { - type FastifyInstance, -} from 'fastify'; + +import { analyticsRoutes } from '../routes/analytics'; import type { PrismaClient } from '@prisma/client'; -import { analyticsRoutes } from '../routes/analytics'; // ─── Shared mock data ──────────────────────────────────────────────────────── @@ -34,7 +35,7 @@ const prismaMock = { // ─── App factory ───────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index 648d98a6..fdc09996 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -1,8 +1,13 @@ -process.env.NODE_ENV = 'test'; - import { describe, it, expect } from 'vitest'; + import { buildApp } from '../app'; +process.env.NODE_ENV = 'test'; +// validateEnv() runs inside buildApp() and exits if these are absent. +// Provide safe test-only fallbacks so CI doesn't need real secrets here. +process.env.JWT_SECRET ??= 'test-jwt-secret-not-for-production-xxxxxxxxxxxxxxxxxxxxxxx'; +process.env.ENCRYPTION_KEY ??= 'a'.repeat(64); + describe('GET /health', () => { it('should return status ok', async () => { const app = await buildApp(); diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 813883e8..97da4cae 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { cardRoutes } from '../routes/cards.js'; const USER_ID = 'user-123'; diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..06b3fe9d 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,8 +1,10 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; + import { eventRoutes } from '../routes/event'; +import type { PrismaClient } from '@prisma/client'; + // ─── Shared mock data ──────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; @@ -64,7 +66,7 @@ const prismaMock = { // // This mirrors the real app setup without touching a real DB or real JWT keys. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index 41830018..d0a44008 100644 --- a/apps/backend/src/__tests__/follow.test.ts +++ b/apps/backend/src/__tests__/follow.test.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { followRoutes } from '../routes/follow.js'; diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts index 06245541..7fe11ec0 100644 --- a/apps/backend/src/__tests__/logout.test.ts +++ b/apps/backend/src/__tests__/logout.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; -import jwtPlugin from '@fastify/jwt'; import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { authRoutes } from '../routes/auth.js'; import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; @@ -33,10 +33,21 @@ type MockRedis = ReturnType; async function buildTestApp(mockRedis: MockRedis): Promise { const app = Fastify({ logger: false }); - // Real JWT plugin — lets us sign and verify actual tokens in tests. - await app.register(jwtPlugin, { secret: TEST_JWT_SECRET }); - // Real cookie plugin — needed by extractRawJwt's cookie fallback path. - await app.register(cookiePlugin); + // cookie must be registered before jwt (required by @fastify/jwt when the + // cookie option is used) so that request.cookies is populated before + // jwtVerify() runs. + // + // Both plugins use `export =` (CJS-style) declarations. TypeScript resolves + // the overloaded type as the namespace object rather than the callable + // function when moduleResolution is "bundler", so `as any` narrows to the + // call signature Fastify's register() actually expects at runtime. + await app.register(cookiePlugin as any); + // Real JWT plugin with cookie support — mirrors the production configuration + // in app.ts so that both Authorization header and token cookie are accepted. + await app.register(jwtPlugin as any, { + secret: TEST_JWT_SECRET, + cookie: { cookieName: 'token', signed: false }, + }); // Minimal Prisma stub. The logout route does not touch the database, but // authRoutes also registers /dev-login and /auth/me which reference @@ -62,8 +73,8 @@ async function buildTestApp(mockRedis: MockRedis): Promise { if (revoked) { return reply.status(401).send({ error: 'Token has been revoked' }); } - } catch { - // Redis failure — fail open, proceed to jwtVerify + } catch (redisErr) { + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); } } await request.jwtVerify(); @@ -90,6 +101,14 @@ function bearerHeader(token: string) { return { Authorization: `Bearer ${token}` }; } +// app.jwt is added by @fastify/jwt's module augmentation. The augmentation +// is not picked up by VS Code's language server under moduleResolution:"bundler" +// for `export =` packages, so all sign() calls go through this helper to keep +// the single cast in one place rather than scattering `(app as any)` everywhere. +function signToken(app: FastifyInstance, payload: object, options?: Record): string { + return (app as any).jwt.sign(payload, options); +} + // ─── DELETE /auth/logout ────────────────────────────────────────────────────── describe('DELETE /auth/logout', () => { @@ -107,7 +126,7 @@ describe('DELETE /auth/logout', () => { }); it('200 — returns logged-out message and clears the token cookie', async () => { - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'DELETE', @@ -125,7 +144,7 @@ describe('DELETE /auth/logout', () => { }); it('blocks the token in Redis with a positive TTL', async () => { - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); await app.inject({ method: 'DELETE', @@ -145,7 +164,7 @@ describe('DELETE /auth/logout', () => { }); it('uses the correct blocklist key derived from the token signature', async () => { - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); await app.inject({ method: 'DELETE', @@ -184,7 +203,7 @@ describe('DELETE /auth/logout', () => { it('still returns 200 if Redis write fails (non-fatal)', async () => { mockRedis.set.mockRejectedValueOnce(new Error('Redis connection lost')); - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'DELETE', @@ -200,7 +219,7 @@ describe('DELETE /auth/logout', () => { // After the first logout the token is in the blocklist (exists returns 1). mockRedis.exists.mockResolvedValue(1); - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'DELETE', @@ -214,6 +233,137 @@ describe('DELETE /auth/logout', () => { // Redis write must NOT be called — handler never ran. expect(mockRedis.set).not.toHaveBeenCalled(); }); + + it('401 — expired token is rejected and does not write to Redis', async () => { + const realNow = Date.now(); + // Sign with 1-second expiry so we can advance the clock past it. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 1 }); + + // Fake only the Date object (not timers) so jwtVerify sees the token as + // expired without blocking the async inject pipeline. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(realNow + 2000); + + try { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + // Authenticate preHandler rejects the expired token; handler never runs. + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('200 — works when JWT is sent via cookie (web browser flow)', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + // Token extracted from cookie must still be blocklisted in Redis. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + }); + + it('200 — Authorization header takes precedence over cookie when both are present', async () => { + const headerToken = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const cookieToken = signToken(app, { id: 'other-user', username: 'other' }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { + Authorization: `Bearer ${headerToken}`, + Cookie: `token=${cookieToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + // The header token must be blocklisted — not the cookie token. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(headerToken)); + expect(key).not.toBe(blocklistKey(cookieToken)); + }); + + it('200 — Set-Cookie response clears token with Path=/ and a past Expires date', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + const raw = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(raw) ? raw.join('; ') : (raw ?? ''); + // Value must be emptied. + expect(cookieStr).toMatch(/token=;/); + // Path must be explicit so the browser clears the cookie on all routes. + expect(cookieStr).toMatch(/Path=\//i); + // Browser must be told to delete the cookie immediately. + expect(cookieStr).toMatch(/Expires=|Max-Age=0/i); + }); + + it('200 — near-expiry token gets a short positive TTL in Redis', async () => { + // Token that expires in 5 seconds — the blocklist TTL must still be positive. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 5 }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [, , , ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(5); + }); + + it('200 — logs warning and skips Redis write when JWT has no exp claim', async () => { + // Signing without expiresIn produces a token with no exp field. + const token = signToken(app, { id: USER_ID, username: USERNAME }); + const warnMock = vi.fn(); + // Replace the logger's warn method so we can assert it was called. + (app.log as any).warn = warnMock; + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledOnce(); + // Verify the message identifies the root cause clearly. + const [, message] = warnMock.mock.calls[0] as [unknown, string]; + expect(message).toMatch(/missing exp/i); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Authorization: 'Bearer ' }, + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); }); // ─── authenticate middleware — blocklist behaviour ──────────────────────────── @@ -234,7 +384,7 @@ describe('authenticate middleware', () => { it('200 — allows a valid non-revoked token', async () => { mockRedis.exists.mockResolvedValue(0); - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'GET', @@ -250,7 +400,7 @@ describe('authenticate middleware', () => { it('401 — rejects a revoked token with "Token has been revoked"', async () => { mockRedis.exists.mockResolvedValue(1); // token is in the blocklist - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'GET', @@ -264,7 +414,7 @@ describe('authenticate middleware', () => { it('200 — continues to allow access when Redis check throws (fail-open)', async () => { mockRedis.exists.mockRejectedValueOnce(new Error('Redis timeout')); - const token = app.jwt.sign({ id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); const res = await app.inject({ method: 'GET', @@ -300,8 +450,8 @@ describe('authenticate middleware', () => { it('401 — rejects a token signed with the wrong secret', async () => { // Sign with a different secret — jwtVerify will fail. const wrongApp = Fastify({ logger: false }); - await wrongApp.register(jwtPlugin, { secret: 'totally-different-secret-xxxxx' }); - const badToken = wrongApp.jwt.sign({ id: USER_ID }); + await wrongApp.register(jwtPlugin as any, { secret: 'totally-different-secret-xxxxx' }); + const badToken = signToken(wrongApp, { id: USER_ID }); await wrongApp.close(); const res = await app.inject({ @@ -313,6 +463,136 @@ describe('authenticate middleware', () => { expect(res.statusCode).toBe(401); expect(res.json().error).toBe('Unauthorized'); }); + + it('200 — allows authenticated request when JWT is sent via cookie', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + // Blocklist check must still run — the key is derived from the cookie token. + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('logs a warning when the Redis check throws and still allows valid JWT through', async () => { + const warnMock = vi.fn(); + (app.log as any).warn = warnMock; + mockRedis.exists.mockRejectedValueOnce(new Error('ECONNREFUSED')); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(warnMock).toHaveBeenCalledOnce(); + const [obj, message] = warnMock.mock.calls[0] as [{ err: Error }, string]; + expect(message).toMatch(/blocklist check failed/i); + expect(obj.err).toBeInstanceOf(Error); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Authorization: 'Bearer ' }, + }); + + // extractRawJwt returns '' (falsy) — blocklist check is skipped, + // jwtVerify receives an empty token and throws. + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); +}); + +// ─── Revocation flow — end-to-end ──────────────────────────────────────────── +// +// Verifies the full lifecycle: token works → logout blocklists it → +// authenticate rejects it. This is the critical security invariant. + +describe('revocation flow — end-to-end', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('token is usable before logout and rejected after blocklisting', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Step 1: token is valid — protected route responds 200. + const before = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(before.statusCode).toBe(200); + + // Step 2: logout succeeds and writes the key to the blocklist. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + + // Step 3: simulate Redis now returning 1 for this token's blocklist key. + // (In production this is automatic — the SET from step 2 persists in Redis.) + mockRedis.exists.mockResolvedValueOnce(1); + + // Step 4: same token is now rejected by the authenticate middleware. + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); + + it('cookie-delivered token is also rejected after logout', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Logout via cookie — browser clients never send an Authorization header. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `token=${token}` }, + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + // The blocklist key must match the token delivered via cookie. + const [writtenKey] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(writtenKey).toBe(blocklistKey(token)); + + // Simulate blocklist hit on next request. + mockRedis.exists.mockResolvedValueOnce(1); + + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `token=${token}` }, + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); }); // ─── blocklistKey utility ───────────────────────────────────────────────────── @@ -378,4 +658,16 @@ describe('extractRawJwt', () => { const req = makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }); expect(extractRawJwt(req)).toBeNull(); }); + + it('returns null when Authorization is "Bearer " with no token after the space', () => { + const req = makeRequest({ authorization: 'Bearer ' }); + // slice(7) || null normalises the empty string to null. + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when the token cookie value is empty', () => { + const req = makeRequest({ cookies: { token: '' } }); + // || null normalises the empty string to null, matching the return type. + expect(extractRawJwt(req)).toBeNull(); + }); }); diff --git a/apps/backend/src/__tests__/oauth-scope.test.ts b/apps/backend/src/__tests__/oauth-scope.test.ts index 0985dfa7..d814e449 100644 --- a/apps/backend/src/__tests__/oauth-scope.test.ts +++ b/apps/backend/src/__tests__/oauth-scope.test.ts @@ -11,10 +11,12 @@ * flow so the two records are independent and can never overwrite each other. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { connectRoutes } from '../routes/connect.js'; import { followRoutes } from '../routes/follow.js'; + import type { PrismaClient } from '@prisma/client'; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -45,7 +47,7 @@ function makeConnectState(userId: string): string { function buildConnectApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); - app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); + app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; }); app.register(connectRoutes, { prefix: '/api/connect' }); return app.ready().then(() => app); } @@ -55,7 +57,7 @@ function buildConnectApp(mockPrisma: Partial) { function buildFollowApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); - app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); + app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; }); app.register(followRoutes, { prefix: '/api/follow' }); return app.ready().then(() => app); } diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts index 07d10f98..31e6cdce 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { profileRoutes } from '../routes/profiles.js'; + import type { PrismaClient } from '@prisma/client'; const mockUser = { diff --git a/apps/backend/src/__tests__/public.test.ts b/apps/backend/src/__tests__/public.test.ts index a767b25d..8e825782 100644 --- a/apps/backend/src/__tests__/public.test.ts +++ b/apps/backend/src/__tests__/public.test.ts @@ -1,9 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { publicRoutes } from '../routes/public.js'; +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + import type { PrismaClient } from '@prisma/client'; + // ── Mock QR utilities ───────────────────────────────────────────────────────── // Prevents real QR rasterisation (and any native canvas/image deps) from running // during unit tests. The stubs return minimal valid values that satisfy the @@ -13,8 +17,6 @@ vi.mock('../utils/qr.js', () => ({ generateQRSvg: vi.fn().mockResolvedValue('fake'), })); -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; - const mockUser = { id: 'user-123', username: 'testuser', diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 350298a1..7904a311 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -1,6 +1,7 @@ +import { type PrismaClient, TeamRole } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient, TeamRole } from '@prisma/client'; + import { teamRoutes } from '../routes/team'; // ─── Shared mock data ───────────────────────────────────────────────────────── @@ -92,7 +93,7 @@ const prismaMock = { // ─── App factory ────────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); diff --git a/apps/backend/src/__tests__/validateEnv.test.ts b/apps/backend/src/__tests__/validateEnv.test.ts index eb0574bd..c806919b 100644 --- a/apps/backend/src/__tests__/validateEnv.test.ts +++ b/apps/backend/src/__tests__/validateEnv.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; + import { validateEnv } from '../utils/validateEnv.js'; // ── helpers ────────────────────────────────────────────────────────────────── diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6fa4242f..0115868b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,7 +7,6 @@ import helmet from '@fastify/helmet'; import jwt from '@fastify/jwt'; import multipart from '@fastify/multipart'; import rateLimit from '@fastify/rate-limit'; -import fastifyStatic from '@fastify/static'; import Fastify, {type FastifyInstance} from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; @@ -21,9 +20,9 @@ import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; -import { validateEnv } from './utils/validateEnv.js'; import { teamRoutes } from './routes/team.js'; import { extractRawJwt, blocklistKey } from './utils/jwt.js'; +import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -66,12 +65,19 @@ export async function buildApp():Promise { }, }); + // cookie must be registered before jwt so that @fastify/jwt can read the + // `token` cookie during jwtVerify() for browser-based clients. + await app.register(cookie); + await app.register(jwt, { // validateEnv() above guarantees JWT_SECRET is present and safe. secret: process.env.JWT_SECRET!, + cookie: { + // Matches the cookie name set in the OAuth callback handlers. + cookieName: 'token', + signed: false, + }, }); - - await app.register(cookie); await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); // 5MB await app.register(rateLimit, { max: 100, @@ -111,9 +117,9 @@ export async function buildApp():Promise { } // Assign verified payload to request.user (upstream addition). const payload = await request.jwtVerify(); - if (payload) request.user = payload; - } catch (error) { - reply.status(401).send({ error: 'Unauthorized' }); + if (payload) { request.user = payload; } + } catch (_err) { + return reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7d841d9c..4840d20c 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,6 +1,6 @@ -import process from 'node:process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/apps/backend/src/plugins/prisma.ts b/apps/backend/src/plugins/prisma.ts index f6ebede8..ec2d74aa 100644 --- a/apps/backend/src/plugins/prisma.ts +++ b/apps/backend/src/plugins/prisma.ts @@ -1,5 +1,6 @@ -import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; +import fp from 'fastify-plugin'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; declare module 'fastify' { diff --git a/apps/backend/src/plugins/redis.ts b/apps/backend/src/plugins/redis.ts index 864b112f..881c289b 100644 --- a/apps/backend/src/plugins/redis.ts +++ b/apps/backend/src/plugins/redis.ts @@ -1,5 +1,6 @@ import fp from 'fastify-plugin'; import Redis from 'ioredis'; + import type { FastifyInstance } from 'fastify'; declare module 'fastify' { @@ -17,7 +18,7 @@ export const redisPlugin = fp(async (app: FastifyInstance) => { try { await redis.connect(); app.log.info('🔴 Redis connected'); - } catch (error) { + } catch { app.log.warn('⚠️ Redis connection failed — running without cache'); } diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index a975424f..3b7fced4 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -11,8 +11,8 @@ export async function analyticsRoutes( app.get( '/overview', { - // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( request: FastifyRequest, @@ -96,8 +96,8 @@ export async function analyticsRoutes( }>( '/views', { - // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( request: FastifyRequest<{ diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index eb548a61..fd1d1d48 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,8 +1,10 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; + import { encrypt } from '../utils/encryption.js'; import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; const GITHUB_USER_URL = 'https://api.github.com/user'; @@ -254,12 +256,10 @@ export async function authRoutes(app: FastifyInstance) { }); // Current user - app.get('/me', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/me', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ where: { id: userId }, @@ -287,7 +287,28 @@ export async function authRoutes(app: FastifyInstance) { return { ...userData, connectedPlatforms: oauthTokens }; }); + // Legacy cookie-clear endpoint kept for backward compatibility. + // Also opportunistically blocklists the token when one is present so that + // existing clients that call POST instead of DELETE also get proper revocation. + // No auth required — the token is decoded but not verified here; this is safe + // because the blocklist key is derived from the signature which cannot be + // forged without the JWT secret. app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { + const raw = extractRawJwt(request); + if (raw && app.hasDecorator('redis')) { + const payload = app.jwt.decode<{ exp?: number }>(raw); + const exp = payload?.exp; + if (exp) { + const ttl = exp - Math.floor(Date.now() / 1000); + if (ttl > 0) { + try { + await app.redis.set(blocklistKey(raw), '1', 'EX', ttl); + } catch (err) { + app.log.warn({ err }, 'Redis blocklist write failed during POST logout — token will expire naturally'); + } + } + } + } reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); @@ -302,6 +323,7 @@ export async function authRoutes(app: FastifyInstance) { // token will still expire naturally based on its exp claim. app.delete('/logout', { + // eslint-disable-next-line @typescript-eslint/unbound-method preHandler: [app.authenticate], }, async (request: FastifyRequest, reply: FastifyReply) => { const raw = extractRawJwt(request); @@ -322,6 +344,15 @@ export async function authRoutes(app: FastifyInstance) { app.log.warn({ err }, 'Redis blocklist write failed during logout — token will expire naturally'); } } + } else { + // A JWT without exp cannot be given a finite Redis TTL, so it cannot be + // actively revoked. This should never happen with tokens signed by this + // server (we always pass expiresIn), but log a warning so it is + // visible if a custom or third-party token ever reaches this path. + app.log.warn( + { userId: (request.user as any)?.id }, + 'JWT missing exp claim — skipping Redis blocklist; token cannot be actively revoked', + ); } } diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 32fe835c..9c6f123f 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,9 +1,8 @@ +import * as cardService from '../services/cardService' import { handleDbError } from '../utils/error.util.js'; import { createCardSchema, updateCardSchema } from '../utils/validators.js'; -import * as cardService from '../services/cardService' import type { Card } from '@devcard/shared'; -import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -21,40 +20,12 @@ interface CardParams { id: string; } -interface PlatformLink { - id: string; - userId: string; - platform: string; - username: string; - url: string; - displayOrder: number; - createdAt: Date; -} - -interface CardLinkWithPlatform { - id: string; - cardId: string; - platformLinkId: string; - displayOrder: number; - platformLink: PlatformLink; -} - -interface CardWithLinks { - id: string; - userId: string; - title: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; - cardLinks: CardLinkWithPlatform[]; -} - export async function cardRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── List Cards ─── @@ -82,7 +53,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { const card = await cardService.createCard(app, userId, parsed.data) return reply.status(201).send(card) } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) + if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } }); @@ -95,12 +66,12 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const parsed = updateCardSchema.safeParse(request.body) - if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) + if (!parsed.success) {return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })} const updated = await cardService.updateCard(app, userId, id, parsed.data) - if (!updated) return reply.status(404).send({ error: 'Card not found' }) + if (!updated) {return reply.status(404).send({ error: 'Card not found' })} return updated } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) + if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } }); @@ -113,8 +84,8 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const res = await cardService.deleteCard(app, userId, id) - if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' }) - if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' }) + if (res && (res as any).code === 'NOT_FOUND') {return reply.status(404).send({ error: 'Card not found' })} + if (res && (res as any).code === 'LAST_CARD') {return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' })} return reply.status(204).send() } catch (error) { return handleDbError(error, request, reply) @@ -129,7 +100,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const resp = await cardService.setDefaultCard(app, userId, id) - if (!resp) return reply.status(404).send({ error: 'Card not found' }) + if (!resp) {return reply.status(404).send({ error: 'Card not found' })} return resp } catch (error) { return handleDbError(error, request, reply) diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..de85daa4 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,7 +1,9 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; + import { encrypt } from '../utils/encryption.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -30,9 +32,9 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], - }, async (request: FastifyRequest, reply: FastifyReply) => { + }, async (request: FastifyRequest, _reply: FastifyReply) => { const userId = (request.user as any).id; const tokens = await app.prisma.oAuthToken.findMany({ @@ -50,7 +52,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -102,7 +104,7 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) {await app.redis.del(`oauth:nonce:${decodedState.nonce}`);} const userId = decodedState.userId; @@ -175,7 +177,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +198,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch { return reply.status(404).send({ error: 'Connection not found' }); } }); diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..9fb6b443 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,7 +1,8 @@ +import {generateUniqueSlug} from '../utils/slug' +import { createEventSchema } from '../validations/event.validation'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation'; -import {generateUniqueSlug} from '../utils/slug' type EventDetails = { @@ -62,7 +63,7 @@ export async function eventRoutes(app:FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async (request: FastifyRequest<{ Body: { name: string, @@ -80,8 +81,8 @@ export async function eventRoutes(app:FastifyInstance) { const {name, description, startDate, endDate, isPublic ,location} = parsed.data - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) + const finalSlug = await generateUniqueSlug(name, async(slug) => { + const existing = await app.prisma.event.findUnique({where: {slug}}) return !!existing }) @@ -95,7 +96,7 @@ export async function eventRoutes(app:FastifyInstance) { name, description, slug: finalSlug, - location: location, + location, startDate: startDateObj, endDate: endDateObj, isPublic: isPublic ?? true, @@ -104,8 +105,8 @@ export async function eventRoutes(app:FastifyInstance) { }) return reply.status(201).send(newEvent); - } catch (error) { - app.log.error('Failed to create event'); + } catch { + app.log.error('Failed to create event'); return reply.status(500).send({error: 'Failed to create event'}) } @@ -153,7 +154,7 @@ export async function eventRoutes(app:FastifyInstance) { return response; }) - app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; @@ -171,7 +172,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.create({ data: { eventId: event.id, - userId: userId, + userId, joinedAt: new Date() } }) @@ -187,7 +188,7 @@ export async function eventRoutes(app:FastifyInstance) { }) - app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; @@ -205,7 +206,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.delete({ where: { userId_eventId: { - userId: userId, + userId, eventId: event.id } } diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index a152fc55..d2cba7e9 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -1,15 +1,17 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; + import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; -import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; import { followLogSchema } from '../validations/follow.validation.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + export async function followRoutes(app: FastifyInstance) { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── Follow via API (Layer 1) ─── diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 5cf13f0c..f834b714 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + type NfcPayloadResponse = { type: 'URI'; payload: string; @@ -23,7 +24,7 @@ export async function nfcRoutes(app: FastifyInstance) { } try { await request.jwtVerify(); - } catch (e) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 81026c74..69666a74 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,25 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { getProfileUrl } from '@devcard/shared'; -import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; -import { getErrorMessage } from '../utils/error.util.js'; import * as profileService from '../services/profileService' +import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; -// ── Response types ──────────────────────────────────────────────────────────── -// Declared explicitly so the API contract is visible without tracing through -// Prisma's generic return types. Follows the convention in public.ts. - -type ProfileUpdateResponse = { - id: string; - email: string; - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; -}; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; export async function profileRoutes(app: FastifyInstance) { // All profile routes require auth @@ -35,7 +17,7 @@ export async function profileRoutes(app: FastifyInstance) { } try { await request.jwtVerify(); - } catch (e) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); @@ -45,7 +27,7 @@ export async function profileRoutes(app: FastifyInstance) { app.get('/me', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await profileService.getOwnProfile(app, userId) - if (!user) return reply.status(404).send({ error: 'User not found' }) + if (!user) {return reply.status(404).send({ error: 'User not found' })} return user }); @@ -80,7 +62,7 @@ export async function profileRoutes(app: FastifyInstance) { const response = await profileService.updateProfile(app, userId, parsed.data) return response } catch (err: any) { - if (err?.code === 'P2002') return reply.status(409).send({ error: 'Username already taken' }) + if (err?.code === 'P2002') {return reply.status(409).send({ error: 'Username already taken' })} app.log.error({ err }, 'DB error in PUT /profiles/me') return reply.status(500).send({ error: 'Internal server error' }) } @@ -112,10 +94,10 @@ export async function profileRoutes(app: FastifyInstance) { const { id } = request.params; const parsedReq = createLinkSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const updated = await profileService.updatePlatformLink(app, userId, id, parsedReq.data) - if (!updated) return reply.status(404).send({ error: 'Link not found' }) + if (!updated) {return reply.status(404).send({ error: 'Link not found' })} return updated } catch (err: any) { app.log.error({ err }, 'Failed to update platform link') @@ -131,7 +113,7 @@ export async function profileRoutes(app: FastifyInstance) { try { const deleted = await profileService.deletePlatformLink(app, userId, id) - if (!deleted) return reply.status(404).send({ error: 'Link not found' }) + if (!deleted) {return reply.status(404).send({ error: 'Link not found' })} return reply.status(204).send() } catch (err: any) { app.log.error({ err }, 'Failed to delete platform link') @@ -144,7 +126,7 @@ export async function profileRoutes(app: FastifyInstance) { app.put('/me/links/reorder', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const parsedReq = reorderLinksSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const resp = await profileService.reorderLinks(app, userId, parsedReq.data.links) return resp diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..06e939e6 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,8 +1,8 @@ -import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; -import type { PlatformLink } from '@devcard/shared'; -import { getErrorMessage } from '../utils/error.util.js'; import * as publicService from '../services/publicService' +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + +import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + // ── QR size bounds ──────────────────────────────────────────────────────────── @@ -16,79 +16,8 @@ const MAX_QR_SIZE = 2048; // Public profile cache TTL matches the Cache-Control max-age (5 minutes). // The QR session JWT TTL is 10 minutes so an offline scan remains valid well // beyond the HTTP cache window. -const PROFILE_CACHE_TTL = 300; // seconds (5 minutes) const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; -type PublicProfileLink = { - id: string; - platform: string; - username: string; - url: string; - displayOrder: number; - followed?: boolean; -} - -type UsernamePublicProfileResponse = { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - links: PublicProfileLink[] -} - -type PublicProfileCardLink = { - id: string; - platform: string; - username: string; - url: string; - followed?: boolean; -} - -type CardPublicProfileResponse = { - id: string; - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - -type UsernameCardPublicProfileResponse = { - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - -// Represents a CardLink record with the joined PlatformLink relation -interface CardLinkWithPlatform { - id: string; - displayOrder: number; - platformLink: PlatformLink; -} - -// ── Internal Redis cache shape ──────────────────────────────────────────────── -// Extends the public response with the owner's DB id so that background view -// tracking can still fire on cache-HIT requests without an extra DB read. -type CachedProfileEntry = UsernamePublicProfileResponse & { _userId: string }; - - export async function publicRoutes(app: FastifyInstance) { // ─── Public Profile ─────────────────────────────────────────────────────── // ─── Public Profile ─── @@ -105,7 +34,7 @@ export async function publicRoutes(app: FastifyInstance) { }, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; + // Try to extract viewer from Authorization header (soft auth). let viewerId: string | null = null @@ -122,7 +51,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getPublicProfile(app, username, viewerId, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) {return reply.status(404).send({ error: 'User not found' })} reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER) return result.data } catch (err: any) { @@ -150,7 +79,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const card = await publicService.getCardById(app, cardId) - if (!card) return reply.status(404).send({ error: 'Card not found' }) + if (!card) {return reply.status(404).send({ error: 'Card not found' })} const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } return response } catch (err: any) { @@ -188,7 +117,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getUserCard(app, username, cardId, viewerId, request) - if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }) + if (result.notFound) {return reply.status(404).send({ error: 'User or card not found' })} return result.data } catch (err: any) { app.log.error({ err }, 'Failed to fetch user card') @@ -209,11 +138,11 @@ export async function publicRoutes(app: FastifyInstance) { } as FastifyContextConfig }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; + try { const result = await publicService.getPublicProfile(app, username, null, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) {return reply.status(404).send({ error: 'User not found' })} const snapshot = result.data const expiresIn = 600 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index af177e52..1274d966 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -29,7 +29,7 @@ export async function teamRoutes(app:FastifyInstance){ const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{ Body: {name: string, description? : string, avatarUrl?: string } }>, reply: FastifyReply) => { @@ -48,7 +48,7 @@ export async function teamRoutes(app:FastifyInstance){ try { const team = await app.prisma.$transaction(async (tx) => { - const team = await tx.team.create({ + const created = await tx.team.create({ data: { name, slug: finalSlug, @@ -60,14 +60,14 @@ export async function teamRoutes(app:FastifyInstance){ await tx.teamMember.create({ data: { - teamId : team.id, - userId, - role: TeamRole.OWNER, - joinedAt: new Date(), + teamId : created.id, + userId, + role: TeamRole.OWNER, + joinedAt: new Date(), } }) - return team - }) + return created + }) return reply.status(201).send(team) }catch (error) { @@ -161,7 +161,7 @@ export async function teamRoutes(app:FastifyInstance){ const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug:string}, Body:{username:string}}>, reply: FastifyReply) => { const paramsSlug = request.params.slug; const userId = (request.user as any).id; @@ -224,7 +224,7 @@ export async function teamRoutes(app:FastifyInstance){ } }) - app.delete('/:slug/members/:userId', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string, userId: string}}>, reply: FastifyReply) => { + app.delete('/:slug/members/:userId', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string, userId: string}}>, reply: FastifyReply) => { const paramsSlug = request.params.slug const paramsUserId = request.params.userId const userID = (request.user as any).id; @@ -286,7 +286,7 @@ export async function teamRoutes(app:FastifyInstance){ } }) - app.patch('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string},Body: {description?:string, name?:string, avatarUrl?:string}}>, reply: FastifyReply) => { + app.patch('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string},Body: {description?:string, name?:string, avatarUrl?:string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; const parsed = updateTeam.safeParse(request.body); @@ -328,7 +328,7 @@ export async function teamRoutes(app:FastifyInstance){ }) - app.delete('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{Params:{slug: string}}>, reply:FastifyReply) => { + app.delete('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{Params:{slug: string}}>, reply:FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; diff --git a/apps/backend/src/services/authService.ts b/apps/backend/src/services/authService.ts index 9af718c5..c9b839bb 100644 --- a/apps/backend/src/services/authService.ts +++ b/apps/backend/src/services/authService.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; export function generateState(): string { return randomBytes(32).toString('hex'); diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..216a98b8 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance } from 'fastify' import type { Prisma } from '@prisma/client' +import type { FastifyInstance } from 'fastify' export async function listCards(app: FastifyInstance, userId: string) { const cards = await app.prisma.card.findMany({ @@ -15,7 +15,7 @@ export async function listCards(app: FastifyInstance, userId: string) { export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + if (ownedLinks.length !== body.linkIds.length) {throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })} } const cardCount = await app.prisma.card.count({ where: { userId } }) @@ -35,7 +35,7 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} if (body.title) { await app.prisma.card.update({ where: { id }, data: { title: body.title } }) @@ -44,7 +44,7 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin if (body.linkIds) { if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + if (ownedLinks.length !== body.linkIds.length) {throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })} } const linkIds = body.linkIds @@ -63,10 +63,10 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin export async function deleteCard(app: FastifyInstance, userId: string, id: string) { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + if (!existing) {return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })} const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + if (userCardCount <= 1) {return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })} if (existing.isDefault) { const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) @@ -82,7 +82,7 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index dc97b2a4..385709b1 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,8 +1,9 @@ -import type { FastifyInstance } from 'fastify' import { getProfileUrl } from '@devcard/shared' -import type { PlatformLink } from '@devcard/shared' + import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + export async function getOwnProfile(app: FastifyInstance, userId: string) { const user = await app.prisma.user.findUnique({ where: { id: userId }, @@ -12,9 +13,9 @@ export async function getOwnProfile(app: FastifyInstance, userId: string) { }, }) - if (!user) return null + if (!user) {return null} - const { provider, providerId, ...profileData } = user as any + const { provider: _provider, providerId: _providerId, ...profileData } = user as any return { ...profileData, defaultCardId: user.cards[0]?.id || null } } @@ -24,7 +25,7 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: const existing = await app.prisma.user.findFirst({ where: { username: data.username, NOT: { id: userId } }, }) - if (existing) throw Object.assign(new Error('Username taken'), { code: 'P2002' }) + if (existing) {throw Object.assign(new Error('Username taken'), { code: 'P2002' })} } const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } }) @@ -42,7 +43,7 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: return response } catch (err: any) { - if (err?.code === 'P2002') throw err + if (err?.code === 'P2002') {throw err} app.log.error({ err }, 'DB error in updateProfile') throw err } @@ -56,14 +57,14 @@ export async function createPlatformLink(app: FastifyInstance, userId: string, l export async function updatePlatformLink(app: FastifyInstance, userId: string, id: string, linkData: any) { const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} const url = linkData.url || getProfileUrl(linkData.platform, linkData.username) return app.prisma.platformLink.update({ where: { id }, data: { platform: linkData.platform, username: linkData.username, url } }) } export async function deletePlatformLink(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return false + if (!existing) {return false} await app.prisma.platformLink.delete({ where: { id } }) return true } diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..0e58cdc9 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,8 +1,8 @@ -import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + const PROFILE_CACHE_TTL = 300 -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { const cacheKey = `profile:${username}` @@ -23,7 +23,7 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v } const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + if (!user) {return null} if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) @@ -54,9 +54,9 @@ export async function getCardById(app: FastifyInstance, cardId: string) { export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } + if (!user) {return { notFound: true }} const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } + if (!card) {return { notFound: true }} if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) diff --git a/apps/backend/src/utils/encryption.ts b/apps/backend/src/utils/encryption.ts index b9105992..adfb3172 100644 --- a/apps/backend/src/utils/encryption.ts +++ b/apps/backend/src/utils/encryption.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index fef1b98b..d9885d09 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -1,6 +1,7 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts index b9e41791..40386962 100644 --- a/apps/backend/src/utils/jwt.ts +++ b/apps/backend/src/utils/jwt.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; + import type { FastifyRequest } from 'fastify'; /** @@ -8,8 +9,8 @@ import type { FastifyRequest } from 'fastify'; */ export function extractRawJwt(request: FastifyRequest): string | null { const auth = request.headers.authorization; - if (auth?.startsWith('Bearer ')) return auth.slice(7); - return request.cookies?.token ?? null; + if (auth?.startsWith('Bearer ')) { return auth.slice(7) || null; } + return request.cookies?.token || null; } /** diff --git a/apps/backend/src/utils/slug.ts b/apps/backend/src/utils/slug.ts index 24b772f3..4f0d0fcd 100644 --- a/apps/backend/src/utils/slug.ts +++ b/apps/backend/src/utils/slug.ts @@ -10,9 +10,9 @@ export async function generateUniqueSlug(name: string, while(true){ const exists = await slugExists(finalSlug) - if(!exists) break; + if(!exists) {break;} - const randomSuffix = Math.random().toString(36).substring(2,6); + const randomSuffix = Math.random().toString(36).slice(2,6); finalSlug = `${cleanSlug}-${randomSuffix}` } return finalSlug; diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..d2f11579 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { getPlatform } from '@devcard/shared'; +import { z } from 'zod'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), diff --git a/apps/mobile/__mocks__/react-native-camera-kit.js b/apps/mobile/__mocks__/react-native-camera-kit.js new file mode 100644 index 00000000..206fa817 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-camera-kit.js @@ -0,0 +1,9 @@ +const React = require('react'); +const { View } = require('react-native'); + +module.exports = { + __esModule: true, + Camera: React.forwardRef((_props, _ref) => React.createElement(View, null)), + CameraType: { Back: 'back', Front: 'front' }, + FlashMode: { Auto: 'auto', On: 'on', Off: 'off' }, +}; diff --git a/apps/mobile/__mocks__/react-native-draggable-flatlist.js b/apps/mobile/__mocks__/react-native-draggable-flatlist.js new file mode 100644 index 00000000..0d9e5b9d --- /dev/null +++ b/apps/mobile/__mocks__/react-native-draggable-flatlist.js @@ -0,0 +1,14 @@ +const React = require('react'); +const { FlatList } = require('react-native'); + +// Minimal stub used in Jest tests only. +// DraggableFlatList exposes the same props as FlatList so this renders +// correctly in snapshot/render tests without the native gesture runtime. +const ScaleDecorator = ({ children }) => + React.createElement(React.Fragment, null, children); + +module.exports = { + __esModule: true, + default: FlatList, + ScaleDecorator, +}; diff --git a/apps/mobile/__mocks__/react-native-view-shot.js b/apps/mobile/__mocks__/react-native-view-shot.js new file mode 100644 index 00000000..29b04b63 --- /dev/null +++ b/apps/mobile/__mocks__/react-native-view-shot.js @@ -0,0 +1,13 @@ +const React = require('react'); +const { View } = require('react-native'); + +const ViewShot = React.forwardRef(({ children }, _ref) => + React.createElement(View, null, children) +); + +module.exports = { + __esModule: true, + default: ViewShot, + captureRef: jest.fn().mockResolvedValue('file://mock-screenshot.png'), + captureScreen: jest.fn().mockResolvedValue('file://mock-screenshot.png'), +}; diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 59cb29d8..76d029f0 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -4,4 +4,12 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!((react-native|@react-native|@react-navigation|@gorhom)/|\\.pnpm/(react-native|@react-native|@react-navigation|@gorhom)[^/]*))', ], + moduleNameMapper: { + // Native packages that either lack a Jest-compatible build or are not + // installed as test dependencies. Mapped to local stubs so Jest bypasses + // node_modules resolution entirely. + 'react-native-draggable-flatlist': '/__mocks__/react-native-draggable-flatlist.js', + 'react-native-view-shot': '/__mocks__/react-native-view-shot.js', + 'react-native-camera-kit': '/__mocks__/react-native-camera-kit.js', + }, }; diff --git a/apps/mobile/src/screens/EventsScreen.tsx b/apps/mobile/src/screens/EventsScreen.tsx index c4dbf7bf..c45da697 100644 --- a/apps/mobile/src/screens/EventsScreen.tsx +++ b/apps/mobile/src/screens/EventsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { View, Text, StyleSheet, TextInput, TouchableOpacity, StatusBar, Alert, diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index 1f300351..62c8776c 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -104,7 +104,7 @@ export default function ScanScreen({ navigation }: Props) { title: 'My DevCard QR', url: uri, }); - } catch (err) { + } catch { Alert.alert('Error', 'Failed to save QR code'); } } diff --git a/apps/mobile/src/screens/TeamDetailScreen.tsx b/apps/mobile/src/screens/TeamDetailScreen.tsx index 9503bb72..ceb88925 100644 --- a/apps/mobile/src/screens/TeamDetailScreen.tsx +++ b/apps/mobile/src/screens/TeamDetailScreen.tsx @@ -4,7 +4,6 @@ import { StatusBar, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import Avatar from '../components/Avatar'; import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import { EmptyState } from '../components/EmptyState'; diff --git a/apps/mobile/src/screens/TeamsScreen.tsx b/apps/mobile/src/screens/TeamsScreen.tsx index c64e047e..71bb527f 100644 --- a/apps/mobile/src/screens/TeamsScreen.tsx +++ b/apps/mobile/src/screens/TeamsScreen.tsx @@ -1,6 +1,6 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { - View, Text, StyleSheet, FlatList, TouchableOpacity, + View, Text, StyleSheet, TouchableOpacity, TextInput, StatusBar, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js new file mode 100644 index 00000000..0eb862a9 --- /dev/null +++ b/packages/shared/eslint.config.js @@ -0,0 +1,23 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**'] }, + { + files: ['src/**/*.ts'], + extends: [tseslint.configs.recommended], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-console': 'warn', + 'no-var': 'error', + 'prefer-const': 'error', + 'eqeqeq': ['error', 'always'], + }, + }, +); diff --git a/packages/shared/package.json b/packages/shared/package.json index b3b3ac7e..051bcc67 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,7 +11,9 @@ "test": "vitest run" }, "devDependencies": { + "eslint": "^10.4.0", "typescript": "^5.4.0", + "typescript-eslint": "^8.59.3", "vitest": "^2.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08a8f46..117dc356 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,9 +269,15 @@ importers: packages/shared: devDependencies: + eslint: + specifier: ^10.4.0 + version: 10.4.1(jiti@2.7.0) typescript: specifier: ^5.4.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.59.3 + version: 8.60.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.19)(terser@5.48.0) From ccd424e469efb525c3809b1e16f8c30445ceba05 Mon Sep 17 00:00:00 2001 From: Antharya Date: Tue, 2 Jun 2026 14:02:44 +0530 Subject: [PATCH 3/6] fix(ci): handle existing backend test paths correctly --- .github/scripts/ciScript.js | 20 +++++++++++++------ .../__mocks__/react-native-camera-kit.js | 10 +--------- .../react-native-draggable-flatlist.js | 16 +++++---------- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/.github/scripts/ciScript.js b/.github/scripts/ciScript.js index f8cb346c..9cb5b5b7 100644 --- a/.github/scripts/ciScript.js +++ b/.github/scripts/ciScript.js @@ -36,13 +36,21 @@ module.exports = async ({ github, context, core }) => { backendFiles.push(fileName); const relative = fileName.replace('apps/backend/src/', ''); - const baseName = relative - .split('/') - .pop() - ?.replace(/\.(ts|tsx|js|jsx)$/, ''); - if (baseName) { - backendTests.push(`src/__tests__/${baseName}.test.ts`); + if (relative.startsWith('__tests__/')) { + // Already a test file — use it directly. Without this guard the + // extension strip above turns logout.test.ts into logout.test and + // then appends .test.ts again, producing logout.test.test.ts. + backendTests.push(`src/${relative}`); + } else { + const baseName = relative + .split('/') + .pop() + ?.replace(/\.(ts|tsx|js|jsx)$/, ''); + + if (baseName) { + backendTests.push(`src/__tests__/${baseName}.test.ts`); + } } } else if (fileName.startsWith('apps/mobile/')) { diff --git a/apps/mobile/__mocks__/react-native-camera-kit.js b/apps/mobile/__mocks__/react-native-camera-kit.js index 206fa817..a0995453 100644 --- a/apps/mobile/__mocks__/react-native-camera-kit.js +++ b/apps/mobile/__mocks__/react-native-camera-kit.js @@ -1,9 +1 @@ -const React = require('react'); -const { View } = require('react-native'); - -module.exports = { - __esModule: true, - Camera: React.forwardRef((_props, _ref) => React.createElement(View, null)), - CameraType: { Back: 'back', Front: 'front' }, - FlashMode: { Auto: 'auto', On: 'on', Off: 'off' }, -}; +module.exports = {}; \ No newline at end of file diff --git a/apps/mobile/__mocks__/react-native-draggable-flatlist.js b/apps/mobile/__mocks__/react-native-draggable-flatlist.js index 0d9e5b9d..f5c2dc73 100644 --- a/apps/mobile/__mocks__/react-native-draggable-flatlist.js +++ b/apps/mobile/__mocks__/react-native-draggable-flatlist.js @@ -1,14 +1,8 @@ const React = require('react'); -const { FlatList } = require('react-native'); -// Minimal stub used in Jest tests only. -// DraggableFlatList exposes the same props as FlatList so this renders -// correctly in snapshot/render tests without the native gesture runtime. -const ScaleDecorator = ({ children }) => - React.createElement(React.Fragment, null, children); +function DraggableFlatList(props) { + return React.createElement('View', props, props.children); +} -module.exports = { - __esModule: true, - default: FlatList, - ScaleDecorator, -}; +module.exports = DraggableFlatList; +module.exports.ScaleDecorator = ({ children }) => children; \ No newline at end of file From 0e97619b5a9f0287de194b55a312159c9acd974f Mon Sep 17 00:00:00 2001 From: Antharya Date: Tue, 2 Jun 2026 14:10:39 +0530 Subject: [PATCH 4/6] fix(mobile): stabilize Jest module mapping for native mocks --- apps/mobile/jest.config.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 76d029f0..46c1bcec 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -5,11 +5,13 @@ module.exports = { 'node_modules/(?!((react-native|@react-native|@react-navigation|@gorhom)/|\\.pnpm/(react-native|@react-native|@react-navigation|@gorhom)[^/]*))', ], moduleNameMapper: { - // Native packages that either lack a Jest-compatible build or are not - // installed as test dependencies. Mapped to local stubs so Jest bypasses - // node_modules resolution entirely. - 'react-native-draggable-flatlist': '/__mocks__/react-native-draggable-flatlist.js', - 'react-native-view-shot': '/__mocks__/react-native-view-shot.js', - 'react-native-camera-kit': '/__mocks__/react-native-camera-kit.js', - }, + '^react-native-draggable-flatlist$': + '/__mocks__/react-native-draggable-flatlist.js', + + '^react-native-view-shot$': + '/__mocks__/react-native-view-shot.js', + + '^react-native-camera-kit$': + '/__mocks__/react-native-camera-kit.js', +}, }; From 8561a3e9b30acf7a018df17255a0b6c02792a781 Mon Sep 17 00:00:00 2001 From: Antharya Date: Tue, 2 Jun 2026 14:15:37 +0530 Subject: [PATCH 5/6] fix(mobile): explicitly use local Jest config in CI --- apps/mobile/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4cae19e2..04f502cc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -8,7 +8,7 @@ "ios": "react-native run-ios", "lint": "eslint .", "start": "react-native start", - "test": "jest" + "test": "jest --config jest.config.js" }, "dependencies": { "@devcard/shared": "workspace:*", From 52a3660424e7f99302e2afdab5630f3ea26f882f Mon Sep 17 00:00:00 2001 From: Antharya Date: Tue, 2 Jun 2026 14:30:37 +0530 Subject: [PATCH 6/6] fix(mobile): add default export for draggable flatlist mock --- apps/mobile/__mocks__/react-native-draggable-flatlist.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/__mocks__/react-native-draggable-flatlist.js b/apps/mobile/__mocks__/react-native-draggable-flatlist.js index f5c2dc73..7211068a 100644 --- a/apps/mobile/__mocks__/react-native-draggable-flatlist.js +++ b/apps/mobile/__mocks__/react-native-draggable-flatlist.js @@ -5,4 +5,5 @@ function DraggableFlatList(props) { } module.exports = DraggableFlatList; +module.exports.default = DraggableFlatList; module.exports.ScaleDecorator = ({ children }) => children; \ No newline at end of file