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

This commit is contained in:
Steve Korshakov 2025-09-19 20:38:48 -07:00
parent 79b97c1b88
commit d032ec1596
30 changed files with 323 additions and 216 deletions

View File

@ -270,3 +270,10 @@ tail -500 .logs/*.log | grep "applySessions.*active" | tail -10
- **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
- 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

View File

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

View File

@ -33,6 +33,7 @@ model Account {
// Profile
firstName String?
lastName String?
username String? @unique
/// [ImageRef]
avatar Json?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, Socket>();
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}`);

View File

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

View File

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

View File

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

View File

@ -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<string, Socket>) {
export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<string, Socket>) {
// RPC register - Register this socket as a listener for an RPC method
socket.on('rpc-register', async (data: any) => {

View File

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

View File

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

View File

@ -183,7 +183,7 @@ export interface EphemeralPayload {
// === EVENT ROUTER CLASS ===
export class EventRouter {
class EventRouter {
private userConnections = new Map<string, Set<ClientConnection>>();
// === CONNECTION MANAGEMENT ===
@ -301,6 +301,8 @@ export class EventRouter {
}
}
export const eventRouter = new EventRouter();
// === EVENT BUILDER FUNCTIONS ===
export function buildNewSessionUpdate(session: {

View File

@ -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<void> {
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' }
});
}

View File

@ -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<void> {
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}`);
}

View File

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

View File

@ -23,7 +23,7 @@ export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile
where: { id: uid },
include: { githubUser: true }
});
if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) {
if (!currentUser || !targetUser) {
return null;
}

View File

@ -21,12 +21,10 @@ export async function friendList(ctx: Context): Promise<UserProfile[]> {
}
});
// 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;

View File

@ -17,7 +17,7 @@ export async function friendRemove(ctx: Context, uid: string): Promise<UserProfi
where: { id: uid },
include: { githubUser: true }
});
if (!currentUser || !currentUser.githubUser || !targetUser || !targetUser.githubUser) {
if (!currentUser || !targetUser) {
return null;
}

View File

@ -1,5 +1,5 @@
import { getPublicUrl, ImageRef } from "@/storage/files";
import { Prisma, RelationshipStatus } from "@prisma/client";
import { RelationshipStatus } from "@prisma/client";
import { GitHubProfile } from "../api/types";
export type UserProfile = {
@ -17,19 +17,12 @@ export type UserProfile = {
status: RelationshipStatus;
}
// Avatar type definition matching the database JSON structure
type AvatarData = {
path: string;
width?: number;
height?: number;
thumbhash?: string;
};
export function buildUserProfile(
account: {
id: string;
firstName: string | null;
lastName: string | null;
username: string | null;
avatar: ImageRef | null;
githubUser: { profile: GitHubProfile } | null;
},
@ -55,7 +48,7 @@ export function buildUserProfile(
firstName: account.firstName || '',
lastName: account.lastName,
avatar,
username: githubProfile?.login || '',
username: account.username || githubProfile?.login || '',
status
};
}

View File

@ -0,0 +1,34 @@
import { db } from "@/storage/db";
import { Context } from "@/context";
import { allocateUserSeq } from "@/storage/seq";
import { buildUpdateAccountUpdate, eventRouter } from "@/app/events/eventRouter";
import { randomKeyNaked } from "@/utils/randomKeyNaked";
export async function usernameUpdate(ctx: Context, username: string): Promise<void> {
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' }
});
}

View File

@ -1,3 +1,5 @@
import { Prisma, PrismaClient } from "@prisma/client";
export class Context {
static create(uid: string) {
@ -10,3 +12,5 @@ export class Context {
this.uid = uid;
}
}
export type Tx = Prisma.TransactionClient | PrismaClient;

View File

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

View File

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