ref: move account, connect and session routes

This commit is contained in:
Steve Korshakov 2025-09-01 14:22:50 -07:00
parent 270042d132
commit acec634e49
4 changed files with 697 additions and 676 deletions

View File

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

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

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

View File

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