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:
2026-03-01 10:50:28 +01:00
parent 02755bd1f0
commit 08cca68086
6 changed files with 239 additions and 46 deletions

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,

View File

@@ -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 ?? '',
},
});
}
}
}
}

View File

@@ -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 {