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
19 changes: 19 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Required — the server refuses to boot without these.
JWT_SECRET=
# Shared secret the agent worker uses for service-to-service calls
# (/internal/session/*, and the /api/* routes it reads/writes as a system caller).
INTERNAL_SERVICE_TOKEN=

DATABASE_URL=postgresql://lovabledb:lovable@localhost:5432/lovablePostgres
REDIS_HOST=localhost
REDIS_PORT=6379

GOOGLE_CLIENT_ID=

# Comma-separated browser origins allowed to call the API.
CORS_ORIGINS=http://localhost:5173

# Basic-auth credentials for the BullMQ dashboard at /admin/queues.
# Leave empty to not mount the dashboard at all.
ADMIN_DASHBOARD_USER=
ADMIN_DASHBOARD_PASSWORD=
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"bullmq": "^5.80.9",
"cors": "^2.8.5",
"express-list-endpoints": "^7.1.1",
"google-auth-library": "^10.9.0",
"ioredis": "^5.11.1",
Expand Down
57 changes: 42 additions & 15 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,36 @@ import chatRouter from './modules/chat';
import designRouter from './modules/design';
import { questionRouter } from './modules/question';
import sessionRouter from './modules/sessions';
import expressListEndpoints from "express-list-endpoints";
import { assertAuthConfig } from './modules/middleware';
import { adminDashboardAuth, isAdminDashboardEnabled } from './modules/admin';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { runQueue } from './modules/worker';
import { logger } from './modules/utils';

assertAuthConfig();

const app = express();
app.use(cors())
app.use(express.json())
console.log("Starting server")

// Browsers may only call the API from origins we know about — CORS_ORIGINS is a
// comma-separated allowlist (dev default: the Vite dev server).
const allowedOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5173")
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);

app.use(cors({
// Disallowed origins get a normal response without CORS headers (the
// browser blocks it) rather than a 500 from a thrown error.
origin(origin, callback) {
callback(null, !origin || allowedOrigins.includes(origin));
},
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
}))
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT ?? "1mb" }))
logger.info("Starting server")
app.use("/api/project", projectRouter);
app.use("/api/run", runRouter);
app.use("/api/user", userRouter);
Expand All @@ -25,16 +45,23 @@ app.use("/api/design", designRouter)
app.use("/api/question", questionRouter)
app.use("/internal/session", sessionRouter)

const bullBoardAdapter = new ExpressAdapter();
bullBoardAdapter.setBasePath("/admin/queues");
createBullBoard({
queues: [new BullMQAdapter(runQueue)],
serverAdapter: bullBoardAdapter,
});
app.use("/admin/queues", bullBoardAdapter.getRouter());
// The queue dashboard exposes every job's payload (prompts, user ids), so it
// only mounts when credentials are configured, and always behind basic auth.
if (isAdminDashboardEnabled()) {
const bullBoardAdapter = new ExpressAdapter();
bullBoardAdapter.setBasePath("/admin/queues");
createBullBoard({
queues: [new BullMQAdapter(runQueue)],
serverAdapter: bullBoardAdapter,
});
app.use("/admin/queues", adminDashboardAuth, bullBoardAdapter.getRouter());
} else {
logger.warn("BullMQ dashboard disabled: set ADMIN_DASHBOARD_USER and ADMIN_DASHBOARD_PASSWORD to enable it");
}

console.table(expressListEndpoints(questionRouter));
app.listen(3000, () =>{
console.log("Server is running on port 3000")
console.log("BullMQ dashboard on http://localhost:3000/admin/queues")
})
logger.info("Server is running on port 3000")
if (isAdminDashboardEnabled()) {
logger.info("BullMQ dashboard on http://localhost:3000/admin/queues")
}
})
37 changes: 37 additions & 0 deletions apps/backend/src/modules/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Request, Response, NextFunction } from "express";
import { timingSafeEqual } from "node:crypto";

