diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fcd04cd..a87e89a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app.ts b/src/app.ts index 1d713a2..02a548e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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); diff --git a/src/config.ts b/src/config.ts index 1abcc7c..4284bf0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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', '*'), diff --git a/src/middleware/require-auth.ts b/src/middleware/require-auth.ts index 6bc06fb..4e8e9e9 100644 --- a/src/middleware/require-auth.ts +++ b/src/middleware/require-auth.ts @@ -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 + } +} diff --git a/src/routes/auth/link.ts b/src/routes/auth/link.ts deleted file mode 100644 index 653d630..0000000 --- a/src/routes/auth/link.ts +++ /dev/null @@ -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; diff --git a/src/routes/auth/meta-web.ts b/src/routes/auth/meta-web.ts deleted file mode 100644 index 8ba09be..0000000 --- a/src/routes/auth/meta-web.ts +++ /dev/null @@ -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(); - -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; diff --git a/src/routes/auth/pairing.ts b/src/routes/auth/pairing.ts new file mode 100644 index 0000000..7821d9e --- /dev/null +++ b/src/routes/auth/pairing.ts @@ -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; diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts index e0a7abb..e8e5cc4 100644 --- a/src/routes/auth/session.ts +++ b/src/routes/auth/session.ts @@ -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); diff --git a/src/routes/pages.ts b/src/routes/pages.ts new file mode 100644 index 0000000..f84dd1f --- /dev/null +++ b/src/routes/pages.ts @@ -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 ` + + + + + ${title} + + + +
+ ${body} + +
+ +`; +} + +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', + `

LCK Control

+

Stream management platform for Meta Quest

+ +
+

What is LCK Control?

+

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:

+ +
+ +
+

Features

+ +
+ +
+ Open Portal +
` + )); + }); + + // Privacy policy + fastify.get('/privacy', async (_request, reply) => { + reply.header('content-type', 'text/html; charset=utf-8').send(layout('Privacy Policy — LCK Control', + `

Privacy Policy

+

Last updated: March 2, 2026

+ +
+

1. Information We Collect

+

When you use LCK Control, we may collect the following information:

+ +
+ +
+

2. How We Use Your Information

+ +
+ +
+

3. Data Sharing

+

We do not sell your personal information. We share data only with:

+ +
+ +
+

4. Data Storage & Security

+

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.

+
+ +
+

5. Your Rights

+

You can:

+ +
+ +
+

6. Google API Services Disclosure

+

LCK Control's use and transfer to any other app of information received from Google APIs will adhere to the Google API Services User Data Policy, including the Limited Use requirements.

+
+ +
+

7. Contact

+

If you have questions about this privacy policy, please contact us at omar@omigame.dev.

+
` + )); + }); + + // 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', + `

Terms of Service

+

Last updated: March 2, 2026

+ +
+

1. Acceptance of Terms

+

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.

+
+ +
+

2. Description of Service

+

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.

+
+ +
+

3. Account & Access

+ +
+ +
+

4. Linked Services

+

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.

+
+ +
+

5. User Conduct

+

You agree not to:

+ +
+ +
+

6. Content & Intellectual Property

+

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.

+
+ +
+

7. Disclaimer of Warranties

+

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.

+
+ +
+

8. Limitation of Liability

+

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.

+
+ +
+

9. Termination

+

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.

+
+ +
+

10. Changes to Terms

+

We may update these terms from time to time. Continued use of the Service after changes constitutes acceptance of the updated terms.

+
+ +
+

11. Contact

+

If you have questions about these terms, please contact us at omar@omigame.dev.

+
` + )); + }); +}; + +export default pageRoutes; diff --git a/src/routes/social/feed.ts b/src/routes/social/feed.ts index b3d83aa..82536c6 100644 --- a/src/routes/social/feed.ts +++ b/src/routes/social/feed.ts @@ -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(); + 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(); + 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, diff --git a/src/services/meta-web-auth.service.ts b/src/services/meta-web-auth.service.ts deleted file mode 100644 index c2faa4e..0000000 --- a/src/services/meta-web-auth.service.ts +++ /dev/null @@ -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}`; -} diff --git a/src/types/api.ts b/src/types/api.ts index 88e8e53..a3b9230 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 ────────────────────────────────────────────