wip: working on account profile
This commit is contained in:
parent
305f91c6bd
commit
8158668190
@ -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,
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { GitHubProfile as GitHubProfileType, GitHubOrg as GitHubOrgType } from "./app/types";
|
||||
|
||||
declare global {
|
||||
namespace PrismaJson {
|
||||
type GitHubProfile = GitHubProfileType;
|
||||
type GitHubOrg = GitHubOrgType;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user