import { FastifyPluginAsync } from 'fastify'; import { randomUUID } from 'node:crypto'; import { requireAuth } from '../../middleware/require-auth.js'; import { decrypt, encrypt } from '../../services/crypto.service.js'; import { revokeYouTubeToken } from '../../services/youtube.service.js'; import { revokeTwitchToken } from '../../services/twitch.service.js'; import { AppError } from '../../plugins/error-handler.js'; import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js'; const accountRoutes: FastifyPluginAsync = async (fastify) => { // GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP) fastify.get('/providers/accounts', { preHandler: [requireAuth], }, async (request) => { const accounts = await fastify.prisma.linkedAccount.findMany({ where: { userId: request.userId }, }); const response: LinkedAccountResponse[] = accounts.map((a) => { const base: LinkedAccountResponse = { id: a.id, serviceId: a.serviceId, displayName: a.displayName, accountId: a.accountId, avatarUrl: a.avatarUrl, }; if (a.serviceId === 'CUSTOM_RTMP') { base.rtmpUrl = decrypt(a.accessTokenEnc, a.accessTokenIv); base.streamKey = decrypt(a.refreshTokenEnc, a.refreshTokenIv); } return base; }); return response; }); // POST /providers/accounts/custom-rtmp — create a custom RTMP account fastify.post<{ Body: CreateCustomRtmpBody }>('/providers/accounts/custom-rtmp', { preHandler: [requireAuth], schema: { body: { type: 'object', required: ['displayName', 'rtmpUrl', 'streamKey'], properties: { displayName: { type: 'string', minLength: 1, maxLength: 200 }, rtmpUrl: { type: 'string', minLength: 1, maxLength: 500 }, streamKey: { type: 'string', minLength: 1, maxLength: 500 }, }, additionalProperties: false, }, }, }, async (request, reply) => { const { displayName, rtmpUrl, streamKey } = request.body; const urlEnc = encrypt(rtmpUrl); const keyEnc = encrypt(streamKey); const account = await fastify.prisma.linkedAccount.create({ data: { userId: request.userId, serviceId: 'CUSTOM_RTMP', accountId: randomUUID(), displayName, avatarUrl: null, accessTokenEnc: urlEnc.ciphertext, accessTokenIv: urlEnc.iv, refreshTokenEnc: keyEnc.ciphertext, refreshTokenIv: keyEnc.iv, tokenExpiresAt: new Date('2099-12-31T00:00:00Z'), }, }); const response: LinkedAccountResponse = { id: account.id, serviceId: account.serviceId, displayName: account.displayName, accountId: account.accountId, avatarUrl: account.avatarUrl, rtmpUrl, streamKey, }; reply.status(201).send(response); }); // DELETE /providers/accounts/:id — revoke tokens and unlink by account ID fastify.delete<{ Params: { id: string } }>('/providers/accounts/:id', { preHandler: [requireAuth], schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' }, }, }, }, }, async (request, reply) => { const account = await fastify.prisma.linkedAccount.findFirst({ where: { id: request.params.id, userId: request.userId, }, }); if (!account) { throw new AppError(404, 'Account not linked'); } // Best-effort revoke tokens at the provider (skip for CUSTOM_RTMP) if (account.serviceId !== 'CUSTOM_RTMP') { try { const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv); if (account.serviceId === 'YOUTUBE') { await revokeYouTubeToken(accessToken); } else { await revokeTwitchToken(accessToken); } } catch { // Revocation failure is non-fatal } } await fastify.prisma.linkedAccount.delete({ where: { id: account.id }, }); reply.status(200).send({ success: true }); }); }; export default accountRoutes;