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
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user