diff --git a/client/src/App.tsx b/client/src/App.tsx index 52883ba..b4bec72 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'; import LandingPage from './pages/LandingPage'; import LobbyPage from './pages/LobbyPage'; import RoomPage from './pages/RoomPage'; +import LinkDiscordPage from './pages/LinkDiscordPage'; import ProtectedRoute from './components/ProtectedRoute'; import ErrorBoundary from './components/ErrorBoundary'; import RoomGuard from './components/RoomGuard'; @@ -10,6 +11,17 @@ export default function App() { return ( } /> + {/* Discord account-linking confirmation. Behind ProtectedRoute so an + unauthenticated visitor is routed through the normal auth flow and + returned here (token preserved) to finish linking. */} + + + + } + /> { + await api.post('/integrations/discord/link', { token }); +} diff --git a/client/src/pages/LinkDiscordPage.test.tsx b/client/src/pages/LinkDiscordPage.test.tsx new file mode 100644 index 0000000..4d86e3f --- /dev/null +++ b/client/src/pages/LinkDiscordPage.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from '@mui/material/styles'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import theme from '../theme/theme'; +import { linkDiscord } from '../api/discord'; +import LinkDiscordPage from './LinkDiscordPage'; + +// The page's only dependency is the link API call (the ProtectedRoute wrapper +// owns the signed-in check); stub it so the test is about what the user sees for +// each outcome, not the network. +vi.mock('../api/discord', () => ({ linkDiscord: vi.fn() })); + +const mockLink = vi.mocked(linkDiscord); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +beforeAll(() => { + window.matchMedia ||= vi.fn().mockImplementation((query) => ({ + matches: false, media: query, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + })); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('LinkDiscordPage', () => { + it('confirms once the token is exchanged successfully', async () => { + mockLink.mockResolvedValue(); + renderAt('/link/discord?token=good-token'); + + expect(await screen.findByText(/Discord linked/i)).toBeInTheDocument(); + expect(mockLink).toHaveBeenCalledWith('good-token'); + }); + + it('shows an expired/invalid message when the API rejects the token', async () => { + mockLink.mockRejectedValue( + Object.assign(new Error('bad'), { response: { status: 400 } }), + ); + renderAt('/link/discord?token=stale-token'); + + expect(await screen.findByText(/invalid or has expired/i)).toBeInTheDocument(); + }); + + it('shows a missing-link message and never calls the API without a token', async () => { + renderAt('/link/discord'); + + expect(await screen.findByText(/link is missing/i)).toBeInTheDocument(); + expect(mockLink).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/pages/LinkDiscordPage.tsx b/client/src/pages/LinkDiscordPage.tsx new file mode 100644 index 0000000..1bc702a --- /dev/null +++ b/client/src/pages/LinkDiscordPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Box, Button, CircularProgress, Paper, Stack, Typography } from '@mui/material'; +import { + CheckCircle as CheckCircleIcon, + ErrorOutlineRounded as ErrorOutlineIcon, +} from '@mui/icons-material'; +import type { AxiosError } from 'axios'; +import BrandMark from '../components/BrandMark'; +import { linkDiscord } from '../api/discord'; + +// Confirmation page for the Discord `/meet link` flow. The bot DMs the user a +// URL to this page carrying a single-purpose `?token=`; because this route sits +// behind ProtectedRoute, the user is already signed in (or gets bounced through +// the normal auth flow and returned here). We exchange the token for a persisted +// Discord↔account mapping and report the outcome. +type Status = 'linking' | 'linked' | 'invalid' | 'no-token'; + +export default function LinkDiscordPage() { + const [params] = useSearchParams(); + const token = params.get('token') ?? ''; + const [status, setStatus] = useState(token ? 'linking' : 'no-token'); + // Guard against the effect firing twice (StrictMode / re-render) and double-posting. + const startedRef = useRef(false); + + useEffect(() => { + if (!token || startedRef.current) return; + startedRef.current = true; + linkDiscord(token) + .then(() => setStatus('linked')) + .catch((err: AxiosError) => { + // A 400 is the expected "expired/invalid token" case; anything else is + // treated the same for the user — the remedy is to re-run /meet link. + void err; + setStatus('invalid'); + }); + }, [token]); + + return ( + + + + + + + + + + + + ); +} + +function LinkStatus({ status }: { status: Status }) { + if (status === 'linking') { + return ( + + + Linking your Discord account… + + ); + } + + if (status === 'linked') { + return ( + + + Discord linked + + Your Discord account is now linked to A-Meet. Head back to Discord and run{' '} + /meet create{' '} + to start a meeting. + + + ); + } + + // 'invalid' and 'no-token' share a layout; only the message differs. + const message = + status === 'no-token' + ? 'This link is missing its token. Run /meet link in Discord to get a fresh link.' + : 'This link is invalid or has expired. Run /meet link in Discord to get a fresh link.'; + + return ( + + + Couldn’t link Discord + {message} + + + ); +} diff --git a/server/.env.example b/server/.env.example index d22dfc0..361c53e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -35,6 +35,16 @@ JWT_EXPIRES_IN=7d GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# ── Discord bot integration (#185) ───────────────────────────────────────── +# Shared secret the Discord bot presents (X-Bot-Api-Key header) to the +# /api/integrations/discord/* endpoints. HOST-GRADE: whoever holds it can create +# meetings on behalf of any linked user. Leave empty to DISABLE the integration +# (the bot-auth middleware then rejects every request). Prod: an SSM SecureString, +# never committed. Never logged. +DISCORD_BOT_API_KEY= +# Lifetime of the single-purpose account-link token (jsonwebtoken duration string). +DISCORD_LINK_TOKEN_TTL=10m + # ── TURN server (M6) ────────────────────────────────────────────────────── # coturn credentials — must match coturn/turnserver.conf user line. TURN_SECRET=change-me-to-a-long-random-turn-secret diff --git a/server/src/app.ts b/server/src/app.ts index 68aec94..f8ff93a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { areWorkersAlive } from './sfu/workers.js'; import authRoutes from './routes/auth.routes.js'; import roomRoutes from './routes/room.routes.js'; import logRoutes from './routes/log.routes.js'; +import discordRoutes from './routes/discord.routes.js'; // Structured 429 handler shared by every limiter. Body mirrors the socket ack // error shape ({ error, retryAfterMs }) so both transports report back-off the @@ -86,6 +87,10 @@ export function createApp(overrides: RateLimitOverrides = {}) { }); app.use('/api/auth', authLimiter, authRoutes); app.use('/api/rooms', roomLimiter, roomRoutes); + // Discord integration. Auth is per-route (bot API key for bot calls, user + // cookie for the browser link page); the room limiter is intentionally NOT + // applied so a single bot-host IP isn't throttled making meetings for a server. + app.use('/api/integrations/discord', discordRoutes); app.use('/api/logs', logRoutes); // 404 diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 220b3c8..3089293 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -75,6 +75,15 @@ export const env = { floodDisconnectThreshold: num('RATE_LIMIT_SOCKET_FLOOD_DISCONNECT', 100), }, }, + // Discord bot integration (see docs/specs/2026-07-23-discord-bot-design.md). + // `botApiKey` is host-grade secret material — the room-creating endpoint trusts + // whoever holds it. Empty locally leaves the integration disabled (the bot-auth + // middleware then rejects every request). Never logged. + discord: { + botApiKey: process.env.DISCORD_BOT_API_KEY || '', + // Lifetime of the single-purpose account-link token (jsonwebtoken duration). + linkTokenTtl: process.env.DISCORD_LINK_TOKEN_TTL || '10m', + }, transcription: { deepgramApiKey: process.env.DEEPGRAM_API_KEY || '', groqApiKey: process.env.GROQ_API_KEY || '', diff --git a/server/src/config/logger.ts b/server/src/config/logger.ts index 5574b10..f4b1df4 100644 --- a/server/src/config/logger.ts +++ b/server/src/config/logger.ts @@ -16,7 +16,15 @@ type LogTarget = | { type: 'file'; level: string }; // Sensitive fields stripped from every log line, in every environment. -export const REDACT_PATHS = ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token']; +// `req.headers["x-bot-api-key"]` is the Discord bot's host-grade credential; +// pino-http serializes request headers, so it must be censored here. +export const REDACT_PATHS = [ + 'req.headers.authorization', + 'req.headers.cookie', + 'req.headers["x-bot-api-key"]', + '*.password', + '*.token', +]; // Pure: which log targets are active for a given environment. Exported so the // stream contract can be asserted in tests without constructing pino or touching diff --git a/server/src/controllers/discord.controller.ts b/server/src/controllers/discord.controller.ts new file mode 100644 index 0000000..cb64d6d --- /dev/null +++ b/server/src/controllers/discord.controller.ts @@ -0,0 +1,59 @@ +import type { Request, Response, NextFunction } from 'express'; +import { env } from '../config/env.js'; +import { DiscordLink } from '../models/DiscordLink.js'; +import { createUniqueRoom } from './room.controller.js'; +import { mintLinkToken, verifyLinkToken } from '../integrations/discord/link-token.js'; + +// POST /api/integrations/discord/link-token (bot-key auth) +// Mint a short-lived token bound to the given Discord ID and hand the bot a +// ready-made confirmation URL to DM the user. +export function createLinkToken(req: Request, res: Response, next: NextFunction) { + try { + const { discordId } = req.body as { discordId: string }; + const token = mintLinkToken(discordId); + const linkUrl = `${env.clientUrl}/link/discord?token=${encodeURIComponent(token)}`; + return res.status(201).json({ token, linkUrl }); + } catch (err) { + return next(err); + } +} + +// POST /api/integrations/discord/link (user cookie auth) +// Verify the link token AND the auth cookie, then upsert the Discord↔user +// mapping. Re-linking a Discord ID overwrites the previous mapping in place. +export async function linkAccount(req: Request, res: Response, next: NextFunction) { + let discordId: string; + try { + discordId = verifyLinkToken((req.body as { token: string }).token); + } catch { + return res.status(400).json({ error: 'This link is invalid or has expired. Run /meet link again.' }); + } + try { + await DiscordLink.findOneAndUpdate( + { discordId }, + { discordId, userId: req.user!.id }, + { upsert: true, setDefaultsOnInsert: true }, + ); + return res.json({ ok: true }); + } catch (err) { + return next(err); + } +} + +// POST /api/integrations/discord/rooms (bot-key auth) +// Create an instant room hosted by the linked user — identical to POST /api/rooms. +// An unlinked Discord ID yields a distinct not-linked error the bot detects. +export async function createRoomForDiscord(req: Request, res: Response, next: NextFunction) { + try { + const { discordId } = req.body as { discordId: string }; + const link = await DiscordLink.findOne({ discordId }); + if (!link) { + return res.status(404).json({ error: 'Discord account is not linked', code: 'not_linked' }); + } + const room = await createUniqueRoom({ host: link.userId, admin: link.userId }); + if (!room) return res.status(500).json({ error: 'Could not generate a unique room code' }); + return res.status(201).json({ roomId: room.roomId }); + } catch (err) { + return next(err); + } +} diff --git a/server/src/controllers/room.controller.ts b/server/src/controllers/room.controller.ts index 3aa86de..ce0db4b 100644 --- a/server/src/controllers/room.controller.ts +++ b/server/src/controllers/room.controller.ts @@ -14,7 +14,7 @@ function generateRoomId() { // Creates a Room with a freshly minted unique code, retrying a few times in the // (very unlikely) event of a collision. `fields` carries any extra data (host, // scheduling metadata, …). Returns the created doc, or null if all retries lost. -async function createUniqueRoom(fields: Record) { +export async function createUniqueRoom(fields: Record) { for (let attempt = 0; attempt < 5; attempt++) { const roomId = generateRoomId(); try { diff --git a/server/src/integrations/discord/link-token.ts b/server/src/integrations/discord/link-token.ts new file mode 100644 index 0000000..9173347 --- /dev/null +++ b/server/src/integrations/discord/link-token.ts @@ -0,0 +1,38 @@ +import { createHmac } from 'crypto'; +import jwt, { type SignOptions } from 'jsonwebtoken'; +import { env } from '../../config/env.js'; + +// Single-purpose token for the Discord account-linking flow. It is deliberately +// NOT interchangeable with the auth cookie JWT: +// - it is signed with a key DERIVED from JWT_SECRET (not the secret itself), +// so an auth JWT fails verification here and a link token fails requireAuth's +// verification — no shared-secret cross-use is possible; +// - it carries a `typ` claim we assert on, defence-in-depth on top of the key; +// - it binds exactly one Discord ID and is short-lived. +const LINK_TOKEN_TYPE = 'discord-link'; + +// Deterministic per-deployment key, distinct from JWT_SECRET. Rotating +// JWT_SECRET rotates this too, which is the desired behaviour. +const linkTokenSecret = createHmac('sha256', env.jwtSecret).update('discord-link-token-v1').digest('hex'); + +/** Mint a short-lived token bound to `discordId`. */ +export function mintLinkToken(discordId: string): string { + return jwt.sign( + { typ: LINK_TOKEN_TYPE, discordId }, + linkTokenSecret, + { expiresIn: env.discord.linkTokenTtl } as SignOptions, + ); +} + +/** + * Verify a link token and return the Discord ID it was minted for. Throws if the + * token is expired, tampered, signed with the wrong key (e.g. an auth JWT), or + * missing the link-token type claim. + */ +export function verifyLinkToken(token: string): string { + const payload = jwt.verify(token, linkTokenSecret) as { typ?: string; discordId?: string }; + if (payload.typ !== LINK_TOKEN_TYPE || !payload.discordId) { + throw new Error('Not a Discord link token'); + } + return payload.discordId; +} diff --git a/server/src/middleware/bot-auth.ts b/server/src/middleware/bot-auth.ts new file mode 100644 index 0000000..7d2d085 --- /dev/null +++ b/server/src/middleware/bot-auth.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from 'crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { env } from '../config/env.js'; + +// Header the Discord bot sends its API key in. +export const BOT_API_KEY_HEADER = 'x-bot-api-key'; + +// Constant-time comparison that also tolerates length differences (timingSafeEqual +// throws on unequal lengths). The key is never logged or echoed in responses. +function keysMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +// Gate for the `/api/integrations/discord/*` routes ONLY. Wired exclusively onto +// those routes, so the bot API key is never an accepted credential anywhere else. +// If no key is configured the integration is effectively disabled (all requests +// rejected) rather than open. +export function requireBotAuth(req: Request, res: Response, next: NextFunction) { + const expected = env.discord.botApiKey; + const provided = req.get(BOT_API_KEY_HEADER) ?? ''; + if (!expected || !keysMatch(provided, expected)) { + return res.status(401).json({ error: 'Invalid bot credentials' }); + } + return next(); +} diff --git a/server/src/models/DiscordLink.ts b/server/src/models/DiscordLink.ts new file mode 100644 index 0000000..6ed9796 --- /dev/null +++ b/server/src/models/DiscordLink.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +// Maps a Discord user to an A-Meet account. Created when a user completes the +// `/meet link` flow; the bot's `/meet create` looks the caller's Discord ID up +// here to find whose meeting to host. One mapping per Discord ID — re-linking +// upserts on `discordId`, so switching A-Meet accounts overwrites in place. +const discordLinkSchema = new mongoose.Schema( + { + discordId: { type: String, required: true, unique: true, index: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + }, + // Only createdAt is meaningful; the mapping is replaced wholesale on re-link. + { timestamps: { createdAt: true, updatedAt: false } }, +); + +export const DiscordLink = mongoose.model('DiscordLink', discordLinkSchema); diff --git a/server/src/routes/discord.routes.ts b/server/src/routes/discord.routes.ts new file mode 100644 index 0000000..b17734e --- /dev/null +++ b/server/src/routes/discord.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { requireBotAuth } from '../middleware/bot-auth.js'; +import { validate } from '../middleware/validate.js'; +import { discordIdBodySchema, discordLinkBodySchema } from '../validation/discord.schema.js'; +import { + createLinkToken, + linkAccount, + createRoomForDiscord, +} from '../controllers/discord.controller.js'; + +// Discord integration API. Mounted at /api/integrations/discord. The bot-key +// gate is applied ONLY to these routes; user routes elsewhere never accept it. +const router = Router(); + +// Bot-key auth: called by the Discord bot process. +router.post('/link-token', requireBotAuth, validate(discordIdBodySchema), createLinkToken); +router.post('/rooms', requireBotAuth, validate(discordIdBodySchema), createRoomForDiscord); + +// User cookie auth: called by the browser `/link/discord` confirmation page. +router.post('/link', requireAuth, validate(discordLinkBodySchema), linkAccount); + +export default router; diff --git a/server/src/validation/discord.schema.ts b/server/src/validation/discord.schema.ts new file mode 100644 index 0000000..1190aae --- /dev/null +++ b/server/src/validation/discord.schema.ts @@ -0,0 +1,21 @@ +import Joi from 'joi'; + +// Discord user IDs are snowflakes: numeric strings, currently 17–20 digits, but +// the width grows over time, so we bound generously rather than pin an exact +// length. Reject anything non-numeric. +const discordId = Joi.string() + .trim() + .pattern(/^\d{1,32}$/) + .required() + .messages({ 'string.pattern.base': 'Invalid Discord ID' }); + +// Body for POST /api/integrations/discord/link-token and .../rooms (bot-key auth). +export const discordIdBodySchema = Joi.object({ discordId }); + +// Body for POST /api/integrations/discord/link (user cookie auth). +export const discordLinkBodySchema = Joi.object({ + token: Joi.string().trim().min(1).required().messages({ + 'string.empty': 'Missing link token', + 'any.required': 'Missing link token', + }), +}); diff --git a/server/test/discord-integration.test.js b/server/test/discord-integration.test.js new file mode 100644 index 0000000..282174f --- /dev/null +++ b/server/test/discord-integration.test.js @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeAll, vi, afterEach } from 'vitest'; +import request from 'supertest'; +import mongoose from 'mongoose'; +import { createApp } from '../src/app.js'; +import { signToken, COOKIE_NAME } from '../src/middleware/auth.js'; +import { mintLinkToken } from '../src/integrations/discord/link-token.js'; +import { DiscordLink } from '../src/models/DiscordLink.js'; +import { Room } from '../src/models/Room.js'; +import { env } from '../src/config/env.js'; +import { useMongoMemoryServer } from './helpers/mongo.js'; + +// Route-level integration tests for the Discord integration endpoints, driven +// through the real app (routes + middleware + Joi validation + error handler) +// against an in-memory MongoDB. The trust boundaries under test are the +// bot-API-key gate, the user cookie gate, and the Discord↔user linking flow. +useMongoMemoryServer(); + +const BOT_KEY = 'test-discord-bot-key'; +const DISCORD_ID = '123456789012345678'; + +let app; +beforeAll(() => { + // The bot key is read from env at request time by the middleware, so setting + // it here (before any request) is sufficient for the whole suite. + env.discord.botApiKey = BOT_KEY; + app = createApp(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function makeUser() { + const id = new mongoose.Types.ObjectId().toString(); + return { id, name: 'Test User', email: `u-${id}@example.com`, avatar: '' }; +} +function cookieFor(user) { + return `${COOKIE_NAME}=${signToken(user)}`; +} +let ipCounter = 0; +function freshIp() { + ipCounter += 1; + return `10.30.${Math.floor(ipCounter / 256) % 256}.${ipCounter % 256}`; +} +function botReq(method, path) { + return request(app)[method](path).set('X-Bot-Api-Key', BOT_KEY).set('X-Forwarded-For', freshIp()); +} + +describe('POST /api/integrations/discord/link-token', () => { + it('mints a token for a valid discordId with the bot key', async () => { + const res = await botReq('post', '/api/integrations/discord/link-token').send({ discordId: DISCORD_ID }); + expect(res.status).toBe(201); + expect(typeof res.body.token).toBe('string'); + expect(res.body.token.length).toBeGreaterThan(0); + // The bot hands the user a ready-made confirmation URL. + expect(res.body.linkUrl).toContain('/link/discord?token='); + }); + + it('rejects a request without the bot key (401)', async () => { + const res = await request(app) + .post('/api/integrations/discord/link-token') + .set('X-Forwarded-For', freshIp()) + .send({ discordId: DISCORD_ID }); + expect(res.status).toBe(401); + }); + + it('rejects a request with a wrong bot key (401)', async () => { + const res = await request(app) + .post('/api/integrations/discord/link-token') + .set('X-Bot-Api-Key', 'wrong-key') + .set('X-Forwarded-For', freshIp()) + .send({ discordId: DISCORD_ID }); + expect(res.status).toBe(401); + }); + + it('rejects a missing/invalid discordId body (400)', async () => { + const res = await botReq('post', '/api/integrations/discord/link-token').send({}); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/integrations/discord/link', () => { + it('links the Discord ID to the signed-in user (upsert)', async () => { + const user = makeUser(); + const token = mintLinkToken(DISCORD_ID); + const res = await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ token }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + + const link = await DiscordLink.findOne({ discordId: DISCORD_ID }); + expect(String(link.userId)).toBe(user.id); + }); + + it('requires a user cookie (401 without one)', async () => { + const token = mintLinkToken(DISCORD_ID); + const res = await request(app) + .post('/api/integrations/discord/link') + .set('X-Forwarded-For', freshIp()) + .send({ token }); + expect(res.status).toBe(401); + expect(await DiscordLink.countDocuments()).toBe(0); + }); + + it('rejects an expired link token (400) and writes nothing', async () => { + const user = makeUser(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const token = mintLinkToken(DISCORD_ID); + vi.setSystemTime(new Date('2026-01-01T01:00:00Z')); + const res = await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ token }); + expect(res.status).toBe(400); + expect(await DiscordLink.countDocuments()).toBe(0); + }); + + it('rejects a normal auth JWT used as a link token (400)', async () => { + const user = makeUser(); + const authToken = signToken(user); // wrong type — an auth cookie JWT + const res = await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ token: authToken }); + expect(res.status).toBe(400); + expect(await DiscordLink.countDocuments()).toBe(0); + }); + + it('rejects a tampered link token (400)', async () => { + const user = makeUser(); + const token = mintLinkToken(DISCORD_ID); + const tampered = `${token.slice(0, -2)}xx`; + const res = await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ token: tampered }); + expect(res.status).toBe(400); + expect(await DiscordLink.countDocuments()).toBe(0); + }); + + it('overwrites the mapping when a Discord ID is re-linked to a new user', async () => { + const first = makeUser(); + const second = makeUser(); + + await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(first)) + .set('X-Forwarded-For', freshIp()) + .send({ token: mintLinkToken(DISCORD_ID) }) + .expect(200); + + await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(second)) + .set('X-Forwarded-For', freshIp()) + .send({ token: mintLinkToken(DISCORD_ID) }) + .expect(200); + + // Exactly one mapping for the Discord ID, now pointing at the second user. + expect(await DiscordLink.countDocuments({ discordId: DISCORD_ID })).toBe(1); + const link = await DiscordLink.findOne({ discordId: DISCORD_ID }); + expect(String(link.userId)).toBe(second.id); + }); +}); + +describe('POST /api/integrations/discord/rooms', () => { + async function link(user, discordId = DISCORD_ID) { + await request(app) + .post('/api/integrations/discord/link') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ token: mintLinkToken(discordId) }) + .expect(200); + } + + it('creates a room hosted by the linked user', async () => { + const user = makeUser(); + await link(user); + + const res = await botReq('post', '/api/integrations/discord/rooms').send({ discordId: DISCORD_ID }); + expect(res.status).toBe(201); + expect(res.body.roomId).toMatch(/^[a-z]{3}-[a-z]{4}-[a-z]{3}$/); + + const room = await Room.findOne({ roomId: res.body.roomId }); + expect(String(room.host)).toBe(user.id); + expect(String(room.admin)).toBe(user.id); + }); + + it('returns a distinct not-linked error (404 + code) for an unknown discordId', async () => { + const res = await botReq('post', '/api/integrations/discord/rooms').send({ discordId: '999999999999999999' }); + expect(res.status).toBe(404); + expect(res.body.code).toBe('not_linked'); + }); + + it('rejects a request without the bot key (401)', async () => { + const res = await request(app) + .post('/api/integrations/discord/rooms') + .set('X-Forwarded-For', freshIp()) + .send({ discordId: DISCORD_ID }); + expect(res.status).toBe(401); + }); +}); + +describe('auth isolation between bot-key and user routes', () => { + it('does NOT accept the bot key on a normal user route', async () => { + // POST /api/rooms is cookie-auth; the bot key must not stand in for a user. + const res = await request(app) + .post('/api/rooms') + .set('X-Bot-Api-Key', BOT_KEY) + .set('X-Forwarded-For', freshIp()) + .send({}); + expect(res.status).toBe(401); + }); + + it('does NOT accept a user cookie on a bot-key route', async () => { + const user = makeUser(); + const res = await request(app) + .post('/api/integrations/discord/rooms') + .set('Cookie', cookieFor(user)) + .set('X-Forwarded-For', freshIp()) + .send({ discordId: DISCORD_ID }); + expect(res.status).toBe(401); + }); +}); diff --git a/server/test/discord-link-token.test.js b/server/test/discord-link-token.test.js new file mode 100644 index 0000000..51ca929 --- /dev/null +++ b/server/test/discord-link-token.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { env } from '../src/config/env.js'; +import { mintLinkToken, verifyLinkToken } from '../src/integrations/discord/link-token.js'; +import { signToken } from '../src/middleware/auth.js'; + +// Unit tests for the single-purpose Discord link token. The token binds one +// Discord ID, is short-lived, and is cryptographically distinct from the auth +// cookie JWT so neither can be used in the other's place. +describe('discord link token', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('round-trips the Discord ID it was minted for', () => { + const token = mintLinkToken('123456789012345678'); + expect(verifyLinkToken(token)).toBe('123456789012345678'); + }); + + it('rejects an expired token', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const token = mintLinkToken('123456789012345678'); + // Jump well past the ~10 minute lifetime. + vi.setSystemTime(new Date('2026-01-01T01:00:00Z')); + expect(() => verifyLinkToken(token)).toThrow(); + }); + + it('rejects a normal auth JWT presented as a link token', () => { + const authToken = signToken({ id: 'u1', name: 'A', email: 'a@example.com', avatar: '' }); + expect(() => verifyLinkToken(authToken)).toThrow(); + }); + + it('rejects a link token presented as an auth JWT (distinct signing key)', () => { + const linkToken = mintLinkToken('123456789012345678'); + // requireAuth verifies against env.jwtSecret; the link token is signed with a + // derived key, so it must fail that verification. + expect(() => jwt.verify(linkToken, env.jwtSecret)).toThrow(); + }); + + it('rejects a tampered token', () => { + const token = mintLinkToken('123456789012345678'); + const tampered = `${token.slice(0, -3)}${token.slice(-3) === 'aaa' ? 'bbb' : 'aaa'}`; + expect(() => verifyLinkToken(tampered)).toThrow(); + }); +}); diff --git a/server/test/logger.test.js b/server/test/logger.test.js index bb80080..c583879 100644 --- a/server/test/logger.test.js +++ b/server/test/logger.test.js @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import pino from 'pino'; +import { Writable } from 'stream'; import { selectLogTargets, buildLoggerOptions, REDACT_PATHS } from '../src/config/logger.js'; // These assert the observable stream-selection and options contract directly, @@ -50,8 +52,27 @@ describe('buildLoggerOptions', () => { expect(opts.redact).toContain('req.headers.cookie'); expect(opts.redact).toContain('*.password'); expect(opts.redact).toContain('*.token'); + // The Discord bot API key is host-grade; pino-http serializes request + // headers, so it must be in the redaction set. + expect(opts.redact).toContain('req.headers["x-bot-api-key"]'); expect(opts.base).toEqual({ service: 'a-meet' }); // ISO timestamps keep log lines correlatable across collectors. expect(typeof opts.timestamp).toBe('function'); }); + + it('censors the Discord bot API key value from serialized request headers', () => { + // Behavioural proof (not just config membership): run a real pino instance + // with the production options over a capture stream and confirm the key + // value never reaches the output — pino replaces it with its censor token. + const lines = []; + const sink = new Writable({ + write(chunk, _enc, cb) { lines.push(chunk.toString()); cb(); }, + }); + const log = pino(buildLoggerOptions('production', 'info'), sink); + log.info({ req: { headers: { 'x-bot-api-key': 'super-secret-bot-key' } } }, 'request completed'); + + const out = lines.join(''); + expect(out).not.toContain('super-secret-bot-key'); + expect(out).toContain('[Redacted]'); + }); }); diff --git a/shared/src/dto.ts b/shared/src/dto.ts index 5cbde42..8ff023f 100644 --- a/shared/src/dto.ts +++ b/shared/src/dto.ts @@ -48,6 +48,52 @@ export interface AuthUserDto { avatar?: string; } +// ── Discord integration contracts ───────────────────────────────────────── +// Consumed by the Discord bot (ticket 2) and the client `/link/discord` page. + +/** Body for `POST /api/integrations/discord/link-token` (bot-key auth). */ +export interface DiscordLinkTokenRequest { + discordId: string; +} + +/** Response of `POST /api/integrations/discord/link-token`. */ +export interface DiscordLinkTokenResponse { + /** Short-lived, single-purpose JWT bound to the Discord ID. */ + token: string; + /** Ready-made confirmation URL the bot DMs to the user. */ + linkUrl: string; +} + +/** Body for `POST /api/integrations/discord/link` (user cookie auth). */ +export interface DiscordLinkRequest { + token: string; +} + +/** Response of `POST /api/integrations/discord/link`. */ +export interface DiscordLinkResponse { + ok: true; +} + +/** Body for `POST /api/integrations/discord/rooms` (bot-key auth). */ +export interface DiscordRoomRequest { + discordId: string; +} + +/** Response of `POST /api/integrations/discord/rooms` — matches `POST /api/rooms`. */ +export interface DiscordRoomResponse { + roomId: string; +} + +/** + * Error body returned by `POST /api/integrations/discord/rooms` when the given + * `discordId` has no linked A-Meet account. The `code` lets the bot detect this + * specific case (vs a generic failure) and prompt the user to run `/meet link`. + */ +export interface DiscordNotLinkedError { + error: string; + code: 'not_linked'; +} + export interface AuthMeResponse { user: AuthUserDto | null; }