Pairing code auth, replace Facebook OAuth, public feed
- Add PairingCode model, POST /generate + /redeem + GET /status endpoints - Remove facebookId from User, make metaId non-nullable - Delete meta-web routes, link routes, meta-web-auth service - Remove metaWeb config block and hasFacebookLink from responses - Add optionalAuth middleware, make feed publicly accessible - Resolve Twitch channel names for embed broadcastIds
This commit is contained in:
@@ -9,8 +9,7 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
metaId String? @unique
|
||||
facebookId String? @unique
|
||||
metaId String @unique
|
||||
displayName String
|
||||
email String?
|
||||
avatarUrl String?
|
||||
@@ -22,11 +21,23 @@ model User {
|
||||
linkedAccounts LinkedAccount[]
|
||||
streamPlans StreamPlan[]
|
||||
sessions Session[]
|
||||
pairingCodes PairingCode[]
|
||||
followers Follow[] @relation("following")
|
||||
following Follow[] @relation("follower")
|
||||
likes Like[]
|
||||
}
|
||||
|
||||
model PairingCode {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
|
||||
@@ -5,11 +5,11 @@ import websocket from '@fastify/websocket';
|
||||
import prismaPlugin from './plugins/prisma.js';
|
||||
import errorHandlerPlugin from './plugins/error-handler.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import pageRoutes from './routes/pages.js';
|
||||
import healthRoutes from './routes/health.js';
|
||||
import metaAuthRoutes from './routes/auth/meta.js';
|
||||
import metaWebRoutes from './routes/auth/meta-web.js';
|
||||
import sessionRoutes from './routes/auth/session.js';
|
||||
import linkRoutes from './routes/auth/link.js';
|
||||
import pairingRoutes from './routes/auth/pairing.js';
|
||||
import accountRoutes from './routes/providers/accounts.js';
|
||||
import youtubeRoutes from './routes/providers/youtube.js';
|
||||
import twitchRoutes from './routes/providers/twitch.js';
|
||||
@@ -48,11 +48,11 @@ export async function buildApp() {
|
||||
const chatManager = new ChatManager(app.prisma, app.log);
|
||||
|
||||
// Routes
|
||||
await app.register(pageRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(metaAuthRoutes);
|
||||
await app.register(metaWebRoutes);
|
||||
await app.register(sessionRoutes);
|
||||
await app.register(linkRoutes);
|
||||
await app.register(pairingRoutes);
|
||||
await app.register(accountRoutes);
|
||||
await app.register(youtubeRoutes);
|
||||
await app.register(twitchRoutes);
|
||||
|
||||
@@ -38,12 +38,6 @@ export const config = {
|
||||
redirectUri: required('TWITCH_REDIRECT_URI'),
|
||||
},
|
||||
|
||||
metaWeb: {
|
||||
clientId: optional('META_WEB_CLIENT_ID', ''),
|
||||
clientSecret: optional('META_WEB_CLIENT_SECRET', ''),
|
||||
redirectUri: optional('META_WEB_REDIRECT_URI', ''),
|
||||
},
|
||||
|
||||
portalUrl: optional('PORTAL_URL', 'https://portal.omigame.dev'),
|
||||
appScheme: optional('APP_SCHEME', 'com.omixlab.lckcontrol'),
|
||||
corsOrigin: optional('CORS_ORIGIN', '*'),
|
||||
|
||||
@@ -14,3 +14,13 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
reply.status(401).send({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function optionalAuth(request: FastifyRequest) {
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) return;
|
||||
try {
|
||||
request.userId = await verifyAccessToken(header.slice(7));
|
||||
} catch {
|
||||
// not authenticated — continue as anonymous
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { verifyUserProof } from '../../services/meta-auth.service.js';
|
||||
import { exchangeFacebookCode, fetchFacebookProfile } from '../../services/meta-web-auth.service.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { LinkQuestBody, LinkFacebookBody } from '../../types/api.js';
|
||||
|
||||
const linkRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/link-quest — portal user links their Quest account
|
||||
fastify.post<{ Body: LinkQuestBody }>('/auth/link-quest', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['metaId'],
|
||||
properties: {
|
||||
metaId: { type: 'string', minLength: 1 },
|
||||
nonce: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { metaId, nonce } = request.body;
|
||||
|
||||
// Validate nonce if provided
|
||||
if (nonce) {
|
||||
const isValid = await verifyUserProof(metaId, nonce);
|
||||
if (!isValid) {
|
||||
throw new AppError(401, 'Invalid user proof');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if metaId is already linked to another user
|
||||
const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } });
|
||||
if (existingUser && existingUser.id !== request.userId) {
|
||||
// Merge: move all data from existing user to current user
|
||||
await fastify.prisma.$transaction([
|
||||
fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }),
|
||||
fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.user.delete({ where: { id: existingUser.id } }),
|
||||
fastify.prisma.user.update({ where: { id: request.userId }, data: { metaId } }),
|
||||
]);
|
||||
} else {
|
||||
// Simply set metaId on current user
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: { metaId },
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// POST /auth/link-facebook — Quest user links their Facebook account
|
||||
fastify.post<{ Body: LinkFacebookBody }>('/auth/link-facebook', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 1 },
|
||||
state: { type: 'string', minLength: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code } = request.body;
|
||||
|
||||
const { accessToken: fbToken } = await exchangeFacebookCode(code);
|
||||
const profile = await fetchFacebookProfile(fbToken);
|
||||
|
||||
// Check if facebookId is already linked to another user
|
||||
const existingUser = await fastify.prisma.user.findUnique({ where: { facebookId: profile.facebookId } });
|
||||
if (existingUser && existingUser.id !== request.userId) {
|
||||
// Merge: move all data from existing user to current user
|
||||
await fastify.prisma.$transaction([
|
||||
fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }),
|
||||
fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.user.delete({ where: { id: existingUser.id } }),
|
||||
fastify.prisma.user.update({ where: { id: request.userId }, data: { facebookId: profile.facebookId } }),
|
||||
]);
|
||||
} else {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: { facebookId: profile.facebookId },
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
};
|
||||
|
||||
export default linkRoutes;
|
||||
@@ -1,128 +0,0 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { signAccessToken } from '../../plugins/auth.js';
|
||||
import { hashToken } from '../../services/crypto.service.js';
|
||||
import { exchangeFacebookCode, fetchFacebookProfile, getFacebookOAuthUrl } from '../../services/meta-web-auth.service.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthTokensResponse, MetaWebCallbackBody } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store
|
||||
const pendingStates = new Map<string, { expiresAt: number }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, val] of pendingStates) {
|
||||
if (val.expiresAt < now) pendingStates.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const metaWebRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /auth/meta/web/auth-url — returns Facebook OAuth dialog URL
|
||||
fastify.get('/auth/meta/web/auth-url', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
}, async () => {
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
url: getFacebookOAuthUrl(state),
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
||||
// GET /auth/meta/web/callback-redirect — Facebook redirects here → 302 to portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/auth/meta/web/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.portalUrl}/auth/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.portalUrl}/auth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /auth/meta/web/callback — exchanges Facebook code for JWT
|
||||
fastify.post<{ Body: MetaWebCallbackBody }>('/auth/meta/web/callback', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 1 },
|
||||
state: { type: 'string', minLength: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code, state } = request.body;
|
||||
|
||||
// Validate CSRF state
|
||||
const pending = pendingStates.get(state);
|
||||
if (!pending || pending.expiresAt < Date.now()) {
|
||||
pendingStates.delete(state);
|
||||
throw new AppError(400, 'Invalid or expired state parameter');
|
||||
}
|
||||
pendingStates.delete(state);
|
||||
|
||||
// Exchange code for Facebook access token
|
||||
const { accessToken: fbToken } = await exchangeFacebookCode(code);
|
||||
const profile = await fetchFacebookProfile(fbToken);
|
||||
|
||||
// Upsert user by facebookId
|
||||
const user = await fastify.prisma.user.upsert({
|
||||
where: { facebookId: profile.facebookId },
|
||||
update: {
|
||||
displayName: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
},
|
||||
create: {
|
||||
facebookId: profile.facebookId,
|
||||
displayName: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
// Create session
|
||||
const refreshToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000);
|
||||
|
||||
await fastify.prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken: hashToken(refreshToken),
|
||||
expiresAt,
|
||||
deviceInfo: 'web-portal',
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken(user.id);
|
||||
|
||||
const response: AuthTokensResponse = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.accessTtl,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
};
|
||||
|
||||
export default metaWebRoutes;
|
||||
133
src/routes/auth/pairing.ts
Normal file
133
src/routes/auth/pairing.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID, randomInt } from 'node:crypto';
|
||||
import { signAccessToken } from '../../plugins/auth.js';
|
||||
import { hashToken } from '../../services/crypto.service.js';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthTokensResponse, PairingGenerateResponse, PairingRedeemBody } from '../../types/api.js';
|
||||
|
||||
const PAIRING_CODE_TTL = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const pairingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/pairing/generate — create a 6-digit pairing code (requires auth)
|
||||
fastify.post('/auth/pairing/generate', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
// Cleanup expired codes globally
|
||||
await fastify.prisma.pairingCode.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
|
||||
// Delete any existing codes for this user
|
||||
await fastify.prisma.pairingCode.deleteMany({
|
||||
where: { userId: request.userId },
|
||||
});
|
||||
|
||||
// Generate unique 6-digit code
|
||||
let code: string;
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = String(randomInt(100000, 999999));
|
||||
const existing = await fastify.prisma.pairingCode.findUnique({ where: { code } });
|
||||
if (!existing) break;
|
||||
attempts++;
|
||||
} while (attempts < 10);
|
||||
|
||||
if (attempts >= 10) {
|
||||
throw new AppError(500, 'Failed to generate unique pairing code');
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + PAIRING_CODE_TTL);
|
||||
|
||||
await fastify.prisma.pairingCode.create({
|
||||
data: {
|
||||
code,
|
||||
userId: request.userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const response: PairingGenerateResponse = {
|
||||
code,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
|
||||
// GET /auth/pairing/status — check if user's pairing code is still active
|
||||
fastify.get('/auth/pairing/status', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const code = await fastify.prisma.pairingCode.findFirst({
|
||||
where: { userId: request.userId, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
reply.status(200).send({
|
||||
active: !!code,
|
||||
code: code?.code ?? null,
|
||||
expiresAt: code?.expiresAt.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /auth/pairing/redeem — exchange a pairing code for tokens (public, rate-limited)
|
||||
fastify.post<{ Body: PairingRedeemBody }>('/auth/pairing/redeem', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 6, maxLength: 6, pattern: '^[0-9]{6}$' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code } = request.body;
|
||||
|
||||
const pairingCode = await fastify.prisma.pairingCode.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!pairingCode) {
|
||||
throw new AppError(400, 'Invalid pairing code');
|
||||
}
|
||||
|
||||
if (pairingCode.expiresAt < new Date()) {
|
||||
await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } });
|
||||
throw new AppError(400, 'Pairing code has expired');
|
||||
}
|
||||
|
||||
// Delete code (single use)
|
||||
await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } });
|
||||
|
||||
// Create session
|
||||
const refreshToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000);
|
||||
|
||||
await fastify.prisma.session.create({
|
||||
data: {
|
||||
userId: pairingCode.userId,
|
||||
refreshToken: hashToken(refreshToken),
|
||||
expiresAt,
|
||||
deviceInfo: 'web-portal',
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken(pairingCode.userId);
|
||||
|
||||
const response: AuthTokensResponse = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.accessTtl,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
};
|
||||
|
||||
export default pairingRoutes;
|
||||
@@ -85,8 +85,6 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
hasQuestLink: !!user.metaId,
|
||||
hasFacebookLink: !!user.facebookId,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
@@ -124,8 +122,6 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
hasQuestLink: !!user.metaId,
|
||||
hasFacebookLink: !!user.facebookId,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
|
||||
215
src/routes/pages.ts
Normal file
215
src/routes/pages.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const portalUrl = config.portalUrl || 'https://portal.omigame.dev';
|
||||
|
||||
function layout(title: string, body: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #09090b; color: #fafafa; line-height: 1.6; }
|
||||
.container { max-width: 640px; margin: 0 auto; padding: 48px 20px; }
|
||||
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
h2 { font-size: 18px; margin: 24px 0 8px; }
|
||||
p, li { font-size: 14px; color: #a1a1aa; }
|
||||
a { color: #3b82f6; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
ul { margin-left: 24px; margin-top: 8px; }
|
||||
li { margin-bottom: 4px; }
|
||||
.subtitle { color: #a1a1aa; font-size: 14px; margin-bottom: 32px; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #27272a; font-size: 12px; color: #52525b; display: flex; gap: 16px; }
|
||||
.btn { display: inline-block; padding: 10px 24px; background: #3b82f6; color: #fff; border-radius: 8px; font-size: 14px; font-weight: 500; }
|
||||
.btn:hover { background: #2563eb; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${body}
|
||||
<div class="footer">
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
<a href="${portalUrl}">Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const pageRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// Landing page
|
||||
fastify.get('/', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('LCK Control',
|
||||
`<h1>LCK Control</h1>
|
||||
<p class="subtitle">Stream management platform for Meta Quest</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>What is LCK Control?</h2>
|
||||
<p>LCK Control is a live streaming management platform that lets you broadcast gameplay from your Meta Quest headset to YouTube, Twitch, and custom RTMP destinations simultaneously. It consists of:</p>
|
||||
<ul>
|
||||
<li><strong>Companion App</strong> — An Android app on your Quest headset that captures and streams gameplay to multiple platforms at once.</li>
|
||||
<li><strong>Web Portal</strong> — A website at <a href="${portalUrl}">${portalUrl.replace('https://', '')}</a> where you can manage stream plans, link accounts, and discover other users' live streams.</li>
|
||||
<li><strong>Backend API</strong> — This server that handles authentication, stream lifecycle management, and real-time chat integration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Multi-platform streaming — broadcast to YouTube, Twitch, and custom RTMP servers simultaneously.</li>
|
||||
<li>Stream plan management — create, prepare, start, and end streams from your headset or the web portal.</li>
|
||||
<li>Live chat integration — view and respond to YouTube, Twitch, and portal comments in real-time.</li>
|
||||
<li>Discovery feed — browse and watch other users' live streams and VODs in a TikTok-style vertical feed.</li>
|
||||
<li>Social features — follow users, like streams, and leave comments.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<a href="${portalUrl}" class="btn">Open Portal</a>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
|
||||
// Privacy policy
|
||||
fastify.get('/privacy', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('Privacy Policy — LCK Control',
|
||||
`<h1>Privacy Policy</h1>
|
||||
<p class="subtitle">Last updated: March 2, 2026</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Information We Collect</h2>
|
||||
<p>When you use LCK Control, we may collect the following information:</p>
|
||||
<ul>
|
||||
<li><strong>Account information:</strong> Your name, profile picture, and email address from your Meta (Facebook) or Meta Quest account when you log in.</li>
|
||||
<li><strong>Linked service data:</strong> When you connect YouTube or Twitch accounts, we store access tokens to manage streams on your behalf. We do not store your passwords.</li>
|
||||
<li><strong>Usage data:</strong> Stream plans you create, likes, follows, and comments you make on the platform.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. How We Use Your Information</h2>
|
||||
<ul>
|
||||
<li>To authenticate you and provide access to the platform.</li>
|
||||
<li>To create and manage live streams on YouTube and Twitch on your behalf.</li>
|
||||
<li>To display your public profile and live streams to other users (when you opt in to public discovery).</li>
|
||||
<li>To enable social features such as likes, comments, and follows.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Data Sharing</h2>
|
||||
<p>We do not sell your personal information. We share data only with:</p>
|
||||
<ul>
|
||||
<li><strong>YouTube API Services:</strong> To manage your live broadcasts. YouTube API Services are subject to <a href="https://policies.google.com/privacy">Google's Privacy Policy</a>.</li>
|
||||
<li><strong>Twitch API:</strong> To manage your Twitch streams.</li>
|
||||
<li><strong>Meta Platform:</strong> For authentication purposes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Data Storage & Security</h2>
|
||||
<p>Your data is stored on our secure servers. Access tokens for linked services are encrypted at rest using AES-256-GCM. We retain your data as long as your account is active.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Your Rights</h2>
|
||||
<p>You can:</p>
|
||||
<ul>
|
||||
<li>Unlink any connected service (YouTube, Twitch) at any time from the Accounts page.</li>
|
||||
<li>Toggle your public visibility on or off.</li>
|
||||
<li>Request deletion of your account and all associated data by contacting us.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Google API Services Disclosure</h2>
|
||||
<p>LCK Control's use and transfer to any other app of information received from Google APIs will adhere to the <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>, including the Limited Use requirements.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Contact</h2>
|
||||
<p>If you have questions about this privacy policy, please contact us at <a href="mailto:omar@omigame.dev">omar@omigame.dev</a>.</p>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
|
||||
// Terms of service
|
||||
fastify.get('/terms', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('Terms of Service — LCK Control',
|
||||
`<h1>Terms of Service</h1>
|
||||
<p class="subtitle">Last updated: March 2, 2026</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>By accessing or using LCK Control ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Description of Service</h2>
|
||||
<p>LCK Control is a live streaming management platform that allows you to broadcast gameplay from your Meta Quest headset to multiple platforms (YouTube, Twitch) simultaneously, discover other users' live streams, and interact through likes and comments.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Account & Access</h2>
|
||||
<ul>
|
||||
<li>You must log in with a valid Meta (Facebook) or Meta Quest account to access authenticated features.</li>
|
||||
<li>You are responsible for maintaining the security of your account.</li>
|
||||
<li>You must not share your account or use the Service on behalf of others without authorization.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Linked Services</h2>
|
||||
<p>When you link YouTube or Twitch accounts, you authorize LCK Control to manage streams on your behalf. You remain subject to the terms of service of those platforms. You can unlink any service at any time.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. User Conduct</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the Service for any unlawful purpose.</li>
|
||||
<li>Stream content that violates the terms of YouTube, Twitch, or applicable law.</li>
|
||||
<li>Harass, abuse, or post harmful content in comments.</li>
|
||||
<li>Attempt to gain unauthorized access to the Service or its systems.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Content & Intellectual Property</h2>
|
||||
<p>You retain ownership of the content you stream. By making your streams public on the portal, you grant other users the ability to view, like, and comment on them. We do not claim ownership of your content.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>The Service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation. Stream management depends on third-party APIs (YouTube, Twitch) which may have their own limitations.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Limitation of Liability</h2>
|
||||
<p>To the maximum extent permitted by law, we shall not be liable for any indirect, incidental, or consequential damages arising from your use of the Service, including but not limited to failed streams, data loss, or service interruptions.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>9. Termination</h2>
|
||||
<p>We may suspend or terminate your access to the Service at any time for violation of these terms. You may stop using the Service at any time by unlinking your accounts and discontinuing use.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>10. Changes to Terms</h2>
|
||||
<p>We may update these terms from time to time. Continued use of the Service after changes constitutes acceptance of the updated terms.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>11. Contact</h2>
|
||||
<p>If you have questions about these terms, please contact us at <a href="mailto:omar@omigame.dev">omar@omigame.dev</a>.</p>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
export default pageRoutes;
|
||||
@@ -1,30 +1,33 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||
|
||||
const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /social/feed?filter=trending|following|recent&cursor=&limit=20
|
||||
fastify.get<{ Querystring: { filter?: string; cursor?: string; limit?: string } }>('/social/feed', {
|
||||
preHandler: [requireAuth],
|
||||
preHandler: [optionalAuth],
|
||||
}, async (request) => {
|
||||
const filter = request.query.filter || 'trending';
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursorId = request.query.cursor;
|
||||
|
||||
// Base condition: only LIVE plans from public users
|
||||
// Base condition: LIVE + ENDED plans from public users
|
||||
const baseWhere: any = {
|
||||
status: 'LIVE',
|
||||
status: { in: ['LIVE', 'ENDED'] },
|
||||
user: { isPublic: true },
|
||||
};
|
||||
|
||||
// If following filter, restrict to followed users
|
||||
if (filter === 'following') {
|
||||
// If following filter, restrict to followed users (requires auth)
|
||||
if (filter === 'following' && request.userId) {
|
||||
const myFollowing = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId },
|
||||
select: { followingId: true },
|
||||
});
|
||||
const followingIds = myFollowing.map(f => f.followingId);
|
||||
baseWhere.userId = { in: followingIds };
|
||||
} else if (filter === 'following' && !request.userId) {
|
||||
// Not logged in — return empty following feed
|
||||
return { items: [], nextCursor: null } as FeedResponse;
|
||||
}
|
||||
|
||||
let orderBy: any;
|
||||
@@ -49,13 +52,33 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const hasMore = plans.length > limit;
|
||||
const items = plans.slice(0, limit);
|
||||
|
||||
// Check which plans the current user has liked
|
||||
// Check which plans the current user has liked (only if authenticated)
|
||||
const planIds = items.map(p => p.id);
|
||||
const myLikes = await fastify.prisma.like.findMany({
|
||||
where: { userId: request.userId, planId: { in: planIds } },
|
||||
select: { planId: true },
|
||||
});
|
||||
const likedSet = new Set(myLikes.map(l => l.planId));
|
||||
let likedSet = new Set<string>();
|
||||
if (request.userId) {
|
||||
const myLikes = await fastify.prisma.like.findMany({
|
||||
where: { userId: request.userId, planId: { in: planIds } },
|
||||
select: { planId: true },
|
||||
});
|
||||
likedSet = new Set(myLikes.map(l => l.planId));
|
||||
}
|
||||
|
||||
// Resolve Twitch channel names from linked accounts
|
||||
const twitchAccountIds = new Set<string>();
|
||||
for (const plan of items) {
|
||||
for (const d of plan.destinations) {
|
||||
if (d.serviceId === 'TWITCH' && d.linkedAccountId) {
|
||||
twitchAccountIds.add(d.linkedAccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const twitchAccounts = twitchAccountIds.size > 0
|
||||
? await fastify.prisma.linkedAccount.findMany({
|
||||
where: { id: { in: [...twitchAccountIds] } },
|
||||
select: { id: true, displayName: true },
|
||||
})
|
||||
: [];
|
||||
const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
|
||||
|
||||
const feedItems: FeedItemResponse[] = items.map(plan => ({
|
||||
plan: {
|
||||
@@ -77,7 +100,10 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
tags: d.tags,
|
||||
rtmpUrl: '',
|
||||
streamKey: '',
|
||||
broadcastId: d.broadcastId,
|
||||
// Twitch embed needs channel name, not numeric account ID
|
||||
broadcastId: d.serviceId === 'TWITCH'
|
||||
? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId)
|
||||
: d.broadcastId,
|
||||
status: d.status,
|
||||
})),
|
||||
user: plan.user,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface FacebookTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface FacebookProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}
|
||||
|
||||
export async function exchangeFacebookCode(code: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
client_secret: config.metaWeb.clientSecret,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
code,
|
||||
});
|
||||
|
||||
const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook token exchange failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookTokenResponse;
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in };
|
||||
}
|
||||
|
||||
export async function fetchFacebookProfile(accessToken: string): Promise<{
|
||||
facebookId: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/me?fields=id,name,picture.type(large)&access_token=${accessToken}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook profile fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookProfile;
|
||||
return {
|
||||
facebookId: data.id,
|
||||
displayName: data.name,
|
||||
email: data.email ?? null,
|
||||
avatarUrl: data.picture?.data?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFacebookOAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
state,
|
||||
scope: 'public_profile',
|
||||
response_type: 'code',
|
||||
});
|
||||
return `https://www.facebook.com/v19.0/dialog/oauth?${params}`;
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export interface UserProfileResponse {
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
isPublic: boolean;
|
||||
hasQuestLink: boolean;
|
||||
hasFacebookLink: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProfileBody {
|
||||
@@ -32,19 +30,13 @@ export interface UpdateProfileBody {
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface MetaWebCallbackBody {
|
||||
export interface PairingGenerateResponse {
|
||||
code: string;
|
||||
state: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface LinkQuestBody {
|
||||
metaId: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface LinkFacebookBody {
|
||||
export interface PairingRedeemBody {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// ── Providers ────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user