// The dashboard is browser-facing, so it uses HTTP basic auth (the browser can
// prompt for it) rather than the worker's bearer token.
export function isAdminDashboardEnabled(): boolean {
return Boolean(process.env.ADMIN_DASHBOARD_USER && process.env.ADMIN_DASHBOARD_PASSWORD);
}

function safeEquals(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
return aBuf.length === bBuf.length && timingSafeEqual(aBuf, bBuf);
}

export function adminDashboardAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
const expectedUser = process.env.ADMIN_DASHBOARD_USER;
const expectedPassword = process.env.ADMIN_DASHBOARD_PASSWORD;

if (!expectedUser || !expectedPassword) {
return res.status(404).end();
}

if (header?.startsWith("Basic ")) {
const [user, ...passwordParts] = Buffer.from(header.slice("Basic ".length), "base64")
.toString("utf8")
.split(":");
const password = passwordParts.join(":");
if (user && safeEquals(user, expectedUser) && safeEquals(password, expectedPassword)) {
return next();
}
}

res.setHeader("WWW-Authenticate", 'Basic realm="queues"');
return res.status(401).json({ message: "Unauthorized" });
}
85 changes: 85 additions & 0 deletions apps/backend/src/modules/authz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Response } from "express";
import { prisma } from "../prisma";
import type { AuthRequest } from "./middleware";

// Ownership checks live here rather than being repeated per route so every
// project/run-scoped handler enforces the same rule: internal (worker) callers
// act on behalf of the system and pass, everyone else must own the project.
//
// Both helpers write the error response and return false when access is denied,
// so callers only need `if (!(await authorizeProject(...))) return`.

export async function authorizeProject(
req: AuthRequest,
res: Response,
projectId: string,
): Promise<boolean> {
if (req.isInternal) {
return true;
}

const userId = req.user?.id;
if (!userId) {
res.status(401).json({ success: false, message: "Unauthorized" });
return false;
}

const project = await prisma.project.findUnique({
where: { id: projectId },
select: { userId: true },
});

if (!project) {
res.status(404).json({ success: false, message: "Project not found" });
return false;
}
if (project.userId !== userId) {
res.status(403).json({ success: false, message: "Forbidden" });
return false;
}

return true;
}

export async function authorizeRun(
req: AuthRequest,
res: Response,
runId: string,
): Promise<boolean> {
if (req.isInternal) {
return true;
}

const userId = req.user?.id;
if (!userId) {
res.status(401).json({ success: false, message: "Unauthorized" });
return false;
}

const run = await prisma.run.findUnique({
where: { id: runId },
select: { project: { select: { userId: true } } },
});

if (!run) {
res.status(404).json({ success: false, message: "Run not found" });
return false;
}
if (run.project.userId !== userId) {
res.status(403).json({ success: false, message: "Forbidden" });
return false;
}

return true;
}

// Routes that create resources for the caller need a real user, never the
// worker's shared secret.
export function requireUserId(req: AuthRequest, res: Response): string | null {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ success: false, message: "Unauthorized" });
return null;
}
return userId;
}
58 changes: 46 additions & 12 deletions apps/backend/src/modules/chat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { auth } from "./middleware";
import { auth, type AuthRequest } from "./middleware";
import { authorizeProject, authorizeRun, requireUserId } from "./authz";
import { randomUUIDv7 } from "bun";
import { prisma } from "../prisma";
import { redis } from "./redis";
Expand All @@ -26,14 +27,29 @@ GET /chat/:projectId/history → all past runs' events, for reload
chatRouter.post('/', auth, createRun)
chatRouter.post('/:projectId', auth, createRun)

