Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { cors } from "@elysiajs/cors";
import { auth } from "@megaforce/auth";
import { env } from "@megaforce/env/server";
import { Elysia } from "elysia";
import { requireAuth } from "./middleware/auth";
import { handleError } from "./middleware/error-handler";
import { requireWorkspace } from "./middleware/workspace";
import {
generateConnectionId,
handleClose,
Expand All @@ -22,6 +25,11 @@ const app = new Elysia()
credentials: true,
}),
)
// Global error handler
.onError((context) => {
return handleError(context.error as Error, context);
})
// Public routes
.all("/api/auth/*", async (context) => {
const { request, status } = context;
if (["POST", "GET"].includes(request.method)) {
Expand All @@ -30,6 +38,45 @@ const app = new Elysia()
return status(405);
})
.get("/", () => "OK")
// Protected routes - require authentication and workspace
.group(
"/api/protected",
(app) =>
app
.derive(async (context) => {
const user = await requireAuth(context);
if (user instanceof Response) {
return { user: null, workspace: null, error: user };
}
return { user };
})
.derive(async (context) => {
if (context.error) {
return context;
}
if (!context.user) {
return context;
}
const workspace = await requireWorkspace(context.user);
if (workspace instanceof Response) {
return { ...context, workspace: null, error: workspace };
}
return { ...context, workspace };
})
.onBeforeHandle((context) => {
if (context.error) {
return context.error;
}
})
// Example protected route
.get("/me", (context) => {
return {
user: context.user,
workspace: context.workspace,
};
}),
// Add more protected routes here as needed
)
.ws("/ws", {
open(ws) {
// Initialize connection data on open
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Authentication Middleware
* Validates user session and injects authenticated user into context
*/

import { auth } from "@megaforce/auth";
import type { Context } from "elysia";

export interface AuthenticatedUser {
id: string;
email: string;
name: string;
emailVerified: boolean;
}

/**
* Authenticate a request and extract user from session
* Returns null if authentication fails
*/
export async function authenticateRequest(
request: Request,
): Promise<AuthenticatedUser | null> {
try {
// Get session from request (better-auth will extract from cookies)
const session = await auth.api.getSession({
headers: request.headers,
});

if (!session?.user?.id) {
return null;
}

return {
id: session.user.id,
email: session.user.email,
name: session.user.name,
emailVerified: session.user.emailVerified,
};
} catch (error) {
console.error("Authentication error:", error);
return null;
}
}

/**
* Middleware to require authentication on routes
* Returns 401 if user is not authenticated
*/
export async function requireAuth(context: Context) {
const user = await authenticateRequest(context.request);

if (!user) {
return new Response(
JSON.stringify({
error: "Unauthorized",
message: "Authentication required",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
}

return user;
}
112 changes: 112 additions & 0 deletions apps/server/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Error Handler Middleware
* Global error handling for the Elysia application
*/

export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
timestamp: string;
path?: string;
}

/**
* Custom application error class
*/
export class AppError extends Error {
constructor(
message: string,
public statusCode = 500,
public code?: string,
) {
super(message);
this.name = "AppError";
}
}

/**
* Format error response
*/
function formatErrorResponse(
error: Error,
statusCode: number,
path?: string,
): ErrorResponse {
return {
error: error.name || "Error",
message: error.message || "An unexpected error occurred",
statusCode,
timestamp: new Date().toISOString(),
...(path && { path }),
};
}

/**
* Global error handler for Elysia
* Handles different error types and returns appropriate responses
*/
export function handleError(error: Error, context?: { request?: Request }) {
console.error("Error occurred:", {
name: error.name,
message: error.message,
stack: error.stack,
path: context?.request?.url,
});

// Handle custom application errors
if (error instanceof AppError) {
return new Response(
JSON.stringify(
formatErrorResponse(error, error.statusCode, context?.request?.url),
),
{
status: error.statusCode,
headers: { "Content-Type": "application/json" },
},
);
}

// Handle validation errors (Elysia/TypeBox)
if (error.name === "ValidationError") {
return new Response(
JSON.stringify(formatErrorResponse(error, 400, context?.request?.url)),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}

// Handle Prisma errors
if (error.name === "PrismaClientKnownRequestError") {
return new Response(
JSON.stringify(
formatErrorResponse(
new Error("Database error occurred"),
500,
context?.request?.url,
),
),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}

// Handle generic errors (500 Internal Server Error)
return new Response(
JSON.stringify(
formatErrorResponse(
new Error("Internal server error"),
500,
context?.request?.url,
),
),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
68 changes: 68 additions & 0 deletions apps/server/src/middleware/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Workspace Middleware
* Injects workspace context for authenticated users
*/

import prisma from "@megaforce/db";
import type { AuthenticatedUser } from "./auth";

export interface WorkspaceContext {
id: string;
name: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}

/**
* Get user's workspace (currently returns first workspace)
* In the future, this could be enhanced to:
* - Accept workspace ID from headers/query params
* - Support multi-workspace users
* - Cache workspace lookups
*/
export async function getUserWorkspace(
userId: string,
): Promise<WorkspaceContext | null> {
try {
const workspace = await prisma.workspace.findFirst({
where: { userId },
select: {
id: true,
name: true,
userId: true,
createdAt: true,
updatedAt: true,
},
});

return workspace;
} catch (error) {
console.error("Error fetching workspace:", error);
return null;
}
}

/**
* Middleware to inject workspace context
* Requires authentication middleware to run first
* Returns 404 if user has no workspace
*/
export async function requireWorkspace(user: AuthenticatedUser) {
const workspace = await getUserWorkspace(user.id);

if (!workspace) {
return new Response(
JSON.stringify({
error: "Not Found",
message: "No workspace found for user",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}

return workspace;
}