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

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