Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -10,6 +11,17 @@ export default function App() {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
{/* 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. */}
<Route
path="/link/discord"
element={
<ProtectedRoute>
<LinkDiscordPage />
</ProtectedRoute>
}
/>
<Route
path="/lobby/:roomId"
element={
Expand Down
10 changes: 10 additions & 0 deletions client/src/api/discord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import api from './axios';
import type { DiscordLinkResponse } from '@a-meet/contracts';

// Exchange a single-purpose link token (from a Discord `/meet link` DM) for a
// persisted Discord↔account mapping. The auth cookie identifies the account;
// resolves on success, throws (like axios) on a non-2xx — a 400 means the token
// is invalid or expired.
export async function linkDiscord(token: string): Promise<void> {
await api.post<DiscordLinkResponse>('/integrations/discord/link', { token });
}
62 changes: 62 additions & 0 deletions client/src/pages/LinkDiscordPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider theme={theme}>
<MemoryRouter initialEntries={[path]}>
<LinkDiscordPage />
</MemoryRouter>
</ThemeProvider>,
);
}

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();
});
});
105 changes: 105 additions & 0 deletions client/src/pages/LinkDiscordPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Status>(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 (
<Box
sx={{
minHeight: '100vh',
'@supports (min-height: 100dvh)': { minHeight: '100dvh' },
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
}}
>
<Box component="header" sx={{ px: { xs: 2, sm: 4 }, py: 2 }}>
<BrandMark />
</Box>

<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 3, py: 6 }}>
<Paper
variant="outlined"
sx={{ p: { xs: 3, sm: 5 }, maxWidth: 460, width: '100%', textAlign: 'center', borderRadius: 3, borderColor: 'divider' }}
>
<LinkStatus status={status} />
</Paper>
</Box>
</Box>
);
}

function LinkStatus({ status }: { status: Status }) {
if (status === 'linking') {
return (
<Stack spacing={3} sx={{ alignItems: 'center' }}>
<CircularProgress />
<Typography variant="h6">Linking your Discord account…</Typography>
</Stack>
);
}

if (status === 'linked') {
return (
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<CheckCircleIcon sx={{ color: 'success.main', fontSize: 56 }} />
<Typography variant="h5">Discord linked</Typography>
<Typography variant="body1" color="text.secondary">
Your Discord account is now linked to A-Meet. Head back to Discord and run{' '}
<Box component="span" sx={{ color: 'text.primary', fontWeight: 600 }}>/meet create</Box>{' '}
to start a meeting.
</Typography>
</Stack>
);
}

// '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 (
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<ErrorOutlineIcon sx={{ color: 'error.main', fontSize: 56 }} />
<Typography variant="h5">Couldn’t link Discord</Typography>
<Typography variant="body1" color="text.secondary">{message}</Typography>
<Button variant="contained" href="/" sx={{ borderRadius: 999, px: 4, py: 1.25 }}>
Return to home screen
</Button>
</Stack>
);
}
10 changes: 10 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions server/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down
10 changes: 9 additions & 1 deletion server/src/config/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions server/src/controllers/discord.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion server/src/controllers/room.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
export async function createUniqueRoom(fields: Record<string, unknown>) {
for (let attempt = 0; attempt < 5; attempt++) {
const roomId = generateRoomId();
try {
Expand Down
38 changes: 38 additions & 0 deletions server/src/integrations/discord/link-token.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading