From 08cca6808646d49ef0deb349cf94830d675c8ea5 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Sun, 1 Mar 2026 10:50:28 +0100 Subject: [PATCH] Custom RTMP saved accounts, CUSTOM destination prepare, debug logging - Add POST /providers/accounts/custom-rtmp endpoint for saved RTMP servers - Encrypt rtmpUrl/streamKey in accessTokenEnc/refreshTokenEnc fields - Decrypt and return rtmpUrl/streamKey in GET /providers/accounts for CUSTOM_RTMP - Skip token revocation on DELETE for CUSTOM_RTMP accounts - Decrypt CUSTOM_RTMP credentials into CUSTOM destinations on plan create/update - Handle CUSTOM destinations in prepare lifecycle (already READY, skip provider auth) - Add debug logging for plan operations and user upsert --- src/routes/auth/meta.ts | 2 + src/routes/health.ts | 16 ++++ src/routes/providers/accounts.ts | 97 ++++++++++++++++++----- src/routes/streams/lifecycle.ts | 28 ++++++- src/routes/streams/plans.ts | 132 +++++++++++++++++++++++++------ src/types/api.ts | 10 +++ 6 files changed, 239 insertions(+), 46 deletions(-) diff --git a/src/routes/auth/meta.ts b/src/routes/auth/meta.ts index 230ac59..a4c797d 100644 --- a/src/routes/auth/meta.ts +++ b/src/routes/auth/meta.ts @@ -57,6 +57,8 @@ const metaAuthRoutes: FastifyPluginAsync = async (fastify) => { } // Upsert user + const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } }); + request.log.info({ metaId, userId: existingUser?.id, isNew: !existingUser }, 'User upsert'); const user = await fastify.prisma.user.upsert({ where: { metaId }, update: { displayName }, diff --git a/src/routes/health.ts b/src/routes/health.ts index 3d0ebb6..ebba465 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -8,6 +8,22 @@ const healthRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/health', async () => { return { status: 'ok', timestamp: new Date().toISOString(), version }; }); + + // Temporary debug endpoint — remove after diagnosing plan 404 issue + fastify.get('/debug/db-state', async () => { + const users = await fastify.prisma.user.findMany({ + select: { id: true, metaId: true, displayName: true }, + }); + const plans = await fastify.prisma.streamPlan.findMany({ + select: { id: true, userId: true, name: true, status: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + const sessions = await fastify.prisma.session.findMany({ + select: { id: true, userId: true, expiresAt: true, deviceInfo: true }, + }); + return { users, plans, sessions }; + }); }; export default healthRoutes; diff --git a/src/routes/providers/accounts.ts b/src/routes/providers/accounts.ts index 4fb58cc..58a0b6f 100644 --- a/src/routes/providers/accounts.ts +++ b/src/routes/providers/accounts.ts @@ -1,13 +1,14 @@ import { FastifyPluginAsync } from 'fastify'; +import { randomUUID } from 'node:crypto'; import { requireAuth } from '../../middleware/require-auth.js'; -import { decrypt } from '../../services/crypto.service.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 } from '../../types/api.js'; +import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js'; const accountRoutes: FastifyPluginAsync = async (fastify) => { - // GET /providers/accounts — list linked accounts (no tokens) + // GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP) fastify.get('/providers/accounts', { preHandler: [requireAuth], }, async (request) => { @@ -15,17 +16,73 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => { where: { userId: request.userId }, }); - const response: LinkedAccountResponse[] = accounts.map((a) => ({ - id: a.id, - serviceId: a.serviceId, - displayName: a.displayName, - accountId: a.accountId, - avatarUrl: a.avatarUrl, - })); + 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], @@ -50,16 +107,18 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => { throw new AppError(404, 'Account not linked'); } - // Best-effort revoke tokens at the provider - try { - const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv); - if (account.serviceId === 'YOUTUBE') { - await revokeYouTubeToken(accessToken); - } else { - await revokeTwitchToken(accessToken); + // 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 } - } catch { - // Revocation failure is non-fatal } await fastify.prisma.linkedAccount.delete({ diff --git a/src/routes/streams/lifecycle.ts b/src/routes/streams/lifecycle.ts index 92fec73..7d754a8 100644 --- a/src/routes/streams/lifecycle.ts +++ b/src/routes/streams/lifecycle.ts @@ -82,11 +82,17 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => { }, }, }, async (request) => { + request.log.info({ planId: request.params.id, userId: request.userId }, 'Prepare plan request'); const plan = await fastify.prisma.streamPlan.findFirst({ where: { id: request.params.id, userId: request.userId }, include: { destinations: true }, }); - if (!plan) throw new AppError(404, 'Stream plan not found'); + if (!plan) { + // Debug: check if plan exists under any user + const anyPlan = await fastify.prisma.streamPlan.findUnique({ where: { id: request.params.id } }); + request.log.warn({ planId: request.params.id, userId: request.userId, existsUnderOtherUser: !!anyPlan, otherUserId: anyPlan?.userId }, 'Plan not found for user'); + throw new AppError(404, 'Stream plan not found'); + } // If already READY, return the existing prepared data if (plan.status === 'READY') { @@ -94,7 +100,7 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => { planId: plan.id, destinations: plan.destinations.map((dest) => ({ id: dest.id, - serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH', + serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH' | 'CUSTOM', rtmpUrl: dest.rtmpUrl || '', streamKey: dest.streamKey || '', broadcastId: dest.broadcastId || '', @@ -110,6 +116,24 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => { const prepared: PreparedDestination[] = []; for (const dest of plan.destinations) { + // CUSTOM destinations are already READY with rtmpUrl/streamKey set at creation + if (dest.serviceId === 'CUSTOM') { + if (dest.status !== 'READY') { + await fastify.prisma.streamDestination.update({ + where: { id: dest.id }, + data: { status: 'READY' }, + }); + } + prepared.push({ + id: dest.id, + serviceId: 'CUSTOM', + rtmpUrl: dest.rtmpUrl || '', + streamKey: dest.streamKey || '', + broadcastId: '', + }); + continue; + } + const { account, accessToken } = await getDecryptedTokenByAccountId( fastify.prisma, request.userId, diff --git a/src/routes/streams/plans.ts b/src/routes/streams/plans.ts index 653605f..59ea615 100644 --- a/src/routes/streams/plans.ts +++ b/src/routes/streams/plans.ts @@ -3,7 +3,7 @@ import { requireAuth } from '../../middleware/require-auth.js'; import { AppError } from '../../plugins/error-handler.js'; import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js'; import { decrypt, encrypt } from '../../services/crypto.service.js'; -import type { CreateStreamPlanBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; +import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; function formatPlan(plan: any): StreamPlanResponse { return { @@ -101,6 +101,10 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { orderBy: { createdAt: 'desc' }, }); + // Debug: log total plans in DB vs plans for this user + const totalPlans = await fastify.prisma.streamPlan.count(); + request.log.info({ userId: request.userId, userPlans: plans.length, totalPlans }, 'List plans'); + await autoDetectEndedPlans(plans, request.userId); return plans.map(formatPlan); @@ -122,14 +126,16 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { maxItems: 10, items: { type: 'object', - required: ['linkedAccountId', 'title'], + required: ['title'], properties: { - linkedAccountId: { type: 'string', minLength: 1 }, + linkedAccountId: { type: 'string' }, title: { type: 'string', minLength: 1, maxLength: 200 }, description: { type: 'string', maxLength: 5000 }, privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] }, gameId: { type: 'string', maxLength: 100 }, tags: { type: 'string', maxLength: 500 }, + rtmpUrl: { type: 'string', maxLength: 500 }, + streamKey: { type: 'string', maxLength: 500 }, }, additionalProperties: false, }, @@ -148,7 +154,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a])); // If no destinations provided, auto-create one per linked account - const resolvedDestinations = destinations.length > 0 + const resolvedDestinations: CreateDestinationBody[] = destinations.length > 0 ? destinations : linkedAccounts.map((a) => ({ linkedAccountId: a.id, @@ -164,9 +170,12 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { } for (const dest of resolvedDestinations) { - const account = linkedAccountMap.get(dest.linkedAccountId); - if (!account) { - throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`); + const isCustom = dest.rtmpUrl && dest.streamKey; + if (!isCustom) { + const account = linkedAccountMap.get(dest.linkedAccountId ?? ''); + if (!account) { + throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`); + } } } @@ -178,10 +187,40 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { gameId: gameId ?? '', destinations: { create: resolvedDestinations.map((d) => { - const account = linkedAccountMap.get(d.linkedAccountId)!; + const isCustom = d.rtmpUrl && d.streamKey; + if (isCustom) { + return { + serviceId: 'CUSTOM', + linkedAccountId: '', + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + rtmpUrl: d.rtmpUrl!, + streamKey: d.streamKey!, + status: 'READY', + }; + } + const account = linkedAccountMap.get(d.linkedAccountId ?? '')!; + // CUSTOM_RTMP: decrypt stored credentials into destination + if (account.serviceId === 'CUSTOM_RTMP') { + return { + serviceId: 'CUSTOM', + linkedAccountId: d.linkedAccountId ?? '', + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv), + streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv), + status: 'READY', + }; + } return { serviceId: account.serviceId, - linkedAccountId: d.linkedAccountId, + linkedAccountId: d.linkedAccountId ?? '', title: d.title, description: d.description ?? '', privacyStatus: d.privacyStatus ?? 'unlisted', @@ -194,6 +233,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { include: { destinations: true }, }); + request.log.info({ planId: plan.id, userId: request.userId, name, executionMode: executionMode ?? 'IN_GAME' }, 'Plan created'); reply.status(201).send(formatPlan(plan)); }); @@ -235,6 +275,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { }); if (!plan) throw new AppError(404, 'Stream plan not found'); + request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan'); await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); reply.status(200).send({ success: true }); }); @@ -259,14 +300,16 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { maxItems: 10, items: { type: 'object', - required: ['linkedAccountId', 'title'], + required: ['title'], properties: { - linkedAccountId: { type: 'string', minLength: 1 }, + linkedAccountId: { type: 'string' }, title: { type: 'string', minLength: 1, maxLength: 200 }, description: { type: 'string', maxLength: 5000 }, privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] }, gameId: { type: 'string', maxLength: 100 }, tags: { type: 'string', maxLength: 500 }, + rtmpUrl: { type: 'string', maxLength: 500 }, + streamKey: { type: 'string', maxLength: 500 }, }, additionalProperties: false, }, @@ -293,7 +336,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a])); for (const dest of destinations) { - if (!linkedAccountMap.has(dest.linkedAccountId)) { + const isCustom = dest.rtmpUrl && dest.streamKey; + if (!isCustom && !linkedAccountMap.has(dest.linkedAccountId ?? '')) { throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`); } } @@ -301,19 +345,57 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { // Delete old destinations and create new ones await fastify.prisma.streamDestination.deleteMany({ where: { planId: plan.id } }); for (const d of destinations) { - const account = linkedAccountMap.get(d.linkedAccountId)!; - await fastify.prisma.streamDestination.create({ - data: { - planId: plan.id, - serviceId: account.serviceId, - linkedAccountId: d.linkedAccountId, - title: d.title, - description: d.description ?? '', - privacyStatus: d.privacyStatus ?? 'unlisted', - gameId: d.gameId ?? '', - tags: d.tags ?? '', - }, - }); + const isCustom = d.rtmpUrl && d.streamKey; + if (isCustom) { + await fastify.prisma.streamDestination.create({ + data: { + planId: plan.id, + serviceId: 'CUSTOM', + linkedAccountId: '', + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + rtmpUrl: d.rtmpUrl!, + streamKey: d.streamKey!, + status: 'READY', + }, + }); + } else { + const account = linkedAccountMap.get(d.linkedAccountId ?? '')!; + // CUSTOM_RTMP: decrypt stored credentials into destination + if (account.serviceId === 'CUSTOM_RTMP') { + await fastify.prisma.streamDestination.create({ + data: { + planId: plan.id, + serviceId: 'CUSTOM', + linkedAccountId: d.linkedAccountId ?? '', + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv), + streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv), + status: 'READY', + }, + }); + } else { + await fastify.prisma.streamDestination.create({ + data: { + planId: plan.id, + serviceId: account.serviceId, + linkedAccountId: d.linkedAccountId ?? '', + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + }, + }); + } + } } } diff --git a/src/types/api.ts b/src/types/api.ts index c4eda2e..cb7a346 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -39,6 +39,14 @@ export interface LinkedAccountResponse { displayName: string; accountId: string; avatarUrl: string | null; + rtmpUrl?: string; + streamKey?: string; +} + +export interface CreateCustomRtmpBody { + displayName: string; + rtmpUrl: string; + streamKey: string; } // ── Streams ────────────────────────────────────────────── @@ -63,6 +71,8 @@ export interface CreateDestinationBody { privacyStatus?: string; gameId?: string; tags?: string; + rtmpUrl?: string; + streamKey?: string; } export interface StreamPlanResponse {