async function createRun(req: Request, res: Response){
const userId = req.headers.userid
async function createRun(req: AuthRequest, res: Response){
// Identity comes from the verified JWT, not a client-supplied header.
const userId = requireUserId(req, res)
if(!userId){
return
}
let projectId = req.params?.projectId
const userPrompt = req.body.userPrompt
const userPrompt = req.body?.userPrompt
const existingSandboxId = req.body?.sandboxId

if(typeof userId !== 'string' || typeof userPrompt !== 'string'){
return res.status(400).json({success: false, message: `Invalid userid or userPrompt`})
if(typeof userPrompt !== 'string' || userPrompt.length === 0){
return res.status(400).json({success: false, message: `Invalid userPrompt`})
}
if(existingSandboxId !== undefined && existingSandboxId !== null && typeof existingSandboxId !== 'string'){
return res.status(400).json({success: false, message: `Invalid sandboxId`})
}
if(projectId !== undefined){
if(typeof projectId !== 'string'){
return res.status(400).json({success: false, message: `Invalid projectId`})
}
if(!(await authorizeProject(req, res, projectId))){
return
}
}
if(!projectId){
const project = await prisma.project.create({data: {
Expand Down Expand Up @@ -91,11 +107,14 @@ async function createRun(req: Request, res: Response){
// Lets the frontend reconstruct a run's UI state from just the runId in the
// URL (e.g. /w/:runId after a page refresh) — RunProvider's state otherwise
// only lives in memory for the current tab.
chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => {
chatRouter.get('/:runId/state', auth, async (req: AuthRequest, res: Response) => {
const { runId } = req.params
if(typeof runId !== 'string'){
return res.status(400).json({success: false, message: `Invalid runId type`})
}
if(!(await authorizeRun(req, res, runId))){
return
}

const run = await prisma.run.findUnique({where: {id: runId}})
if(!run){
Expand Down Expand Up @@ -143,18 +162,27 @@ chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => {
})
})

chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: Response) => {
const userId = req.headers.userid
chatRouter.post('/:projectId/:runId/continue', auth, async (req: AuthRequest, res: Response) => {
const userId = requireUserId(req, res)
if(!userId){
return
}
const { projectId, runId } = req.params
const answers: Answers[] = req.body?.answers ?? []
const selectedDesignId: string | undefined = req.body?.selectedDesignId

if(typeof userId !== 'string' || typeof projectId !== 'string' || typeof runId !== 'string'){
if(typeof projectId !== 'string' || typeof runId !== 'string'){
return res.status(400).json({success: false, message: `Invalid params`})
}
if(!Array.isArray(answers)){
return res.status(400).json({success: false, message: `answers must be an array (send [] if none)`})
}
if(selectedDesignId !== undefined && typeof selectedDesignId !== 'string'){
return res.status(400).json({success: false, message: `Invalid selectedDesignId`})
}
if(!(await authorizeProject(req, res, projectId))){
return
}

const run = await prisma.run.findFirst({where: {id: runId, projectId}})
if(!run){
Expand Down Expand Up @@ -196,11 +224,14 @@ chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: R
})

// SSE frontend --> Backend
chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{
chatRouter.get('/:runId/stream', auth, async (req: AuthRequest, res: Response) =>{
const {runId} = req.params
if(typeof runId !== 'string'){
return res.status(400).json({message: 'runId should be of string type'})
}
if(!(await authorizeRun(req, res, runId))){
return
}

// Validate + fetch before committing to SSE headers, so a bad/missing
// runId (e.g. a stale reconnect after the run was deleted) gets a clean
Expand Down Expand Up @@ -268,12 +299,15 @@ chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{

})

chatRouter.get('/:projectId/history', auth, async (req: Request, res: Response) =>{
chatRouter.get('/:projectId/history', auth, async (req: AuthRequest, res: Response) =>{

const {projectId} = req.params
if(typeof projectId !== 'string'){
return res.status(400).json({message: `Invalid projectId type`})
}
if(!(await authorizeProject(req, res, projectId))){
return
}
const runs = await prisma.run.findMany({
where: { projectId: projectId },
orderBy: { startedAt: 'desc' },
Expand Down
Loading