diff --git a/.github/scripts/ciScript.js b/.github/scripts/ciScript.js index 21bd910e..4e4e4792 100644 --- a/.github/scripts/ciScript.js +++ b/.github/scripts/ciScript.js @@ -13,7 +13,6 @@ const deriveTestFiles = (files) => { }); }; - module.exports = async ({ github, context, core }) => { const owner = context.repo.owner; const repo = context.repo.repo; @@ -24,7 +23,6 @@ module.exports = async ({ github, context, core }) => { const backendFiles = []; const mobileFiles = []; const webFiles = []; - const dbFiles = []; try { if (prState === 'closed') { @@ -54,21 +52,16 @@ module.exports = async ({ github, context, core }) => { mobileFiles.push(fileName); } else if (fileName.startsWith('apps/web/')) { webFiles.push(fileName); - }else if(fileName.startsWith('apps/backend/prisma')){ - dbFiles.push(fileName) - }else if(fileName.includes('schema.prisma') || fileName.includes('/migrations/')){ - dbFiles.push(fileName) } }); const strippedBackend = backendFiles.map(f => f.replace('apps/backend/', '')); const strippedMobile = mobileFiles.map(f => f.replace('apps/mobile/', '')); - console.log({ backendFiles, mobileFiles, webFiles, dbFiles }); + console.log({ backendFiles, mobileFiles, webFiles }); core.setOutput('backendFiles', strippedBackend.join(' ')); core.setOutput('mobileFiles', strippedMobile.join(' ')); - core.setOutput('dbFiles', dbFiles.join(' ')); core.setOutput('webFiles', webFiles.map(f => f.replace('apps/web/', '')).join(' ')); core.setOutput('backendTestFiles', deriveTestFiles(strippedBackend).join(' ')); core.setOutput('mobileTestFiles', deriveTestFiles(strippedMobile).join(' ')); diff --git a/.github/scripts/discordPinReminder.js b/.github/scripts/discordPinReminder.js index 6751f7c5..d5724578 100644 --- a/.github/scripts/discordPinReminder.js +++ b/.github/scripts/discordPinReminder.js @@ -3,36 +3,35 @@ module.exports = async ({ github, context }) => { const ignoreUsers = [ 'ShantKhatri', 'Harxhit', - 'blankirigaya', - ]; - + 'blankirigaya' + ] try { - if (!pr || !pr.merged) { - console.log('PR not merged.'); - return; - } - - const prNumber = pr.number; - const contributor = pr.user.login; - - if (ignoreUsers.includes(contributor)) { - console.log(`Ignoring PR #${prNumber} by ${contributor}`); - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `Congratulations @${contributor} on getting PR #${prNumber} merged! - -Thank you for your contribution to the project. + // Only continue if merged + if (!pr || !pr.merged) { + console.log('PR not merged.'); + return; + } + + const prNumber = pr.number; + const contributor = pr.user.login; -To receive the appropriate GSSoC labels and recognition, please mention @Harxhit in the **#get-labels** channel on our Discord server and share your merged PR link.`, - }); + if(ignoreUsers.includes(contributor)){ + console.log(`Ignoring PR #${prNumber} by ${contributor}`); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Congratulations @${contributor} on getting PR #${prNumber} merged! - console.log(`Comment added to PR #${prNumber}`); + Thank you for your contribution. Please mention @Harxhit in our Discord server to receive the appropriate GSSoC labels and recognition. + ` + }); + + console.log(`Comment added to PR #${prNumber}`); } catch (error) { - console.error(error); + console.error(error) } -}; \ No newline at end of file +}; diff --git a/.github/scripts/triageIssue.js b/.github/scripts/triageIssue.js deleted file mode 100644 index b10f06e4..00000000 --- a/.github/scripts/triageIssue.js +++ /dev/null @@ -1,151 +0,0 @@ -module.exports = async({github, context}) => { - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue = context.payload.issue; - const issueNumber = issue.number; - const issueDescription = issue.body; - const username = issue.user.login - - if(context.eventName === 'issues'){ - try { - if(!issueDescription || !issueDescription.trim()){ - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `Hi @${username}, - - Thanks for opening this issue. - - It looks like the issue description is currently missing. - - Please provide: - - A brief summary of the problem - - Expected behavior - - Actual behavior (if applicable) - - Any relevant screenshots, logs, or context - - This helps the team understand, prioritize, and route the issue correctly. - - Thank you! - - ` - }) - } - - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `Hi @${username}, - - Thanks for opening this issue. - - Please reply with one of the following areas so the issue can be routed to the appropriate team member: - - - /backend - - /web - - /mobile - - /devops - - Once an area is selected, the corresponding label will be added automatically.` - }) - - } catch (error) { - console.error(error) - } - }else if(context.eventName === 'issue_comment'){ - if (context.payload.comment.user.type === 'Bot' || context.payload.comment.user.login === 'github-actions[bot]') { - return; - } - - const comment = (context.payload.comment.body || '').trim().toLowerCase(); - const existingLabels = (issue.labels || []).map(label => label.name); - - if ( - existingLabels.includes('backend') || - existingLabels.includes('web') || - existingLabels.includes('mobile') || - existingLabels.includes('devops') - ) { - return; - } - - if (!['/backend', '/web', '/mobile', '/devops'].includes(comment)) { - return; - } - if(comment === '/backend'){ - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ['backend'] - - }) - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The issue has been classified as **backend**. - -@Harxhit, please review and triage this issue when available. - -The **backend** label has been applied and the issue has been routed accordingly.` - }) - }else if(comment === '/web'){ - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ['web'] - - }) - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The issue has been classified as **web**. - -@ShantKhatri, please review and triage this issue when available. - -The **web** label has been applied and the issue has been routed accordingly.` - }) - }else if(comment === '/mobile'){ - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ['mobile'] - - }) - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The issue has been classified as **mobile**. - -@blankirigaya, please review and triage this issue when available. - -The **mobile** label has been applied and the issue has been routed accordingly.` - }) - }else if(comment === '/devops'){ - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ['devops'] - - }) - return await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The issue has been classified as **devops**. - -@ShantKhatri, please review and triage this issue when available. - -The **devops** label has been applied and the issue has been routed accordingly.` - }) - } - } -} \ No newline at end of file diff --git a/.github/scripts/triagePr.js b/.github/scripts/triagePr.js deleted file mode 100644 index 62a11ecd..00000000 --- a/.github/scripts/triagePr.js +++ /dev/null @@ -1,116 +0,0 @@ -module.exports = async ({github, context}) => { - const owner = context.repo.owner; - const repo = context.repo.repo; - const pr = context.payload.pull_request; - const prNumber = pr.number; - const username = pr.user.login; - - const backendFiles = []; - const webFiles = []; - const mobileFiles = []; - const devopsFiles = []; - - const labels = []; - let primaryArea = null; - let reviewer = null; - - try { - const changedFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner, - repo, - pull_number: prNumber - } - ); - - changedFiles.forEach((file) => { - const fileName = file.filename; - - if(fileName.startsWith('apps/backend/')){ - backendFiles.push(fileName); - }else if(fileName.startsWith('apps/web/')){ - webFiles.push(fileName); - }else if(fileName.startsWith('apps/mobile/')){ - mobileFiles.push(fileName) - }else if(fileName.startsWith('.github/') || fileName.startsWith('infra/') || fileName.startsWith('terraform/')){ - devopsFiles.push(fileName) - } - }) - - - if(backendFiles.length > 0){ - labels.push('backend') - - if(!primaryArea){ - primaryArea = 'backend'; - reviewer = '@Harxhit' - } - } - if(mobileFiles.length > 0){ - labels.push('mobile'); - if(!primaryArea){ - primaryArea = 'mobile'; - reviewer = '@blankirigaya' - } - } - if(webFiles.length > 0){ - labels.push('web'); - if(!primaryArea){ - primaryArea = 'web'; - reviewer = '@ShantKhatri' - } - } - if(devopsFiles.length > 0){ - labels.push('devops') - if(!primaryArea){ - primaryArea = 'devops' - reviewer = '@ShantKhatri' - } - } - - if(labels.length === 0){ - return; - } - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels - }); - const body = `Hi @${username}, - -Thanks for opening this pull request. - -This PR has been automatically classified based on the files modified. - -### Applied Labels - -${labels.map(label => `- ${label}`).join('\n')} - -### Primary Review Area - -* ${primaryArea} - -### Reviewer - -${reviewer} has been identified as the primary reviewer for this pull request. - -If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer. - -Thank you for your contribution! `; - - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: body - }); - - } catch (error) { - console.error(error) - } - -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e502a2c3..e8cedac7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,6 @@ jobs: webFiles: ${{ steps.detect.outputs.webFiles }} backendTestFiles: ${{ steps.detect.outputs.backendTestFiles }} mobileTestFiles: ${{ steps.detect.outputs.mobileTestFiles }} - dbFiles: ${{ steps.detect.outputs.dbFiles }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -56,14 +55,12 @@ jobs: with: node-version: 22 + - name: Install shared dependencies + run: npm --prefix packages/shared install + - name: Install backend dependencies run: npm --prefix apps/backend install - - name: DB migration check - if: needs.detect-changes.outputs.dbFiles != '' - continue-on-error: true - run: npm run db:migrate - - name: Backend lint id: backend_lint continue-on-error: true diff --git a/.github/workflows/triageIssue.yml b/.github/workflows/triageIssue.yml deleted file mode 100644 index 98162d4c..00000000 --- a/.github/workflows/triageIssue.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Issue triage - -on: - issues: - types: [opened] - issue_comment: - types: [created, edited] - - -permissions: - issues: write - -jobs: - assignLabel: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - - - name: Triage issue - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/triageIssue.js'); - await script({ github, context }); diff --git a/.github/workflows/triagePr.yml b/.github/workflows/triagePr.yml deleted file mode 100644 index 99344773..00000000 --- a/.github/workflows/triagePr.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Pull request triage - -on: - pull_request_target: - types: [opened] - -permissions: - pull-requests: write - issues: write - -jobs: - assignLabel: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - - - name: Triage pull request - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/triagePr.js'); - await script({ github, context }); \ No newline at end of file diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 832b4eee..64f44440 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "@devcard/backend", "version": "1.0.0", - "hasInstallScript": true, "dependencies": { "@devcard/shared": "file:../../packages/shared", "@fastify/cookie": "^11.0.0", diff --git a/apps/backend/package.json b/apps/backend/package.json index d71b0777..995ce916 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,7 +15,6 @@ "db:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", - "postinstall": "prisma generate", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index b238961b..648d98a6 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -1,13 +1,8 @@ -import { describe, it, expect, vi } from 'vitest'; +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(); @@ -20,22 +15,6 @@ describe('GET /health', () => { expect(res.statusCode).toBe(200); expect(JSON.parse(res.body)).toEqual({ status: 'ok' }); - await app.close(); - }); -}); - -describe('request logging hook', () => { - it('logs method and url for each request', async () => { - const app = await buildApp(); - const spy = vi.spyOn(app.log, 'info'); - - await app.inject({ method: 'GET', url: '/health' }); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ method: 'GET', url: '/health' }), - 'incoming request', - ); - await app.close(); }); }); \ No newline at end of file diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..f110c0da 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -440,4 +440,4 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 2b39535b..4d3fb977 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,21 +1,20 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; -import jwt from '@fastify/jwt'; + import { connectRoutes } from '../routes/connect.js'; + import type { PrismaClient } from '@prisma/client'; -process.env.PUBLIC_APP_URL = 'http://localhost:3000'; -process.env.BACKEND_URL = 'http://localhost:3001'; -process.env.MOBILE_REDIRECT_URI = 'devcard://connect'; -process.env.GITHUB_CLIENT_ID = 'test-client-id'; -process.env.GITHUB_CLIENT_SECRET = 'test-client-secret'; -process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; +const USER_ID = 'user-abc'; +const VALID_NONCE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; +const VALID_STATE = Buffer.from(JSON.stringify({ userId: USER_ID, nonce: VALID_NONCE })).toString('base64'); +const ATTACKER_USER_ID = 'user-victim'; +const CRAFTED_STATE = Buffer.from(JSON.stringify({ userId: ATTACKER_USER_ID, nonce: 'nonce-never-issued' })).toString('base64'); -const mockRedis = { - get: vi.fn(), - set: vi.fn(), - del: vi.fn(), -}; +// Mock encrypt so the token-storage path does not throw in tests +vi.mock('../utils/encryption.js', () => ({ + encrypt: vi.fn().mockReturnValue('encrypted_token'), +})); const mockPrisma = { oAuthToken: { @@ -25,163 +24,163 @@ const mockPrisma = { }, }; -global.fetch = vi.fn(); +// Redis mock that reports as connected and ready +const mockRedis = { + status: 'ready', + set: vi.fn(), + get: vi.fn(), + del: vi.fn(), +}; -async function buildApp() { - const app = Fastify(); - await app.register(jwt, { secret: 'test-secret' }); +// Redis mock that simulates connection failure +const mockRedisDown = { + status: 'end', + set: vi.fn(), + get: vi.fn(), + del: vi.fn(), +}; + +async function buildApp(redisOverride?: object | null): Promise { + const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); - app.decorate('redis', mockRedis as any); - - app.decorate('authenticate', async (request: any, reply: any) => { - try { - await request.jwtVerify(); - } catch (err) { - reply.status(401).send({ error: 'Unauthorized' }); - } + app.decorate('redis', (redisOverride === undefined ? mockRedis : redisOverride) as any); + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; }); - + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + json: async () => ({ access_token: 'gh_token_abc', scope: 'user:follow' }), + })); app.register(connectRoutes, { prefix: '/api/connect' }); await app.ready(); return app; } -describe('GET /api/connect/github/callback', () => { +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/connect/github/callback — CSRF nonce enforcement +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/connect/github/callback — CSRF nonce enforcement', () => { beforeEach(() => { vi.clearAllMocks(); + process.env.PUBLIC_APP_URL = 'https://app.devcard.test'; + process.env.BACKEND_URL = 'https://api.devcard.test'; + process.env.GITHUB_CLIENT_ID = 'gh_client_id'; + process.env.GITHUB_CLIENT_SECRET = 'gh_client_secret'; }); - it('redirects with missing_params if code or state is missing', async () => { - const app = await buildApp(); - - // Missing code - let res = await app.inject({ + it('returns 503 when Redis is unavailable (status !== ready)', async () => { + const app = await buildApp(mockRedisDown); + const res = await app.inject({ method: 'GET', - url: '/api/connect/github/callback?state=somestate', + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); - expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params'); - // Missing state - res = await app.inject({ - method: 'GET', - url: '/api/connect/github/callback?code=somecode', - }); - expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params'); + expect(res.statusCode).toBe(503); + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); + expect(mockRedisDown.get).not.toHaveBeenCalled(); }); - it('redirects with connect_failed if state is invalid/malformed', async () => { - const app = await buildApp(); - const invalidState = Buffer.from(JSON.stringify({ wrongKey: 'value' })).toString('base64'); - + it('returns 503 when app.redis is null/falsy', async () => { + const app = await buildApp(null); const res = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${invalidState}`, + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); - - expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); + + expect(res.statusCode).toBe(503); + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); }); - it('redirects with invalid_state if nonce is not found in Redis (CSRF/Expired)', async () => { + it('redirects to invalid_state when nonce was never issued (crafted state)', async () => { mockRedis.get.mockResolvedValue(null); + const app = await buildApp(); - const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); - const res = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${validState}`, + url: `/api/connect/github/callback?code=gh_code&state=${CRAFTED_STATE}`, }); - - expect(mockRedis.get).toHaveBeenCalledWith('oauth:nonce:nonce-123'); + expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state'); + expect(res.headers.location).toContain('error=invalid_state'); + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); }); - it('redirects with invalid_state if Redis userId does not match state userId', async () => { - mockRedis.get.mockResolvedValue('different-user-id'); + it('redirects to invalid_state when nonce is present but userId does not match', async () => { + mockRedis.get.mockResolvedValue('user-different'); + const app = await buildApp(); - const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); - const res = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${validState}`, + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); - + expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state'); + expect(res.headers.location).toContain('error=invalid_state'); + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); }); - it('successfully exchanges code, upserts token, and redirects on valid flow (Web)', async () => { - mockRedis.get.mockResolvedValue('user-1'); - (global.fetch as any).mockResolvedValue({ - json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' }) - }); + it('completes the OAuth flow and stores the token when nonce is valid', async () => { + mockRedis.get.mockResolvedValue(USER_ID); + mockRedis.del.mockResolvedValue(1); mockPrisma.oAuthToken.upsert.mockResolvedValue({}); const app = await buildApp(); - const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'web_nonce-123' })).toString('base64'); - const res = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${validState}`, + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); - - // Nonce should be deleted immediately - expect(mockRedis.del).toHaveBeenCalledWith('oauth:nonce:web_nonce-123'); - - // Code exchange should be triggered - expect(global.fetch).toHaveBeenCalledWith('https://github.com/login/oauth/access_token', expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('testcode') - })); - - // Upsert should be called - expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith(expect.objectContaining({ - where: { userId_platform: { userId: 'user-1', platform: 'github_follow' } } - })); - - // Redirects to web success + expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?connected=github'); + expect(res.headers.location).toContain('connected=github'); + expect(mockRedis.del).toHaveBeenCalledWith(`oauth:nonce:${VALID_NONCE}`); + expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledOnce(); }); - it('redirects to mobile scheme if nonce starts with mobile_', async () => { - mockRedis.get.mockResolvedValue('user-1'); - (global.fetch as any).mockResolvedValue({ - json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' }) - }); + it('consumes the nonce exactly once — replay of the same state is rejected', async () => { + mockRedis.get.mockResolvedValueOnce(USER_ID); + mockRedis.del.mockResolvedValue(1); mockPrisma.oAuthToken.upsert.mockResolvedValue({}); const app = await buildApp(); - const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'mobile_nonce-123' })).toString('base64'); - - const res = await app.inject({ + const first = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${validState}`, + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); - - expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('devcard://connect?connected=github'); - }); + expect(first.statusCode).toBe(302); + expect(first.headers.location).toContain('connected=github'); - it('redirects with connect_failed if token exchange returns an error', async () => { - mockRedis.get.mockResolvedValue('user-1'); - (global.fetch as any).mockResolvedValue({ - json: vi.fn().mockResolvedValue({ error: 'bad_verification_code' }) + mockRedis.get.mockResolvedValueOnce(null); + const second = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=gh_code&state=${VALID_STATE}`, }); + expect(second.statusCode).toBe(302); + expect(second.headers.location).toContain('error=invalid_state'); + expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledOnce(); + }); + it('redirects to connect_failed when code or state is missing', async () => { + const app = await buildApp(); + + const noCode = await app.inject({ method: 'GET', url: '/api/connect/github/callback?state=abc' }); + expect(noCode.statusCode).toBe(302); + expect(noCode.headers.location).toContain('error=missing_params'); + + const noState = await app.inject({ method: 'GET', url: '/api/connect/github/callback?code=abc' }); + expect(noState.statusCode).toBe(302); + expect(noState.headers.location).toContain('error=missing_params'); + }); + + it('redirects to connect_failed when state is not valid base64 JSON', async () => { const app = await buildApp(); - const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); - const res = await app.inject({ method: 'GET', - url: `/api/connect/github/callback?code=testcode&state=${validState}`, + url: '/api/connect/github/callback?code=gh_code&state=not_valid_base64!!!', }); - - expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); + mockPrisma.oAuthToken.upsert.mockResolvedValue({}); + expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); + expect(res.headers.location).toContain('error=connect_failed'); + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts deleted file mode 100644 index 15fc7d1f..00000000 --- a/apps/backend/src/__tests__/logout.test.ts +++ /dev/null @@ -1,673 +0,0 @@ -import cookiePlugin from '@fastify/cookie'; -import jwtPlugin from '@fastify/jwt'; -import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; - -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(): { exists: Mock; set: Mock; del: Mock } { - 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 }); - - // 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 - // 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 (redisErr) { - app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); - } - } - 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): { Authorization: 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', () => { - 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 = 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); - 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 = signToken(app, { 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 = signToken(app, { 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 = signToken(app, { 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 = signToken(app, { 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(); - }); - - 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 ──────────────────────────── - -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 = 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(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 = 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(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 = signToken(app, { 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 as any, { secret: 'totally-different-secret-xxxxx' }); - const badToken = signToken(wrongApp, { 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'); - }); - - 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 ───────────────────────────────────────────────────── - -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 }>): FastifyRequest { - 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(); - }); - - 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/app.ts b/apps/backend/src/app.ts index 6116b91b..06b87205 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ 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'; @@ -20,9 +21,8 @@ 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 { teamRoutes } from './routes/team.js'; -import { extractRawJwt, blocklistKey } from './utils/jwt.js'; import { validateEnv } from './utils/validateEnv.js'; +import { teamRoutes } from './routes/team.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -42,12 +42,6 @@ export async function buildApp():Promise { }, }); - // Log method + path for every incoming request. - app.addHook('onRequest', (request, _reply, done) => { - app.log.info({ method: request.method, url: request.url }, 'incoming request'); - done(); - }); - // ─── Core Plugins ─── await app.register(cors, { origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173', @@ -71,19 +65,12 @@ 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, @@ -101,31 +88,13 @@ 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 { - 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). + // Ensure the verified payload is assigned to `request.user` like the original plugin. const payload = await request.jwtVerify(); - if (payload) { request.user = payload; } - } catch (_err) { - return reply.status(401).send({ error: 'Unauthorized' }); + if (payload) request.user = payload; + } catch (error) { + reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index efc22fe5..a975424f 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -70,14 +70,13 @@ export async function analyticsRoutes( // Count unique viewers // In raw SQL this is `SELECT COUNT(DISTINCT viewer_id) FROM card_views WHERE owner_id = ?` // Prisma group-by as workaround: - const uniqueViewersQuery = await app.prisma.$queryRaw<[{ count: bigint }]>` - SELECT COUNT(DISTINCT viewer_id) AS count - FROM card_views - WHERE owner_id = ${userId} - AND viewer_id IS NOT NULL - `; - - const uniqueViewers = Number(uniqueViewersQuery[0]?.count ?? 0); + const uniqueViewersQuery = + await app.prisma.cardView.groupBy({ + by: ['viewerId', 'viewerIp'], + where: { ownerId: userId }, + }); + + const uniqueViewers = uniqueViewersQuery.length; return { totalViews, diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index cffebea7..c14949e1 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,8 +1,6 @@ -import { encrypt } from '../utils/encryption.js'; -import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; -import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; - import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { encrypt } from '../utils/encryption.js'; +import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -16,7 +14,7 @@ interface OAuthCallbackQuery { state?: string; } -export async function authRoutes(app: FastifyInstance): Promise { +export async function authRoutes(app: FastifyInstance) { // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -255,10 +253,12 @@ export async function authRoutes(app: FastifyInstance): Promise { }); // Current user - app.get('/me', { - // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [app.authenticate], - }, async (request: FastifyRequest, reply: FastifyReply) => { + 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) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ where: { id: userId }, @@ -286,59 +286,8 @@ export async function authRoutes(app: FastifyInstance): Promise { return { ...userData, connectedPlatforms: oauthTokens }; }); - // Legacy endpoint kept for backward compatibility with existing clients. - // Cookie-only logout — use DELETE /auth/logout for token revocation. - app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { - app.log.info('Legacy cookie-only logout called — token not blocklisted'); - 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', { - // eslint-disable-next-line @typescript-eslint/unbound-method - 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, userId: (request.user as any)?.id }, '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', - ); - } - } - + app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); } - diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..32fe835c 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,11 +1,12 @@ -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 { CardResponse } from '../services/cardService'; import type { Card } from '@devcard/shared'; +import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + interface CreateCardBody { title: string; linkIds: string[]; @@ -38,7 +39,7 @@ interface CardLinkWithPlatform { platformLink: PlatformLink; } -interface _CardWithLinks { +interface CardWithLinks { id: string; userId: string; title: string; @@ -53,12 +54,12 @@ export async function cardRoutes(app: FastifyInstance): Promise { 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 (e) { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── List Cards ─── - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { + app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; try { return await cardService.listCards(app, userId) @@ -81,25 +82,25 @@ 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) } }); // ─── Update Card ─── - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { + app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; 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) } }); @@ -111,18 +112,11 @@ export async function cardRoutes(app: FastifyInstance): Promise { const { id } = request.params; try { - await cardService.deleteCard(app, userId, id) + 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.' }) return reply.status(204).send() - } catch (error:any) { - if (error?.code === 'NOT_FOUND') { - return reply.status(404).send({ error: 'Card not found' }); - } - - if (error?.code === 'LAST_CARD') { - return reply.status(400).send({ - error: 'Cannot delete the last remaining card. A user must have at least one card.', - }); - } + } catch (error) { return handleDbError(error, request, reply) } }); @@ -135,7 +129,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..e7af79f2 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 (_e) { 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 (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -93,16 +95,23 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } - // Verify nonce was issued by this server -- prevents CSRF - const storedUserId = app.redis ? await app.redis.get(`oauth:nonce:${decodedState.nonce}`) : null; + // Hard-fail when Redis is unavailable: proceeding without nonce + // verification would allow CSRF — an attacker could craft a valid-looking + // state for any userId and have a token stored under their target's account. + if (!app.redis || app.redis.status !== 'ready') { + app.log.error('OAuth CSRF check skipped: Redis unavailable — aborting callback'); + return reply.status(503).send({ error: 'Service temporarily unavailable. Please try again.' }); + } + + const storedUserId = await app.redis.get(`oauth:nonce:${decodedState.nonce}`); - if (app.redis && (!storedUserId || storedUserId !== decodedState.userId)) { - app.log.warn({ nonce: decodedState.nonce }, 'OAuth CSRF check failed: nonce mismatch'); + if (!storedUserId || storedUserId !== decodedState.userId) { + app.log.warn({ nonce: decodedState.nonce }, 'OAuth CSRF check failed: nonce mismatch or nonce not found'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`); } - // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + // Consume the nonce — one-time use, prevents replay attacks + await app.redis.del(`oauth:nonce:${decodedState.nonce}`); const userId = decodedState.userId; @@ -175,7 +184,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 (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +205,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch (_error) { return reply.status(404).send({ error: 'Connection not found' }); } }); @@ -218,4 +227,4 @@ function parseOAuthState(state: string): ParsedOAuthState | null { function generateState(): string { return randomBytes(32).toString('hex'); -} +} \ No newline at end of file diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index fd3b9903..a9721783 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,174 +1,93 @@ -import type { Prisma } from '@prisma/client'; -import type { FastifyInstance } from 'fastify'; - -type CardLinkResponse = { platformLink: unknown }; -type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; -export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; - -function mapCard(card: RawCard): CardResponse { - return { - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cardLink) => cardLink.platformLink), - }; -} +import type { FastifyInstance } from 'fastify' +import type { Prisma } from '@prisma/client' -export async function listCards(app: FastifyInstance, userId: string): Promise { - const cards = (await app.prisma.card.findMany({ +export async function listCards(app: FastifyInstance, userId: string) { + const cards = await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - })) as unknown as RawCard[]; + }) - return cards.map(mapCard); + return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) } -export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise { +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' }); - } + 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' }) } - const maxRetries = 3; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const card = (await app.prisma.$transaction( - async (tx: Prisma.TransactionClient) => { - const cardCount = await tx.card.count({ where: { userId } }); - - return tx.card.create({ - data: { - userId, - title: body.title, - isDefault: cardCount === 0, - cardLinks: { - create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), - }, - }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - }); - }, - { - isolationLevel: 'Serializable', - }, - )) as unknown as RawCard; - - return mapCard(card); - } catch (error: unknown) { - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: string }).code === 'P2034' && - attempt < maxRetries - ) { - continue; - } + const cardCount = await app.prisma.card.count({ where: { userId } }) - app.log.error(error); - throw error; - } - } + const card = await app.prisma.card.create({ + data: { + userId, + title: body.title, + isDefault: cardCount === 0, + cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, + }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }) - throw new Error('Failed to create card after retrying serialization conflicts'); + return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } } -export async function updateCard( - app: FastifyInstance, - userId: string, - id: string, - body: { title?: string; linkIds?: string[] }, -): Promise { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }); - if (!existing) { - return null; - } +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 (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }); + await app.prisma.card.update({ where: { id }, data: { title: body.title } }) } 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' }); - } + 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' }) } - const linkIds = body.linkIds; + const linkIds = body.linkIds await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }); + await tx.cardLink.deleteMany({ where: { cardId: id } }) if (linkIds.length > 0) { - await tx.cardLink.createMany({ - data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })), - }); + await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) } - }); - } - - const updated = (await app.prisma.card.findUnique({ - where: { id }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - })) as unknown as RawCard | null; - - if (!updated) { - return null; + }) } - return mapCard(updated); + const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) + return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } } -export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { +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) { - throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); - } + const existing = await tx.card.findFirst({ where: { id, userId } }) + if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) - const userCardCount = await tx.card.count({ where: { userId } }); - if (userCardCount <= 1) { - throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); - } + const userCardCount = await tx.card.count({ where: { userId } }) + 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' }, - }); - + const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) if (oldestRemainingCard) { - await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); + await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) } } - await tx.card.delete({ where: { id } }); - return null; - }); + await tx.card.delete({ where: { id } }) + return null + }) } -export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }); - if (!existing) { - return null; - } +export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }) + if (!existing) return null await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); - await tx.card.update({ where: { id }, data: { isDefault: true } }); - }); + await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) + await tx.card.update({ where: { id }, data: { isDefault: true } }) + }) - return { message: 'Default card updated' }; + return { message: 'Default card updated' } } diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts deleted file mode 100644 index 40386962..00000000 --- a/apps/backend/src/utils/jwt.ts +++ /dev/null @@ -1,27 +0,0 @@ -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) || null; } - 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')}`; -} diff --git a/apps/backend/src/utils/oauth.ts b/apps/backend/src/utils/oauth.ts deleted file mode 100644 index 9dff87fe..00000000 --- a/apps/backend/src/utils/oauth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -export function generateState(): string { - return randomBytes(32).toString('hex'); -} - -export 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()}`; -} - -export 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/web/src/pages/LandingPage.css b/apps/web/src/pages/LandingPage.css index 387ee469..9d39a315 100644 --- a/apps/web/src/pages/LandingPage.css +++ b/apps/web/src/pages/LandingPage.css @@ -73,35 +73,36 @@ gap: 1.75rem; padding: 4rem 0 5rem; } + .feature-card { - position: relative; - overflow: hidden; padding: 2.4rem; min-height: 140px; border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); background: var(--bg-card); border: 1px solid var(--border); - transition: transform 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease, background 0.35s ease; + transition: transform 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease; } .feature-card:hover { - transform: translateY(-6px); - border-color: rgba(99, 102, 241, 0.55); - box-shadow: 0 26px 50px -18px rgba(99, 102, 241, 0.3); + transform: translateY(-8px); + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 26px 50px -18px rgba(0, 0, 0, 0.35); } - +.feature-icon { + font-size: 2.3rem; + margin-bottom: 1.4rem; +} .feature-card h3 { font-size: 1.4rem; margin-bottom: 0.9rem; - color: var(--text-primary); } + .feature-card p { - font-size: 0.97rem; color: var(--text-secondary); - line-height: 1.75; + line-height: 1.7; } .footer { @@ -146,94 +147,3 @@ opacity: 0.6; } } - - -.dark .feature-card h3 { - color: #f1f5f9; -} - -.dark .feature-card p { - color: #cbd5e1; -} - - -:root:not(.dark) .feature-card h3 { - color: #0f172a; -} - -:root:not(.dark) .feature-card p { - color: #475569; -} - -:root:not(.dark) .feature-card { - box-shadow: 0 4px 24px rgba(99, 102, 241, 0.10), - 0 1.5px 6px rgba(0,0,0,0.07); - border-color: rgba(99, 102, 241, 0.18); -} - -:root:not(.dark) .feature-card:hover { - box-shadow: 0 8px 32px rgba(99, 102, 241, 0.18), - 0 2px 8px rgba(0,0,0,0.08); - border-color: rgba(99, 102, 241, 0.45); -} - - -.dark .feature-card { - border-color: rgba(255, 255, 255, 0.18); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); -} - -.feature-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, #6366f1, #a855f7, #6366f1); - opacity: 0; - transition: opacity 0.35s ease; -} - -.feature-card:hover::before { - opacity: 1; -} - - -.feature-icon { - font-size: 2rem; - margin-bottom: 1.4rem; - display: inline-flex; - align-items: center; - justify-content: center; - width: 52px; - height: 52px; - border-radius: 14px; - background: rgba(99, 102, 241, 0.12); - border: 1px solid rgba(99, 102, 241, 0.2); -} - - -.dark .feature-icon { - background: rgba(99, 102, 241, 0.15); - border-color: rgba(99, 102, 241, 0.25); -} - - -:root:not(.dark) .feature-icon { - background: rgba(99, 102, 241, 0.08); - border-color: rgba(99, 102, 241, 0.15); -} - - -.dark .feature-card:hover { - background: rgba(99, 102, 241, 0.06); - box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.2), - 0 26px 50px -18px rgba(99, 102, 241, 0.35); -} - -:root:not(.dark) .feature-card:hover { - background: rgba(255, 255, 255, 0.95); - box-shadow: 0 8px 32px rgba(99, 102, 241, 0.18), - 0 0 0 1px rgba(99, 102, 241, 0.15); -} \ No newline at end of file