ref: move account, connect and session routes
This commit is contained in:
parent
270042d132
commit
acec634e49
@ -3,9 +3,7 @@ import { log, logger } from "@/utils/log";
|
||||
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { z } from "zod";
|
||||
import * as privacyKit from "privacy-kit";
|
||||
import { db } from "@/storage/db";
|
||||
import { Account, Prisma } from "@prisma/client";
|
||||
import { onShutdown } from "@/utils/shutdown";
|
||||
import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
@ -14,10 +12,8 @@ import { auth } from "@/app/auth/auth";
|
||||
import {
|
||||
EventRouter,
|
||||
ClientConnection,
|
||||
buildNewSessionUpdate,
|
||||
buildNewMessageUpdate,
|
||||
buildUpdateSessionUpdate,
|
||||
buildUpdateAccountUpdate,
|
||||
buildUpdateMachineUpdate,
|
||||
buildSessionActivityEphemeral,
|
||||
buildMachineActivityEphemeral,
|
||||
@ -33,14 +29,12 @@ import {
|
||||
httpRequestDurationHistogram
|
||||
} from "@/app/monitoring/metrics2";
|
||||
import { activityCache } from "@/app/presence/sessionCache";
|
||||
import { encryptBytes, encryptString } from "@/modules/encrypt";
|
||||
import { Fastify, GitHubProfile } from "./types";
|
||||
import { uploadImage } from "@/storage/uploadImage";
|
||||
import { separateName } from "@/utils/separateName";
|
||||
import { getPublicUrl } from "@/storage/files";
|
||||
import { Fastify } from "./types";
|
||||
import { registerAuthRoutes } from "./routes/authRoutes";
|
||||
import { registerPushRoutes } from "./routes/pushRoutes";
|
||||
import { registerSessionRoutes } from "./routes/sessionRoutes";
|
||||
import { registerConnectRoutes } from "./routes/connectRoutes";
|
||||
import { registerAccountRoutes } from "./routes/accountRoutes";
|
||||
|
||||
export async function startApi(eventRouter: EventRouter): Promise<{ app: FastifyInstance; io: Server }> {
|
||||
|
||||
@ -177,6 +171,12 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
||||
}
|
||||
});
|
||||
|
||||
// Catch-all route for debugging 404s
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
log({ module: '404-handler' }, `404 - Method: ${request.method}, Path: ${request.url}, Headers: ${JSON.stringify(request.headers)}`);
|
||||
reply.code(404).send({ error: 'Not found', path: request.url, method: request.method });
|
||||
});
|
||||
|
||||
// Error hook for additional logging
|
||||
app.addHook('onError', async (request, reply, error) => {
|
||||
const method = request.method;
|
||||
@ -245,666 +245,8 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
||||
registerAuthRoutes(typed);
|
||||
registerPushRoutes(typed);
|
||||
registerSessionRoutes(typed, eventRouter);
|
||||
|
||||
// GitHub OAuth parameters
|
||||
typed.get('/v1/connect/github/params', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
url: z.string()
|
||||
}),
|
||||
400: z.object({
|
||||
error: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||
const redirectUri = process.env.GITHUB_REDIRECT_URL;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return reply.code(400).send({ error: 'GitHub OAuth not configured' });
|
||||
}
|
||||
|
||||
// Generate ephemeral state token (5 minutes TTL)
|
||||
const state = await auth.createGithubToken(request.userId);
|
||||
|
||||
// Build complete OAuth URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user,user:email,read:org,codespace',
|
||||
state: state
|
||||
});
|
||||
|
||||
const url = `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||
|
||||
return reply.send({ url });
|
||||
});
|
||||
|
||||
// GitHub OAuth callback (GET for redirect from GitHub)
|
||||
typed.get('/v1/connect/github/callback', {
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
code: z.string(),
|
||||
state: z.string()
|
||||
})
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { code, state } = request.query;
|
||||
|
||||
// Verify the state token to get userId
|
||||
const tokenData = await auth.verifyGithubToken(state);
|
||||
if (!tokenData) {
|
||||
log({ module: 'github-oauth' }, `Invalid state token: ${state}`);
|
||||
return reply.redirect('https://app.happy.engineering?error=invalid_state');
|
||||
}
|
||||
|
||||
const userId = tokenData.userId;
|
||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return reply.redirect('https://app.happy.engineering?error=server_config');
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for access token
|
||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code
|
||||
})
|
||||
});
|
||||
|
||||
const tokenResponseData = await tokenResponse.json() as {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
if (tokenResponseData.error) {
|
||||
return reply.redirect(`https://app.happy.engineering?error=${encodeURIComponent(tokenResponseData.error)}`);
|
||||
}
|
||||
|
||||
const accessToken = tokenResponseData.access_token;
|
||||
|
||||
// Get user info from GitHub
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
});
|
||||
|
||||
const userData = await userResponse.json() as GitHubProfile;
|
||||
|
||||
if (!userResponse.ok) {
|
||||
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}`);
|
||||
|
||||
// Redirect to app with success
|
||||
return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`);
|
||||
|
||||
} catch (error) {
|
||||
log({ module: 'github-oauth' }, `Error in GitHub GET callback: ${error}`);
|
||||
return reply.redirect('https://app.happy.engineering?error=server_error');
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub webhook handler with type safety
|
||||
typed.post('/v1/connect/github/webhook', {
|
||||
schema: {
|
||||
headers: z.object({
|
||||
'x-hub-signature-256': z.string(),
|
||||
'x-github-event': z.string(),
|
||||
'x-github-delivery': z.string().optional()
|
||||
}).passthrough(),
|
||||
body: z.any(),
|
||||
response: {
|
||||
200: z.object({ received: z.boolean() }),
|
||||
401: z.object({ error: z.string() }),
|
||||
500: z.object({ error: z.string() })
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const signature = request.headers['x-hub-signature-256'];
|
||||
const eventName = request.headers['x-github-event'];
|
||||
const deliveryId = request.headers['x-github-delivery'];
|
||||
const rawBody = (request as any).rawBody;
|
||||
|
||||
if (!rawBody) {
|
||||
log({ module: 'github-webhook', level: 'error' },
|
||||
'Raw body not available for webhook signature verification');
|
||||
return reply.code(500).send({ error: 'Server configuration error' });
|
||||
}
|
||||
|
||||
// Get the webhooks handler
|
||||
const { getWebhooks } = await import("@/modules/github");
|
||||
const webhooks = getWebhooks();
|
||||
if (!webhooks) {
|
||||
log({ module: 'github-webhook', level: 'error' },
|
||||
'GitHub webhooks not initialized');
|
||||
return reply.code(500).send({ error: 'Webhooks not configured' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub disconnect endpoint
|
||||
typed.delete('/v1/connect/github', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.literal(true)
|
||||
}),
|
||||
404: z.object({
|
||||
error: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const userId = request.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}`);
|
||||
|
||||
return reply.send({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`);
|
||||
return reply.code(500).send({ error: 'Failed to disconnect GitHub account' });
|
||||
}
|
||||
});
|
||||
|
||||
typed.get('/v1/account/profile', {
|
||||
preHandler: app.authenticate,
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const user = await db.account.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
githubUser: true
|
||||
}
|
||||
});
|
||||
return reply.send({
|
||||
id: userId,
|
||||
timestamp: Date.now(),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null,
|
||||
github: user.githubUser ? user.githubUser.profile : null
|
||||
});
|
||||
});
|
||||
|
||||
// Get Account Settings API
|
||||
typed.get('/v1/account/settings', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
settings: z.string().nullable(),
|
||||
settingsVersion: z.number()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.literal('Failed to get account settings')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const user = await db.account.findUnique({
|
||||
where: { id: request.userId },
|
||||
select: { settings: true, settingsVersion: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
settings: user.settings,
|
||||
settingsVersion: user.settingsVersion
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update Account Settings API
|
||||
typed.post('/v1/account/settings', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
settings: z.string().nullable(),
|
||||
expectedVersion: z.number().int().min(0)
|
||||
}),
|
||||
response: {
|
||||
200: z.union([z.object({
|
||||
success: z.literal(true),
|
||||
version: z.number()
|
||||
}), z.object({
|
||||
success: z.literal(false),
|
||||
error: z.literal('version-mismatch'),
|
||||
currentVersion: z.number(),
|
||||
currentSettings: z.string().nullable()
|
||||
})]),
|
||||
500: z.object({
|
||||
success: z.literal(false),
|
||||
error: z.literal('Failed to update account settings')
|
||||
})
|
||||
}
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { settings, expectedVersion } = request.body;
|
||||
|
||||
try {
|
||||
// Get current user data for version check
|
||||
const currentUser = await db.account.findUnique({
|
||||
where: { id: userId },
|
||||
select: { settings: true, settingsVersion: true }
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to update account settings'
|
||||
});
|
||||
}
|
||||
|
||||
// Check current version
|
||||
if (currentUser.settingsVersion !== expectedVersion) {
|
||||
return reply.code(200).send({
|
||||
success: false,
|
||||
error: 'version-mismatch',
|
||||
currentVersion: currentUser.settingsVersion,
|
||||
currentSettings: currentUser.settings
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with version check
|
||||
const { count } = await db.account.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
settingsVersion: expectedVersion
|
||||
},
|
||||
data: {
|
||||
settings: settings,
|
||||
settingsVersion: expectedVersion + 1,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
// Re-fetch to get current version
|
||||
const account = await db.account.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
return reply.code(200).send({
|
||||
success: false,
|
||||
error: 'version-mismatch',
|
||||
currentVersion: account?.settingsVersion || 0,
|
||||
currentSettings: account?.settings || null
|
||||
});
|
||||
}
|
||||
|
||||
// Generate update for connected clients
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const settingsUpdate = {
|
||||
value: settings,
|
||||
version: expectedVersion + 1
|
||||
};
|
||||
|
||||
// Send account update to all user connections
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12));
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
version: expectedVersion + 1
|
||||
});
|
||||
} catch (error) {
|
||||
log({ module: 'api', level: 'error' }, `Failed to update account settings: ${error}`);
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to update account settings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Query Usage Reports API
|
||||
typed.post('/v1/usage/query', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
sessionId: z.string().nullish(),
|
||||
startTime: z.number().int().positive().nullish(),
|
||||
endTime: z.number().int().positive().nullish(),
|
||||
groupBy: z.enum(['hour', 'day']).nullish()
|
||||
})
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { sessionId, startTime, endTime, groupBy } = request.body;
|
||||
const actualGroupBy = groupBy || 'day';
|
||||
|
||||
try {
|
||||
// Build query conditions
|
||||
const where: {
|
||||
accountId: string;
|
||||
sessionId?: string | null;
|
||||
createdAt?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
} = {
|
||||
accountId: userId
|
||||
};
|
||||
|
||||
if (sessionId) {
|
||||
// Verify session belongs to user
|
||||
const session = await db.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
accountId: userId
|
||||
}
|
||||
});
|
||||
if (!session) {
|
||||
return reply.code(404).send({ error: 'Session not found' });
|
||||
}
|
||||
where.sessionId = sessionId;
|
||||
}
|
||||
|
||||
if (startTime || endTime) {
|
||||
where.createdAt = {};
|
||||
if (startTime) {
|
||||
where.createdAt.gte = new Date(startTime * 1000);
|
||||
}
|
||||
if (endTime) {
|
||||
where.createdAt.lte = new Date(endTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch usage reports
|
||||
const reports = await db.usageReport.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate data by time period
|
||||
const aggregated = new Map<string, {
|
||||
tokens: Record<string, number>;
|
||||
cost: Record<string, number>;
|
||||
count: number;
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
for (const report of reports) {
|
||||
const data = report.data as PrismaJson.UsageReportData;
|
||||
const date = new Date(report.createdAt);
|
||||
|
||||
// Calculate timestamp based on groupBy
|
||||
let timestamp: number;
|
||||
if (actualGroupBy === 'hour') {
|
||||
// Round down to hour
|
||||
const hourDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0);
|
||||
timestamp = Math.floor(hourDate.getTime() / 1000);
|
||||
} else {
|
||||
// Round down to day
|
||||
const dayDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
timestamp = Math.floor(dayDate.getTime() / 1000);
|
||||
}
|
||||
|
||||
const key = timestamp.toString();
|
||||
|
||||
if (!aggregated.has(key)) {
|
||||
aggregated.set(key, {
|
||||
tokens: {},
|
||||
cost: {},
|
||||
count: 0,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
const agg = aggregated.get(key)!;
|
||||
agg.count++;
|
||||
|
||||
// Aggregate tokens
|
||||
for (const [tokenKey, tokenValue] of Object.entries(data.tokens)) {
|
||||
if (typeof tokenValue === 'number') {
|
||||
agg.tokens[tokenKey] = (agg.tokens[tokenKey] || 0) + tokenValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate costs
|
||||
for (const [costKey, costValue] of Object.entries(data.cost)) {
|
||||
if (typeof costValue === 'number') {
|
||||
agg.cost[costKey] = (agg.cost[costKey] || 0) + costValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by timestamp
|
||||
const result = Array.from(aggregated.values())
|
||||
.map(data => ({
|
||||
timestamp: data.timestamp,
|
||||
tokens: data.tokens,
|
||||
cost: data.cost,
|
||||
reportCount: data.count
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return reply.send({
|
||||
usage: result,
|
||||
groupBy: actualGroupBy,
|
||||
totalReports: reports.length
|
||||
});
|
||||
} catch (error) {
|
||||
log({ module: 'api', level: 'error' }, `Failed to query usage reports: ${error}`);
|
||||
return reply.code(500).send({ error: 'Failed to query usage reports' });
|
||||
}
|
||||
});
|
||||
|
||||
// Messages API
|
||||
typed.get('/v1/sessions/:sessionId/messages', {
|
||||
schema: {
|
||||
params: z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { sessionId } = request.params;
|
||||
|
||||
// Verify session belongs to user
|
||||
const session = await db.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
accountId: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return reply.code(404).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const messages = await db.sessionMessage.findMany({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 150,
|
||||
select: {
|
||||
id: true,
|
||||
seq: true,
|
||||
localId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
messages: messages.map((v) => ({
|
||||
id: v.id,
|
||||
seq: v.seq,
|
||||
content: v.content,
|
||||
localId: v.localId,
|
||||
createdAt: v.createdAt.getTime(),
|
||||
updatedAt: v.updatedAt.getTime()
|
||||
}))
|
||||
});
|
||||
});
|
||||
registerAccountRoutes(typed, eventRouter);
|
||||
registerConnectRoutes(typed, eventRouter);
|
||||
|
||||
// Machines
|
||||
|
||||
@ -1107,12 +449,6 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
||||
});
|
||||
}
|
||||
|
||||
// Catch-all route for debugging 404s
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
log({ module: '404-handler' }, `404 - Method: ${request.method}, Path: ${request.url}, Headers: ${JSON.stringify(request.headers)}`);
|
||||
reply.code(404).send({ error: 'Not found', path: request.url, method: request.method });
|
||||
});
|
||||
|
||||
// Start
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
|
307
sources/app/api/routes/accountRoutes.ts
Normal file
307
sources/app/api/routes/accountRoutes.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import { EventRouter, buildUpdateAccountUpdate } from "@/modules/eventRouter";
|
||||
import { db } from "@/storage/db";
|
||||
import { Fastify } from "../types";
|
||||
import { getPublicUrl } from "@/storage/files";
|
||||
import { z } from "zod";
|
||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
import { log } from "@/utils/log";
|
||||
|
||||
export function registerAccountRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
app.get('/v1/account/profile', {
|
||||
preHandler: app.authenticate,
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const user = await db.account.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
githubUser: true
|
||||
}
|
||||
});
|
||||
return reply.send({
|
||||
id: userId,
|
||||
timestamp: Date.now(),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null,
|
||||
github: user.githubUser ? user.githubUser.profile : null
|
||||
});
|
||||
});
|
||||
|
||||
// Get Account Settings API
|
||||
app.get('/v1/account/settings', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
settings: z.string().nullable(),
|
||||
settingsVersion: z.number()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.literal('Failed to get account settings')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const user = await db.account.findUnique({
|
||||
where: { id: request.userId },
|
||||
select: { settings: true, settingsVersion: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
settings: user.settings,
|
||||
settingsVersion: user.settingsVersion
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update Account Settings API
|
||||
app.post('/v1/account/settings', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
settings: z.string().nullable(),
|
||||
expectedVersion: z.number().int().min(0)
|
||||
}),
|
||||
response: {
|
||||
200: z.union([z.object({
|
||||
success: z.literal(true),
|
||||
version: z.number()
|
||||
}), z.object({
|
||||
success: z.literal(false),
|
||||
error: z.literal('version-mismatch'),
|
||||
currentVersion: z.number(),
|
||||
currentSettings: z.string().nullable()
|
||||
})]),
|
||||
500: z.object({
|
||||
success: z.literal(false),
|
||||
error: z.literal('Failed to update account settings')
|
||||
})
|
||||
}
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { settings, expectedVersion } = request.body;
|
||||
|
||||
try {
|
||||
// Get current user data for version check
|
||||
const currentUser = await db.account.findUnique({
|
||||
where: { id: userId },
|
||||
select: { settings: true, settingsVersion: true }
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to update account settings'
|
||||
});
|
||||
}
|
||||
|
||||
// Check current version
|
||||
if (currentUser.settingsVersion !== expectedVersion) {
|
||||
return reply.code(200).send({
|
||||
success: false,
|
||||
error: 'version-mismatch',
|
||||
currentVersion: currentUser.settingsVersion,
|
||||
currentSettings: currentUser.settings
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with version check
|
||||
const { count } = await db.account.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
settingsVersion: expectedVersion
|
||||
},
|
||||
data: {
|
||||
settings: settings,
|
||||
settingsVersion: expectedVersion + 1,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
// Re-fetch to get current version
|
||||
const account = await db.account.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
return reply.code(200).send({
|
||||
success: false,
|
||||
error: 'version-mismatch',
|
||||
currentVersion: account?.settingsVersion || 0,
|
||||
currentSettings: account?.settings || null
|
||||
});
|
||||
}
|
||||
|
||||
// Generate update for connected clients
|
||||
const updSeq = await allocateUserSeq(userId);
|
||||
const settingsUpdate = {
|
||||
value: settings,
|
||||
version: expectedVersion + 1
|
||||
};
|
||||
|
||||
// Send account update to all user connections
|
||||
const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12));
|
||||
eventRouter.emitUpdate({
|
||||
userId,
|
||||
payload: updatePayload,
|
||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
version: expectedVersion + 1
|
||||
});
|
||||
} catch (error) {
|
||||
log({ module: 'api', level: 'error' }, `Failed to update account settings: ${error}`);
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to update account settings'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/usage/query', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
sessionId: z.string().nullish(),
|
||||
startTime: z.number().int().positive().nullish(),
|
||||
endTime: z.number().int().positive().nullish(),
|
||||
groupBy: z.enum(['hour', 'day']).nullish()
|
||||
})
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { sessionId, startTime, endTime, groupBy } = request.body;
|
||||
const actualGroupBy = groupBy || 'day';
|
||||
|
||||
try {
|
||||
// Build query conditions
|
||||
const where: {
|
||||
accountId: string;
|
||||
sessionId?: string | null;
|
||||
createdAt?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
} = {
|
||||
accountId: userId
|
||||
};
|
||||
|
||||
if (sessionId) {
|
||||
// Verify session belongs to user
|
||||
const session = await db.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
accountId: userId
|
||||
}
|
||||
});
|
||||
if (!session) {
|
||||
return reply.code(404).send({ error: 'Session not found' });
|
||||
}
|
||||
where.sessionId = sessionId;
|
||||
}
|
||||
|
||||
if (startTime || endTime) {
|
||||
where.createdAt = {};
|
||||
if (startTime) {
|
||||
where.createdAt.gte = new Date(startTime * 1000);
|
||||
}
|
||||
if (endTime) {
|
||||
where.createdAt.lte = new Date(endTime * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch usage reports
|
||||
const reports = await db.usageReport.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate data by time period
|
||||
const aggregated = new Map<string, {
|
||||
tokens: Record<string, number>;
|
||||
cost: Record<string, number>;
|
||||
count: number;
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
for (const report of reports) {
|
||||
const data = report.data as PrismaJson.UsageReportData;
|
||||
const date = new Date(report.createdAt);
|
||||
|
||||
// Calculate timestamp based on groupBy
|
||||
let timestamp: number;
|
||||
if (actualGroupBy === 'hour') {
|
||||
// Round down to hour
|
||||
const hourDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0);
|
||||
timestamp = Math.floor(hourDate.getTime() / 1000);
|
||||
} else {
|
||||
// Round down to day
|
||||
const dayDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
timestamp = Math.floor(dayDate.getTime() / 1000);
|
||||
}
|
||||
|
||||
const key = timestamp.toString();
|
||||
|
||||
if (!aggregated.has(key)) {
|
||||
aggregated.set(key, {
|
||||
tokens: {},
|
||||
cost: {},
|
||||
count: 0,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
const agg = aggregated.get(key)!;
|
||||
agg.count++;
|
||||
|
||||
// Aggregate tokens
|
||||
for (const [tokenKey, tokenValue] of Object.entries(data.tokens)) {
|
||||
if (typeof tokenValue === 'number') {
|
||||
agg.tokens[tokenKey] = (agg.tokens[tokenKey] || 0) + tokenValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate costs
|
||||
for (const [costKey, costValue] of Object.entries(data.cost)) {
|
||||
if (typeof costValue === 'number') {
|
||||
agg.cost[costKey] = (agg.cost[costKey] || 0) + costValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by timestamp
|
||||
const result = Array.from(aggregated.values())
|
||||
.map(data => ({
|
||||
timestamp: data.timestamp,
|
||||
tokens: data.tokens,
|
||||
cost: data.cost,
|
||||
reportCount: data.count
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return reply.send({
|
||||
usage: result,
|
||||
groupBy: actualGroupBy,
|
||||
totalReports: reports.length
|
||||
});
|
||||
} catch (error) {
|
||||
log({ module: 'api', level: 'error' }, `Failed to query usage reports: ${error}`);
|
||||
return reply.code(500).send({ error: 'Failed to query usage reports' });
|
||||
}
|
||||
});
|
||||
}
|
329
sources/app/api/routes/connectRoutes.ts
Normal file
329
sources/app/api/routes/connectRoutes.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import { z } from "zod";
|
||||
import { type Fastify } 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 "@/modules/eventRouter";
|
||||
import { GitHubProfile } from "../types";
|
||||
import { separateName } from "@/utils/separateName";
|
||||
import { uploadImage } from "@/storage/uploadImage";
|
||||
import { EventRouter } from "@/modules/eventRouter";
|
||||
import { encryptString } from "@/modules/encrypt";
|
||||
|
||||
export function registerConnectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
|
||||
// GitHub OAuth parameters
|
||||
app.get('/v1/connect/github/params', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
url: z.string()
|
||||
}),
|
||||
400: z.object({
|
||||
error: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||
const redirectUri = process.env.GITHUB_REDIRECT_URL;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return reply.code(400).send({ error: 'GitHub OAuth not configured' });
|
||||
}
|
||||
|
||||
// Generate ephemeral state token (5 minutes TTL)
|
||||
const state = await auth.createGithubToken(request.userId);
|
||||
|
||||
// Build complete OAuth URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user,user:email,read:org,codespace',
|
||||
state: state
|
||||
});
|
||||
|
||||
const url = `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||
|
||||
return reply.send({ url });
|
||||
});
|
||||
|
||||
// GitHub OAuth callback (GET for redirect from GitHub)
|
||||
app.get('/v1/connect/github/callback', {
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
code: z.string(),
|
||||
state: z.string()
|
||||
})
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { code, state } = request.query;
|
||||
|
||||
// Verify the state token to get userId
|
||||
const tokenData = await auth.verifyGithubToken(state);
|
||||
if (!tokenData) {
|
||||
log({ module: 'github-oauth' }, `Invalid state token: ${state}`);
|
||||
return reply.redirect('https://app.happy.engineering?error=invalid_state');
|
||||
}
|
||||
|
||||
const userId = tokenData.userId;
|
||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return reply.redirect('https://app.happy.engineering?error=server_config');
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for access token
|
||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code: code
|
||||
})
|
||||
});
|
||||
|
||||
const tokenResponseData = await tokenResponse.json() as {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
if (tokenResponseData.error) {
|
||||
return reply.redirect(`https://app.happy.engineering?error=${encodeURIComponent(tokenResponseData.error)}`);
|
||||
}
|
||||
|
||||
const accessToken = tokenResponseData.access_token;
|
||||
|
||||
// Get user info from GitHub
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
});
|
||||
|
||||
const userData = await userResponse.json() as GitHubProfile;
|
||||
|
||||
if (!userResponse.ok) {
|
||||
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}`);
|
||||
|
||||
// Redirect to app with success
|
||||
return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`);
|
||||
|
||||
} catch (error) {
|
||||
log({ module: 'github-oauth' }, `Error in GitHub GET callback: ${error}`);
|
||||
return reply.redirect('https://app.happy.engineering?error=server_error');
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub webhook handler with type safety
|
||||
app.post('/v1/connect/github/webhook', {
|
||||
schema: {
|
||||
headers: z.object({
|
||||
'x-hub-signature-256': z.string(),
|
||||
'x-github-event': z.string(),
|
||||
'x-github-delivery': z.string().optional()
|
||||
}).passthrough(),
|
||||
body: z.any(),
|
||||
response: {
|
||||
200: z.object({ received: z.boolean() }),
|
||||
401: z.object({ error: z.string() }),
|
||||
500: z.object({ error: z.string() })
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const signature = request.headers['x-hub-signature-256'];
|
||||
const eventName = request.headers['x-github-event'];
|
||||
const deliveryId = request.headers['x-github-delivery'];
|
||||
const rawBody = (request as any).rawBody;
|
||||
|
||||
if (!rawBody) {
|
||||
log({ module: 'github-webhook', level: 'error' },
|
||||
'Raw body not available for webhook signature verification');
|
||||
return reply.code(500).send({ error: 'Server configuration error' });
|
||||
}
|
||||
|
||||
// Get the webhooks handler
|
||||
const { getWebhooks } = await import("@/modules/github");
|
||||
const webhooks = getWebhooks();
|
||||
if (!webhooks) {
|
||||
log({ module: 'github-webhook', level: 'error' },
|
||||
'GitHub webhooks not initialized');
|
||||
return reply.code(500).send({ error: 'Webhooks not configured' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// GitHub disconnect endpoint
|
||||
app.delete('/v1/connect/github', {
|
||||
preHandler: app.authenticate,
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.literal(true)
|
||||
}),
|
||||
404: z.object({
|
||||
error: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
error: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const userId = request.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}`);
|
||||
|
||||
return reply.send({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`);
|
||||
return reply.code(500).send({ error: 'Failed to disconnect GitHub account' });
|
||||
}
|
||||
});
|
||||
}
|
@ -8,7 +8,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||
import { allocateUserSeq } from "@/storage/seq";
|
||||
|
||||
export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
|
||||
|
||||
// Sessions API
|
||||
app.get('/v1/sessions', {
|
||||
preHandler: app.authenticate,
|
||||
@ -293,4 +293,53 @@ export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/v1/sessions/:sessionId/messages', {
|
||||
schema: {
|
||||
params: z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
},
|
||||
preHandler: app.authenticate
|
||||
}, async (request, reply) => {
|
||||
const userId = request.userId;
|
||||
const { sessionId } = request.params;
|
||||
|
||||
// Verify session belongs to user
|
||||
const session = await db.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
accountId: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return reply.code(404).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const messages = await db.sessionMessage.findMany({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 150,
|
||||
select: {
|
||||
id: true,
|
||||
seq: true,
|
||||
localId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
messages: messages.map((v) => ({
|
||||
id: v.id,
|
||||
seq: v.seq,
|
||||
content: v.content,
|
||||
localId: v.localId,
|
||||
createdAt: v.createdAt.getTime(),
|
||||
updatedAt: v.updatedAt.getTime()
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user