wip: working on account profile

This commit is contained in:
Steve Korshakov 2025-08-26 20:39:55 -07:00
parent 305f91c6bd
commit 8158668190
5 changed files with 227 additions and 29 deletions

View File

@ -27,7 +27,8 @@ import {
buildSessionActivityEphemeral,
buildMachineActivityEphemeral,
buildUsageEphemeral,
buildMachineStatusEphemeral
buildMachineStatusEphemeral,
buildUpdateAccountGithubUpdate
} from "@/modules/eventRouter";
import {
incrementWebSocketConnection,
@ -78,21 +79,39 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
try {
// Test database connectivity
await db.$queryRaw`SELECT 1`;
reply.send({
status: 'ok',
reply.send({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'happy-server'
});
} catch (error) {
log({ module: 'health', level: 'error' }, `Health check failed: ${error}`);
reply.code(503).send({
status: 'error',
reply.code(503).send({
status: 'error',
timestamp: new Date().toISOString(),
service: 'happy-server',
error: 'Database connectivity failed'
});
}
});
// Add content type parser for webhook endpoints to preserve raw body
app.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
function (req, body, done) {
try {
const json = JSON.parse(body as string);
// Store raw body for webhook signature verification
(req as any).rawBody = body;
done(null, json);
} catch (err: any) {
err.statusCode = 400;
done(err, undefined);
}
}
);
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
const typed = app.withTypeProvider<ZodTypeProvider>();
@ -312,21 +331,21 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}
}, async (request, reply) => {
const { code, state } = request.query;
// Verify the state token to get userId
const tokenData = await auth.verifyGithubToken(state);
if (!tokenData) {
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', {
@ -341,19 +360,19 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
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: {
@ -361,13 +380,13 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
'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() },
@ -381,24 +400,109 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
}
});
// Link GitHub user to account
await db.account.update({
where: { id: userId },
data: { githubUserId: githubUser.id }
});
// Send account update to all user connections
const updSeq = await allocateUserSeq(userId);
const updatePayload = buildUpdateAccountGithubUpdate(userId, userData, 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' });
}
});
// Account auth request
typed.post('/v1/auth/account/request', {
schema: {
@ -782,6 +886,23 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}
});
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: {
githubUser: true
}
});
return reply.send({
id: userId,
timestamp: Date.now(),
github: user.githubUser ?? null,
});
});
// Get Account Settings API
typed.get('/v1/account/settings', {
preHandler: app.authenticate,

View File

@ -1,8 +0,0 @@
import { GitHubProfile as GitHubProfileType, GitHubOrg as GitHubOrgType } from "./app/types";
declare global {
namespace PrismaJson {
type GitHubProfile = GitHubProfileType;
type GitHubOrg = GitHubOrgType;
}
}

View File

@ -1,5 +1,6 @@
import { Socket } from "socket.io";
import { log } from "@/utils/log";
import { GitHubProfile } from "@/app/types";
// === CONNECTION TYPES ===
@ -75,6 +76,7 @@ export type UpdateEvent = {
value: string | null;
version: number;
} | null | undefined;
github?: GitHubProfile | null | undefined;
} | {
type: 'update-machine';
machineId: string;
@ -341,6 +343,19 @@ export function buildUpdateAccountUpdate(userId: string, settings: { value: stri
};
}
export function buildUpdateAccountGithubUpdate(userId: string, github: GitHubProfile | null, updateSeq: number, updateId: string): UpdatePayload {
return {
id: updateId,
seq: updateSeq,
body: {
t: 'update-account',
id: userId,
github
},
createdAt: Date.now()
};
}
export function buildUpdateMachineUpdate(machineId: string, updateSeq: number, updateId: string, metadata?: { value: string; version: number }, daemonState?: { value: string; version: number }): UpdatePayload {
return {
id: updateId,

View File

@ -1,6 +1,10 @@
import { App } from "octokit";
import { Webhooks } from "@octokit/webhooks";
import type { EmitterWebhookEvent } from "@octokit/webhooks";
import { log } from "@/utils/log";
let app: App | null = null;
let webhooks: Webhooks | null = null;
export async function initGithub() {
if (
@ -8,12 +12,73 @@ export async function initGithub() {
process.env.GITHUB_PRIVATE_KEY &&
process.env.GITHUB_CLIENT_ID &&
process.env.GITHUB_CLIENT_SECRET &&
process.env.GITHUB_REDIRECT_URL &&
process.env.GITHUB_REDIRECT_URI &&
process.env.GITHUB_WEBHOOK_SECRET
) {
app = new App({
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY,
webhooks: {
secret: process.env.GITHUB_WEBHOOK_SECRET
}
});
// Initialize standalone webhooks handler for type-safe event processing
webhooks = new Webhooks({
secret: process.env.GITHUB_WEBHOOK_SECRET
});
// Register type-safe event handlers
registerWebhookHandlers();
}
}
function registerWebhookHandlers() {
if (!webhooks) return;
// Type-safe handlers for specific events
webhooks.on("push", async ({ id, name, payload }: EmitterWebhookEvent<"push">) => {
log({ module: 'github-webhook', event: 'push' },
`Push to ${payload.repository.full_name} by ${payload.pusher.name}`);
});
webhooks.on("pull_request", async ({ id, name, payload }: EmitterWebhookEvent<"pull_request">) => {
log({ module: 'github-webhook', event: 'pull_request' },
`PR ${payload.action} on ${payload.repository.full_name}: #${payload.pull_request.number} - ${payload.pull_request.title}`);
});
webhooks.on("issues", async ({ id, name, payload }: EmitterWebhookEvent<"issues">) => {
log({ module: 'github-webhook', event: 'issues' },
`Issue ${payload.action} on ${payload.repository.full_name}: #${payload.issue.number} - ${payload.issue.title}`);
});
webhooks.on(["star.created", "star.deleted"], async ({ id, name, payload }: EmitterWebhookEvent<"star.created" | "star.deleted">) => {
const action = payload.action === 'created' ? 'starred' : 'unstarred';
log({ module: 'github-webhook', event: 'star' },
`Repository ${action}: ${payload.repository.full_name} by ${payload.sender.login}`);
});
webhooks.on("repository", async ({ id, name, payload }: EmitterWebhookEvent<"repository">) => {
log({ module: 'github-webhook', event: 'repository' },
`Repository ${payload.action}: ${payload.repository.full_name}`);
});
// Catch-all for unhandled events
webhooks.onAny(async ({ id, name, payload }: EmitterWebhookEvent) => {
log({ module: 'github-webhook', event: name as string },
`Received webhook event: ${name}`, { id });
});
webhooks.onError((error: any) => {
log({ module: 'github-webhook', level: 'error' },
`Webhook handler error: ${error.event?.name}`, error);
});
}
export function getWebhooks(): Webhooks | null {
return webhooks;
}
export function getApp(): App | null {
return app;
}

View File

@ -1,3 +1,4 @@
import { GitHubProfile as GitHubProfileType, GitHubOrg as GitHubOrgType } from "../app/types";
declare global {
namespace PrismaJson {
// Session message content types
@ -60,6 +61,7 @@ declare global {
value: string | null;
version: number;
} | null | undefined;
github?: GitHubProfileType | null | undefined;
} | {
t: 'update-machine';
machineId: string;
@ -73,6 +75,9 @@ declare global {
};
activeAt?: number;
};
type GitHubProfile = GitHubProfileType;
type GitHubOrg = GitHubOrgType;
}
}