From d032ec1596aa8367cde1ece3ccbde887cfc98234 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Fri, 19 Sep 2025 20:38:48 -0700 Subject: [PATCH] ref: extract github connect and disconnect to a separate action, shared eventRouter, copy username to the account on connect and remove on disconnect, make github profile unique --- CLAUDE.md | 9 +- .../20250920025406_add_username/migration.sql | 11 ++ prisma/schema.prisma | 1 + sources/app/api/api.ts | 17 +- sources/app/api/routes/accessKeysRoutes.ts | 3 +- sources/app/api/routes/accountRoutes.ts | 6 +- sources/app/api/routes/artifactsRoutes.ts | 4 +- sources/app/api/routes/connectRoutes.ts | 147 ++---------------- sources/app/api/routes/machinesRoutes.ts | 6 +- sources/app/api/routes/sessionRoutes.ts | 4 +- sources/app/api/routes/userRoutes.ts | 35 ++--- sources/app/api/socket.ts | 16 +- sources/app/api/socket/accessKeyHandler.ts | 4 +- .../app/api/socket/artifactUpdateHandler.ts | 4 +- .../app/api/socket/machineUpdateHandler.ts | 4 +- sources/app/api/socket/rpcHandler.ts | 4 +- .../app/api/socket/sessionUpdateHandler.ts | 4 +- sources/app/api/socket/usageHandler.ts | 4 +- sources/app/events/eventRouter.ts | 4 +- sources/app/github/githubConnect.ts | 108 +++++++++++++ sources/app/github/githubDisconnect.ts | 70 +++++++++ sources/app/presence/timeout.ts | 4 +- sources/app/social/friendAdd.ts | 2 +- sources/app/social/friendList.ts | 6 +- sources/app/social/friendRemove.ts | 2 +- sources/app/social/type.ts | 13 +- sources/app/social/usernameUpdate.ts | 34 ++++ sources/context.ts | 6 +- sources/main.ts | 6 +- sources/types.ts | 1 + 30 files changed, 323 insertions(+), 216 deletions(-) create mode 100644 prisma/migrations/20250920025406_add_username/migration.sql create mode 100644 sources/app/github/githubConnect.ts create mode 100644 sources/app/github/githubDisconnect.ts create mode 100644 sources/app/social/usernameUpdate.ts diff --git a/CLAUDE.md b/CLAUDE.md index ae36ef3..e311bc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,4 +269,11 @@ tail -500 .logs/*.log | grep "applySessions.*active" | tail -10 - **CLI logs**: `[HH:MM:SS.mmm]` in local time (e.g., `[13:45:23.738]`) - **Server logs**: Include both `time` (Unix ms) and `localTime` (HH:MM:ss.mmm) - **Mobile logs**: Sent with `timestamp` in UTC, converted to `localTime` on server -- **All consolidated logs**: Have `localTime` field for easy correlation \ No newline at end of file +- **All consolidated logs**: Have `localTime` field for easy correlation +- When writing a some operations on db, like adding friend, sending a notification - always create a dedicated file in relevant subfolder of the @sources/app/ folder. Good example is "friendAdd", always prefix with an entity type, then action that should be performed. +- Never create migrations yourself, it is can be done only by human +- Do not return stuff from action functions "just in case", only essential +- Do not add logging when not asked +- do not run non-transactional things (like uploadign files) in transactions +- After writing an action - add a documentation comment that explains logic, also keep it in sync. +- always use github usernames \ No newline at end of file diff --git a/prisma/migrations/20250920025406_add_username/migration.sql b/prisma/migrations/20250920025406_add_username/migration.sql new file mode 100644 index 0000000..8507d5a --- /dev/null +++ b/prisma/migrations/20250920025406_add_username/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `Account` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "username" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Account_username_key" ON "Account"("username"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f5c855..2fa2d07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,7 @@ model Account { // Profile firstName String? lastName String? + username String? @unique /// [ImageRef] avatar Json? diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 6db410b..b02943f 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -2,7 +2,6 @@ import fastify from "fastify"; import { log, logger } from "@/utils/log"; import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; import { onShutdown } from "@/utils/shutdown"; -import { EventRouter } from "@/app/events/eventRouter"; import { Fastify } from "./types"; import { authRoutes } from "./routes/authRoutes"; import { pushRoutes } from "./routes/pushRoutes"; @@ -21,7 +20,7 @@ import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; import { userRoutes } from "./routes/userRoutes"; -export async function startApi(eventRouter: EventRouter) { +export async function startApi() { // Configure log('Starting API...'); @@ -53,12 +52,12 @@ export async function startApi(eventRouter: EventRouter) { // Routes authRoutes(typed); pushRoutes(typed); - sessionRoutes(typed, eventRouter); - accountRoutes(typed, eventRouter); - connectRoutes(typed, eventRouter); - machinesRoutes(typed, eventRouter); - artifactsRoutes(typed, eventRouter); - accessKeysRoutes(typed, eventRouter); + sessionRoutes(typed); + accountRoutes(typed); + connectRoutes(typed); + machinesRoutes(typed); + artifactsRoutes(typed); + accessKeysRoutes(typed); devRoutes(typed); versionRoutes(typed); voiceRoutes(typed); @@ -72,7 +71,7 @@ export async function startApi(eventRouter: EventRouter) { }); // Start Socket - startSocket(typed, eventRouter); + startSocket(typed); // End log('API ready on port http://localhost:' + port); diff --git a/sources/app/api/routes/accessKeysRoutes.ts b/sources/app/api/routes/accessKeysRoutes.ts index 2afd732..27d5796 100644 --- a/sources/app/api/routes/accessKeysRoutes.ts +++ b/sources/app/api/routes/accessKeysRoutes.ts @@ -2,9 +2,8 @@ import { Fastify } from "../types"; import { z } from "zod"; import { db } from "@/storage/db"; import { log } from "@/utils/log"; -import { EventRouter } from "@/app/events/eventRouter"; -export function accessKeysRoutes(app: Fastify, eventRouter: EventRouter) { +export function accessKeysRoutes(app: Fastify) { // Get Access Key API app.get('/v1/access-keys/:sessionId/:machineId', { preHandler: app.authenticate, diff --git a/sources/app/api/routes/accountRoutes.ts b/sources/app/api/routes/accountRoutes.ts index 8c2b0b5..5231aad 100644 --- a/sources/app/api/routes/accountRoutes.ts +++ b/sources/app/api/routes/accountRoutes.ts @@ -1,4 +1,4 @@ -import { EventRouter, buildUpdateAccountUpdate } from "@/app/events/eventRouter"; +import { eventRouter, buildUpdateAccountUpdate } from "@/app/events/eventRouter"; import { db } from "@/storage/db"; import { Fastify } from "../types"; import { getPublicUrl } from "@/storage/files"; @@ -8,7 +8,7 @@ import { allocateUserSeq } from "@/storage/seq"; import { log } from "@/utils/log"; import { AccountProfile } from "@/types"; -export function accountRoutes(app: Fastify, eventRouter: EventRouter) { +export function accountRoutes(app: Fastify) { app.get('/v1/account/profile', { preHandler: app.authenticate, }, async (request, reply) => { @@ -18,6 +18,7 @@ export function accountRoutes(app: Fastify, eventRouter: EventRouter) { select: { firstName: true, lastName: true, + username: true, avatar: true, githubUser: true } @@ -28,6 +29,7 @@ export function accountRoutes(app: Fastify, eventRouter: EventRouter) { timestamp: Date.now(), firstName: user.firstName, lastName: user.lastName, + username: user.username, avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null, github: user.githubUser ? user.githubUser.profile : null, connectedServices: Array.from(connectedVendors) diff --git a/sources/app/api/routes/artifactsRoutes.ts b/sources/app/api/routes/artifactsRoutes.ts index f27e08e..78c7e65 100644 --- a/sources/app/api/routes/artifactsRoutes.ts +++ b/sources/app/api/routes/artifactsRoutes.ts @@ -1,4 +1,4 @@ -import { EventRouter, buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate } from "@/app/events/eventRouter"; +import { eventRouter, buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate } from "@/app/events/eventRouter"; import { db } from "@/storage/db"; import { Fastify } from "../types"; import { z } from "zod"; @@ -7,7 +7,7 @@ import { allocateUserSeq } from "@/storage/seq"; import { log } from "@/utils/log"; import * as privacyKit from "privacy-kit"; -export function artifactsRoutes(app: Fastify, eventRouter: EventRouter) { +export function artifactsRoutes(app: Fastify) { // GET /v1/artifacts - List all artifacts for the account app.get('/v1/artifacts', { preHandler: app.authenticate, diff --git a/sources/app/api/routes/connectRoutes.ts b/sources/app/api/routes/connectRoutes.ts index bfc546d..441f77d 100644 --- a/sources/app/api/routes/connectRoutes.ts +++ b/sources/app/api/routes/connectRoutes.ts @@ -1,19 +1,15 @@ import { z } from "zod"; -import { type Fastify } from "../types"; +import { type Fastify, GitHubProfile } from "../types"; import { auth } from "@/app/auth/auth"; import { log } from "@/utils/log"; -import { db } from "@/storage/db"; -import { Prisma } from "@prisma/client"; -import { allocateUserSeq } from "@/storage/seq"; -import { randomKeyNaked } from "@/utils/randomKeyNaked"; -import { buildUpdateAccountUpdate } from "@/app/events/eventRouter"; -import { GitHubProfile } from "../types"; -import { separateName } from "@/utils/separateName"; -import { uploadImage } from "@/storage/uploadImage"; -import { EventRouter } from "@/app/events/eventRouter"; +import { eventRouter } from "@/app/events/eventRouter"; import { decryptString, encryptString } from "@/modules/encrypt"; +import { githubConnect } from "@/app/github/githubConnect"; +import { githubDisconnect } from "@/app/github/githubDisconnect"; +import { Context } from "@/context"; +import { db } from "@/storage/db"; -export function connectRoutes(app: Fastify, eventRouter: EventRouter) { +export function connectRoutes(app: Fastify) { // Add content type parser for webhook endpoints to preserve raw body app.addContentTypeParser( @@ -155,53 +151,9 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) { return reply.redirect('https://app.happy.engineering?error=github_user_fetch_failed'); } - // Store GitHub user and connect to account - const githubUser = await db.githubUser.upsert({ - where: { id: userData.id.toString() }, - update: { - profile: userData, - token: encryptString(['user', userId, 'github', 'token'], accessToken!) - }, - create: { - id: userData.id.toString(), - profile: userData, - token: encryptString(['user', userId, 'github', 'token'], accessToken!) - } - }); - - // Avatar - log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`); - const image = await fetch(userData.avatar_url); - const imageBuffer = await image.arrayBuffer(); - log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`); - const avatar = await uploadImage(userId, 'avatars', 'github', userData.avatar_url, Buffer.from(imageBuffer)); - log({ module: 'github-oauth' }, `Uploaded avatar for user ${userId}: ${userData.avatar_url}`); - - // Name - const name = separateName(userData.name); - log({ module: 'github-oauth' }, `Separated name for user ${userId}: ${userData.name} -> ${name.firstName} ${name.lastName}`); - - // Link GitHub user to account - await db.account.update({ - where: { id: userId }, - data: { githubUserId: githubUser.id, avatar, firstName: name.firstName, lastName: name.lastName } - }); - - // Send account update to all user connections - const updSeq = await allocateUserSeq(userId); - const updatePayload = buildUpdateAccountUpdate(userId, { - github: userData, - firstName: name.firstName, - lastName: name.lastName, - avatar: avatar - }, updSeq, randomKeyNaked(12)); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-user-authenticated-connections' } - }); - - log({ module: 'github-oauth' }, `GitHub account connected successfully for user ${userId}: ${userData.login}`); + // Use the new githubConnect operation + const ctx = Context.create(userId); + await githubConnect(ctx, userData, accessToken!); // Redirect to app with success return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`); @@ -248,42 +200,16 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) { return reply.code(500).send({ error: 'Webhooks not configured' }); } + // Verify and handle the webhook with type safety try { - // Verify and handle the webhook with type safety await webhooks.verifyAndReceive({ id: deliveryId || 'unknown', name: eventName, payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body), signature: signature }); - - // Log successful processing - log({ - module: 'github-webhook', - event: eventName, - delivery: deliveryId - }, `Successfully processed ${eventName} webhook`); - return reply.send({ received: true }); - } catch (error: any) { - if (error.message?.includes('signature does not match')) { - log({ - module: 'github-webhook', - level: 'warn', - event: eventName, - delivery: deliveryId - }, 'Invalid webhook signature'); - return reply.code(401).send({ error: 'Invalid signature' }); - } - - log({ - module: 'github-webhook', - level: 'error', - event: eventName, - delivery: deliveryId - }, `Error processing webhook: ${error.message}`); - return reply.code(500).send({ error: 'Internal server error' }); } }); @@ -306,56 +232,11 @@ export function connectRoutes(app: Fastify, eventRouter: EventRouter) { } }, async (request, reply) => { const userId = request.userId; - + const ctx = Context.create(userId); try { - // Get current user's GitHub connection - const user = await db.account.findUnique({ - where: { id: userId }, - select: { githubUserId: true } - }); - - if (!user || !user.githubUserId) { - return reply.code(404).send({ error: 'GitHub account not connected' }); - } - - const githubUserId = user.githubUserId; - log({ module: 'github-disconnect' }, `Disconnecting GitHub account for user ${userId}: ${githubUserId}`); - - // Remove GitHub connection from account and delete GitHub user record - await db.$transaction(async (tx) => { - // Remove link from account and clear avatar - await tx.account.update({ - where: { id: userId }, - data: { - githubUserId: null, - avatar: Prisma.JsonNull - } - }); - - // Delete GitHub user record (this also deletes the token) - await tx.githubUser.delete({ - where: { id: githubUserId } - }); - }); - - // Send account update to all user connections - const updSeq = await allocateUserSeq(userId); - const updatePayload = buildUpdateAccountUpdate(userId, { - github: null, - avatar: null - }, updSeq, randomKeyNaked(12)); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-user-authenticated-connections' } - }); - - log({ module: 'github-disconnect' }, `GitHub account and avatar disconnected successfully for user ${userId}`); - + await githubDisconnect(ctx); return reply.send({ success: true }); - - } catch (error) { - log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`); + } catch (error: any) { return reply.code(500).send({ error: 'Failed to disconnect GitHub account' }); } }); diff --git a/sources/app/api/routes/machinesRoutes.ts b/sources/app/api/routes/machinesRoutes.ts index 08f11cb..7d9c38d 100644 --- a/sources/app/api/routes/machinesRoutes.ts +++ b/sources/app/api/routes/machinesRoutes.ts @@ -1,4 +1,4 @@ -import { EventRouter } from "@/app/events/eventRouter"; +import { eventRouter } from "@/app/events/eventRouter"; import { Fastify } from "../types"; import { z } from "zod"; import { db } from "@/storage/db"; @@ -7,7 +7,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; import { buildNewMachineUpdate, buildUpdateMachineUpdate } from "@/app/events/eventRouter"; -export function machinesRoutes(app: Fastify, eventRouter: EventRouter) { +export function machinesRoutes(app: Fastify) { app.post('/v1/machines', { preHandler: app.authenticate, schema: { @@ -59,7 +59,7 @@ export function machinesRoutes(app: Fastify, eventRouter: EventRouter) { metadataVersion: 1, daemonState: daemonState || null, daemonStateVersion: daemonState ? 1 : 0, - dataEncryptionKey: dataEncryptionKey ? Buffer.from(dataEncryptionKey, 'base64') : undefined, + dataEncryptionKey: dataEncryptionKey ? new Uint8Array(Buffer.from(dataEncryptionKey, 'base64')) : undefined, // Default to offline - in case the user does not start daemon active: false, // lastActiveAt and activeAt defaults to now() in schema diff --git a/sources/app/api/routes/sessionRoutes.ts b/sources/app/api/routes/sessionRoutes.ts index 3049e85..86f8652 100644 --- a/sources/app/api/routes/sessionRoutes.ts +++ b/sources/app/api/routes/sessionRoutes.ts @@ -1,4 +1,4 @@ -import { EventRouter, buildNewSessionUpdate } from "@/app/events/eventRouter"; +import { eventRouter, buildNewSessionUpdate } from "@/app/events/eventRouter"; import { type Fastify } from "../types"; import { db } from "@/storage/db"; import { z } from "zod"; @@ -7,7 +7,7 @@ import { log } from "@/utils/log"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; -export function sessionRoutes(app: Fastify, eventRouter: EventRouter) { +export function sessionRoutes(app: Fastify) { // Sessions API app.get('/v1/sessions', { diff --git a/sources/app/api/routes/userRoutes.ts b/sources/app/api/routes/userRoutes.ts index 6d1b7b8..ab0784d 100644 --- a/sources/app/api/routes/userRoutes.ts +++ b/sources/app/api/routes/userRoutes.ts @@ -39,7 +39,7 @@ export async function userRoutes(app: Fastify) { } }); - if (!user || !user.githubUser) { + if (!user) { return reply.code(404).send({ error: 'User not found' }); } @@ -65,7 +65,7 @@ export async function userRoutes(app: Fastify) { height: user.avatar.height, thumbhash: user.avatar.thumbhash } : null, - username: user.githubUser.profile.login, + username: user.username || (user.githubUser?.profile?.login || ''), status: status } }); @@ -90,22 +90,27 @@ export async function userRoutes(app: Fastify) { }, async (request, reply) => { const { query } = request.query; - // Search for user + // Search for user by username or GitHub login const user = await db.account.findFirst({ where: { - githubUser: { - profile: { - path: ['login'], - equals: query + OR: [ + { username: query }, + { + githubUser: { + profile: { + path: ['login'], + equals: query + } + } } - } + ] }, include: { githubUser: true } }); - if (!user || !user.githubUser) { + if (!user) { return reply.code(404).send({ error: 'User not found' }); } @@ -130,7 +135,7 @@ export async function userRoutes(app: Fastify) { height: user.avatar.height, thumbhash: user.avatar.thumbhash } : null, - username: user.githubUser.profile.login, + username: user.username || (user.githubUser?.profile?.login || ''), status: status } }); @@ -144,7 +149,7 @@ export async function userRoutes(app: Fastify) { }), response: { 200: z.object({ - user: UserProfileSchema + user: UserProfileSchema.nullable() }), 404: z.object({ error: z.literal('User not found') @@ -154,9 +159,6 @@ export async function userRoutes(app: Fastify) { preHandler: app.authenticate }, async (request, reply) => { const user = await friendAdd(Context.create(request.userId), request.body.uid); - if (!user) { - return reply.code(404).send({ error: 'User not found' }); - } return reply.send({ user }); }); @@ -167,7 +169,7 @@ export async function userRoutes(app: Fastify) { }), response: { 200: z.object({ - user: UserProfileSchema + user: UserProfileSchema.nullable() }), 404: z.object({ error: z.literal('User not found') @@ -177,9 +179,6 @@ export async function userRoutes(app: Fastify) { preHandler: app.authenticate }, async (request, reply) => { const user = await friendRemove(Context.create(request.userId), request.body.uid); - if (!user) { - return reply.code(404).send({ error: 'User not found' }); - } return reply.send({ user }); }); diff --git a/sources/app/api/socket.ts b/sources/app/api/socket.ts index b2f395b..f085960 100644 --- a/sources/app/api/socket.ts +++ b/sources/app/api/socket.ts @@ -1,6 +1,6 @@ import { onShutdown } from "@/utils/shutdown"; import { Fastify } from "./types"; -import { buildMachineActivityEphemeral, ClientConnection, EventRouter } from "@/app/events/eventRouter"; +import { buildMachineActivityEphemeral, ClientConnection, eventRouter } from "@/app/events/eventRouter"; import { Server, Socket } from "socket.io"; import { log } from "@/utils/log"; import { auth } from "@/app/auth/auth"; @@ -13,7 +13,7 @@ import { machineUpdateHandler } from "./socket/machineUpdateHandler"; import { artifactUpdateHandler } from "./socket/artifactUpdateHandler"; import { accessKeyHandler } from "./socket/accessKeyHandler"; -export function startSocket(app: Fastify, eventRouter: EventRouter) { +export function startSocket(app: Fastify) { const io = new Server(app.server, { cors: { origin: "*", @@ -137,13 +137,13 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) { userRpcListeners = new Map(); rpcListeners.set(userId, userRpcListeners); } - rpcHandler(userId, socket, eventRouter, userRpcListeners); - usageHandler(userId, socket, eventRouter); - sessionUpdateHandler(userId, socket, connection, eventRouter); + rpcHandler(userId, socket, userRpcListeners); + usageHandler(userId, socket); + sessionUpdateHandler(userId, socket, connection); pingHandler(socket); - machineUpdateHandler(userId, socket, eventRouter); - artifactUpdateHandler(userId, socket, eventRouter); - accessKeyHandler(userId, socket, eventRouter); + machineUpdateHandler(userId, socket); + artifactUpdateHandler(userId, socket); + accessKeyHandler(userId, socket); // Ready log({ module: 'websocket' }, `User connected: ${userId}`); diff --git a/sources/app/api/socket/accessKeyHandler.ts b/sources/app/api/socket/accessKeyHandler.ts index b8c3b52..e3c8690 100644 --- a/sources/app/api/socket/accessKeyHandler.ts +++ b/sources/app/api/socket/accessKeyHandler.ts @@ -1,9 +1,9 @@ import { Socket } from "socket.io"; import { db } from "@/storage/db"; import { log } from "@/utils/log"; -import { EventRouter } from "@/app/events/eventRouter"; +import { eventRouter } from "@/app/events/eventRouter"; -export function accessKeyHandler(userId: string, socket: Socket, eventRouter: EventRouter) { +export function accessKeyHandler(userId: string, socket: Socket) { // Get access key via socket socket.on('access-key-get', async (data: { sessionId: string; machineId: string }, callback: (response: any) => void) => { try { diff --git a/sources/app/api/socket/artifactUpdateHandler.ts b/sources/app/api/socket/artifactUpdateHandler.ts index 0897353..8a0fc2a 100644 --- a/sources/app/api/socket/artifactUpdateHandler.ts +++ b/sources/app/api/socket/artifactUpdateHandler.ts @@ -1,5 +1,5 @@ import { websocketEventsCounter } from "@/app/monitoring/metrics2"; -import { buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate, EventRouter } from "@/app/events/eventRouter"; +import { buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate, eventRouter } from "@/app/events/eventRouter"; import { db } from "@/storage/db"; import { allocateUserSeq } from "@/storage/seq"; import { log } from "@/utils/log"; @@ -7,7 +7,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { Socket } from "socket.io"; import * as privacyKit from "privacy-kit"; -export function artifactUpdateHandler(userId: string, socket: Socket, eventRouter: EventRouter) { +export function artifactUpdateHandler(userId: string, socket: Socket) { // Read artifact with full body socket.on('artifact-read', async (data: { artifactId: string; diff --git a/sources/app/api/socket/machineUpdateHandler.ts b/sources/app/api/socket/machineUpdateHandler.ts index 3d6aaa0..56cf996 100644 --- a/sources/app/api/socket/machineUpdateHandler.ts +++ b/sources/app/api/socket/machineUpdateHandler.ts @@ -1,13 +1,13 @@ import { machineAliveEventsCounter, websocketEventsCounter } from "@/app/monitoring/metrics2"; import { activityCache } from "@/app/presence/sessionCache"; -import { buildMachineActivityEphemeral, buildUpdateMachineUpdate, EventRouter } from "@/app/events/eventRouter"; +import { buildMachineActivityEphemeral, buildUpdateMachineUpdate, eventRouter } from "@/app/events/eventRouter"; import { log } from "@/utils/log"; import { db } from "@/storage/db"; import { Socket } from "socket.io"; import { allocateUserSeq } from "@/storage/seq"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; -export function machineUpdateHandler(userId: string, socket: Socket, eventRouter: EventRouter) { +export function machineUpdateHandler(userId: string, socket: Socket) { socket.on('machine-alive', async (data: { machineId: string; time: number; diff --git a/sources/app/api/socket/rpcHandler.ts b/sources/app/api/socket/rpcHandler.ts index 1f29674..4a7d740 100644 --- a/sources/app/api/socket/rpcHandler.ts +++ b/sources/app/api/socket/rpcHandler.ts @@ -1,8 +1,8 @@ -import { EventRouter } from "@/app/events/eventRouter"; +import { eventRouter } from "@/app/events/eventRouter"; import { log } from "@/utils/log"; import { Socket } from "socket.io"; -export function rpcHandler(userId: string, socket: Socket, eventRouter: EventRouter, rpcListeners: Map) { +export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map) { // RPC register - Register this socket as a listener for an RPC method socket.on('rpc-register', async (data: any) => { diff --git a/sources/app/api/socket/sessionUpdateHandler.ts b/sources/app/api/socket/sessionUpdateHandler.ts index 8f6c5fb..0cce977 100644 --- a/sources/app/api/socket/sessionUpdateHandler.ts +++ b/sources/app/api/socket/sessionUpdateHandler.ts @@ -1,6 +1,6 @@ import { sessionAliveEventsCounter, websocketEventsCounter } from "@/app/monitoring/metrics2"; import { activityCache } from "@/app/presence/sessionCache"; -import { buildNewMessageUpdate, buildSessionActivityEphemeral, buildUpdateSessionUpdate, ClientConnection, EventRouter } from "@/app/events/eventRouter"; +import { buildNewMessageUpdate, buildSessionActivityEphemeral, buildUpdateSessionUpdate, ClientConnection, eventRouter } from "@/app/events/eventRouter"; import { db } from "@/storage/db"; import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq"; import { AsyncLock } from "@/utils/lock"; @@ -8,7 +8,7 @@ import { log } from "@/utils/log"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { Socket } from "socket.io"; -export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection, eventRouter: EventRouter) { +export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection) { socket.on('update-metadata', async (data: any, callback: (response: any) => void) => { try { const { sid, metadata, expectedVersion } = data; diff --git a/sources/app/api/socket/usageHandler.ts b/sources/app/api/socket/usageHandler.ts index 2d59f7c..7eada5e 100644 --- a/sources/app/api/socket/usageHandler.ts +++ b/sources/app/api/socket/usageHandler.ts @@ -1,10 +1,10 @@ import { Socket } from "socket.io"; import { AsyncLock } from "@/utils/lock"; import { db } from "@/storage/db"; -import { buildUsageEphemeral, EventRouter } from "@/app/events/eventRouter"; +import { buildUsageEphemeral, eventRouter } from "@/app/events/eventRouter"; import { log } from "@/utils/log"; -export function usageHandler(userId: string, socket: Socket, eventRouter: EventRouter) { +export function usageHandler(userId: string, socket: Socket) { const receiveUsageLock = new AsyncLock(); socket.on('usage-report', async (data: any, callback?: (response: any) => void) => { await receiveUsageLock.inLock(async () => { diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index cf8ad4f..2dc6f67 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -183,7 +183,7 @@ export interface EphemeralPayload { // === EVENT ROUTER CLASS === -export class EventRouter { +class EventRouter { private userConnections = new Map>(); // === CONNECTION MANAGEMENT === @@ -301,6 +301,8 @@ export class EventRouter { } } +export const eventRouter = new EventRouter(); + // === EVENT BUILDER FUNCTIONS === export function buildNewSessionUpdate(session: { diff --git a/sources/app/github/githubConnect.ts b/sources/app/github/githubConnect.ts new file mode 100644 index 0000000..ccff3c6 --- /dev/null +++ b/sources/app/github/githubConnect.ts @@ -0,0 +1,108 @@ +import { db } from "@/storage/db"; +import { Context } from "@/context"; +import { encryptString } from "@/modules/encrypt"; +import { uploadImage } from "@/storage/uploadImage"; +import { separateName } from "@/utils/separateName"; +import { GitHubProfile } from "@/app/api/types"; +import { allocateUserSeq } from "@/storage/seq"; +import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { githubDisconnect } from "./githubDisconnect"; + +/** + * Connects a GitHub account to a user profile. + * + * Flow: + * 1. Check if already connected to same account - early exit if yes + * 2. If GitHub account is connected to another user - disconnect it first + * 3. Upload avatar to S3 (non-transactional operation) + * 4. In transaction: persist GitHub account and link to user with GitHub username + * 5. Send socket update after transaction completes + * + * @param ctx - Request context containing user ID + * @param githubProfile - GitHub profile data from OAuth + * @param accessToken - GitHub access token for API access + */ +export async function githubConnect( + ctx: Context, + githubProfile: GitHubProfile, + accessToken: string +): Promise { + const userId = ctx.uid; + const githubUserId = githubProfile.id.toString(); + + // Step 1: Check if user is already connected to this exact GitHub account + const currentUser = await db.account.findFirstOrThrow({ + where: { id: userId }, + select: { githubUserId: true, username: true } + }); + if (currentUser.githubUserId === githubUserId) { + return; + } + + // Step 2: Check if GitHub account is connected to another user + const existingConnection = await db.account.findFirst({ + where: { + githubUserId: githubUserId, + NOT: { id: userId } + } + }); + if (existingConnection) { + const disconnectCtx: Context = Context.create(existingConnection.id); + await githubDisconnect(disconnectCtx); + } + + // Step 3: Upload avatar to S3 (outside transaction for performance) + const imageResponse = await fetch(githubProfile.avatar_url); + const imageBuffer = await imageResponse.arrayBuffer(); + const avatar = await uploadImage(userId, 'avatars', 'github', githubProfile.avatar_url, Buffer.from(imageBuffer)); + + // Extract name from GitHub profile + const name = separateName(githubProfile.name); + + // Step 4: Start transaction for atomic database operations + await db.$transaction(async (tx) => { + + // Upsert GitHub user record with encrypted token + await tx.githubUser.upsert({ + where: { id: githubUserId }, + update: { + profile: githubProfile, + token: encryptString(['user', userId, 'github', 'token'], accessToken) + }, + create: { + id: githubUserId, + profile: githubProfile, + token: encryptString(['user', userId, 'github', 'token'], accessToken) + } + }); + + // Link GitHub account to user + await tx.account.update({ + where: { id: userId }, + data: { + githubUserId: githubUserId, + username: githubProfile.login, + firstName: name.firstName, + lastName: name.lastName, + avatar: avatar + } + }); + }); + + // Step 5: Send update via socket (after transaction completes) + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateAccountUpdate(userId, { + github: githubProfile, + username: githubProfile.login, + firstName: name.firstName, + lastName: name.lastName, + avatar: avatar + }, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); +} \ No newline at end of file diff --git a/sources/app/github/githubDisconnect.ts b/sources/app/github/githubDisconnect.ts new file mode 100644 index 0000000..c42daa5 --- /dev/null +++ b/sources/app/github/githubDisconnect.ts @@ -0,0 +1,70 @@ +import { db } from "@/storage/db"; +import { Context } from "@/context"; +import { log } from "@/utils/log"; +import { allocateUserSeq } from "@/storage/seq"; +import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { Prisma } from "@prisma/client"; + +/** + * Disconnects a GitHub account from a user profile. + * + * Flow: + * 1. Check if user has GitHub connected - early exit if not + * 2. In transaction: clear GitHub link, username, avatar from account and delete GitHub user record + * 3. Send socket update after transaction completes + * + * @param ctx - Request context containing user ID + */ +export async function githubDisconnect(ctx: Context): Promise { + const userId = ctx.uid; + + // Step 1: Check if user has GitHub connection + const user = await db.account.findUnique({ + where: { id: userId }, + select: { githubUserId: true } + }); + + // Early exit if no GitHub connection + if (!user?.githubUserId) { + log({ module: 'github-disconnect' }, `User ${userId} has no GitHub account connected`); + return; + } + + const githubUserId = user.githubUserId; + log({ module: 'github-disconnect' }, `Disconnecting GitHub account ${githubUserId} from user ${userId}`); + + // Step 2: Transaction for atomic database operations + await db.$transaction(async (tx) => { + // Clear GitHub connection, username, and avatar from account + await tx.account.update({ + where: { id: userId }, + data: { + githubUserId: null, + username: null, + avatar: Prisma.JsonNull + } + }); + + // Delete GitHub user record (includes token) + await tx.githubUser.delete({ + where: { id: githubUserId } + }); + }); + + // Step 3: Send update via socket (after transaction completes) + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateAccountUpdate(userId, { + github: null, + username: null, + avatar: null + }, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + log({ module: 'github-disconnect' }, `GitHub account ${githubUserId} disconnected successfully from user ${userId}`); +} \ No newline at end of file diff --git a/sources/app/presence/timeout.ts b/sources/app/presence/timeout.ts index 4747cbc..529c5da 100644 --- a/sources/app/presence/timeout.ts +++ b/sources/app/presence/timeout.ts @@ -2,9 +2,9 @@ import { db } from "@/storage/db"; import { delay } from "@/utils/delay"; import { forever } from "@/utils/forever"; import { shutdownSignal } from "@/utils/shutdown"; -import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, EventRouter } from "@/app/events/eventRouter"; +import { buildMachineActivityEphemeral, buildSessionActivityEphemeral, eventRouter } from "@/app/events/eventRouter"; -export function startTimeout(eventRouter: EventRouter) { +export function startTimeout() { forever('session-timeout', async () => { while (true) { // Find timed out sessions diff --git a/sources/app/social/friendAdd.ts b/sources/app/social/friendAdd.ts index c3841df..055e258 100644 --- a/sources/app/social/friendAdd.ts +++ b/sources/app/social/friendAdd.ts @@ -23,7 +23,7 @@ export async function friendAdd(ctx: Context, uid: string): Promise { } }); - // Filter out users without GitHub profiles and build UserProfile objects + // Build UserProfile objects const profiles: UserProfile[] = []; for (const relationship of relationships) { - if (relationship.toUser.githubUser) { - profiles.push(buildUserProfile(relationship.toUser, relationship.status)); - } + profiles.push(buildUserProfile(relationship.toUser, relationship.status)); } return profiles; diff --git a/sources/app/social/friendRemove.ts b/sources/app/social/friendRemove.ts index b83182c..afb819a 100644 --- a/sources/app/social/friendRemove.ts +++ b/sources/app/social/friendRemove.ts @@ -17,7 +17,7 @@ export async function friendRemove(ctx: Context, uid: string): Promise { + const userId = ctx.uid; + + // Check if username is already taken + const existingUser = await db.account.findFirst({ + where: { + username: username, + NOT: { id: userId } + } + }); + if (existingUser) { // Should never happen + throw new Error('Username is already taken'); + } + + // Update username + await db.account.update({ + where: { id: userId }, + data: { username: username } + }); + + // Send account update to all user connections + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateAccountUpdate(userId, { username: username }, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); +} \ No newline at end of file diff --git a/sources/context.ts b/sources/context.ts index ad84787..576b942 100644 --- a/sources/context.ts +++ b/sources/context.ts @@ -1,3 +1,5 @@ +import { Prisma, PrismaClient } from "@prisma/client"; + export class Context { static create(uid: string) { @@ -9,4 +11,6 @@ export class Context { private constructor(uid: string) { this.uid = uid; } -} \ No newline at end of file +} + +export type Tx = Prisma.TransactionClient | PrismaClient; \ No newline at end of file diff --git a/sources/main.ts b/sources/main.ts index 73e5118..31315a7 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -11,7 +11,6 @@ import { startDatabaseMetricsUpdater } from "@/app/monitoring/metrics2"; import { initEncrypt } from "./modules/encrypt"; import { initGithub } from "./modules/github"; import { loadFiles } from "./storage/files"; -import { EventRouter } from "./app/events/eventRouter"; async function main() { @@ -26,7 +25,6 @@ async function main() { await redis.ping(); // Initialize auth module - const eventRouter = new EventRouter(); await initEncrypt(); await initGithub(); await loadFiles(); @@ -36,10 +34,10 @@ async function main() { // Start // - await startApi(eventRouter); + await startApi(); await startMetricsServer(); startDatabaseMetricsUpdater(); - startTimeout(eventRouter); + startTimeout(); // // Ready diff --git a/sources/types.ts b/sources/types.ts index 9cd0a48..eb49dd6 100644 --- a/sources/types.ts +++ b/sources/types.ts @@ -4,6 +4,7 @@ import { ImageRef } from "./storage/files"; export type AccountProfile = { firstName: string | null; lastName: string | null; + username: string | null; avatar: ImageRef | null; github: GitHubProfile | null; settings: {