import { createServer, type IncomingMessage, type ServerResponse, } from "node:http"; import { loadConfig } from "@nproxy/config"; import { BillingError, createPrismaAccountStore, createPrismaAuthStore, createPrismaBillingStore, createPrismaGenerationStore, prisma, } from "@nproxy/db"; import { createEmailTransport, createPaymentProviderAdapter } from "@nproxy/providers"; import { AuthError, GenerationRequestError, createGenerationRequest, type CreateGenerationRequestInput, } from "@nproxy/domain"; import { serializePublicAccountOverview } from "./account-response.js"; const config = loadConfig(); const port = Number.parseInt(process.env.PORT ?? "3000", 10); const accountStore = createPrismaAccountStore(prisma); const authStore = createPrismaAuthStore(prisma); const billingStore = createPrismaBillingStore(prisma); const generationStore = createPrismaGenerationStore(prisma); const emailTransport = createEmailTransport(config.email); const paymentProviderAdapter = createPaymentProviderAdapter(config.payment); const sessionCookieName = "nproxy_session"; const server = createServer(async (request, response) => { try { if (request.method === "GET" && request.url === "/healthz") { sendJson(response, 200, { ok: true, service: "web" }); return; } if (request.method === "POST" && request.url === "/api/auth/register") { const body = await readJsonBody(request); const payload = readAuthPayload(body); const session = await authStore.registerUser({ email: payload.email, password: payload.password, passwordPepper: config.auth.passwordPepper, }); sendJson( response, 201, { user: serializeAuthenticatedUser(session.user) }, createSessionCookie(session.token, session.expiresAt), ); return; } if (request.method === "POST" && request.url === "/api/auth/login") { const body = await readJsonBody(request); const payload = readAuthPayload(body); const session = await authStore.loginUser({ email: payload.email, password: payload.password, passwordPepper: config.auth.passwordPepper, }); sendJson( response, 200, { user: serializeAuthenticatedUser(session.user) }, createSessionCookie(session.token, session.expiresAt), ); return; } if (request.method === "POST" && request.url === "/api/auth/password-reset/request") { const body = await readJsonBody(request); const payload = readEmailOnlyPayload(body); const challenge = await authStore.createPasswordResetChallenge({ email: payload.email, }); if (challenge) { const resetUrl = new URL("/reset-password", config.urls.appBaseUrl); resetUrl.searchParams.set("token", challenge.token); await emailTransport.send({ to: challenge.email, subject: "Reset your nproxy password", text: [ "We received a request to reset your password.", `Reset link: ${resetUrl.toString()}`, `This link expires at ${challenge.expiresAt.toISOString()}.`, ].join("\n"), }); } sendJson(response, 200, { ok: true, }); return; } if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") { const body = await readJsonBody(request); const payload = readPasswordResetConfirmPayload(body); await authStore.resetPassword({ token: payload.token, newPassword: payload.password, passwordPepper: config.auth.passwordPepper, }); sendJson(response, 200, { ok: true, }); return; } if (request.method === "POST" && request.url === "/api/auth/logout") { const authenticatedSession = await requireAuthenticatedSession(request); await authStore.revokeSession(authenticatedSession.token); sendJson(response, 200, { ok: true }, clearSessionCookie()); return; } if (request.method === "GET" && request.url === "/api/auth/me") { const authenticatedSession = await requireAuthenticatedSession(request); sendJson(response, 200, { user: serializeAuthenticatedUser(authenticatedSession.user), session: serializeUserSession(authenticatedSession.session, authenticatedSession.session.id), }); return; } if (request.method === "GET" && request.url === "/api/auth/sessions") { const authenticatedSession = await requireAuthenticatedSession(request); const sessions = await authStore.listUserSessions(authenticatedSession.user.id); sendJson(response, 200, { sessions: sessions.map((session) => serializeUserSession(session, authenticatedSession.session.id), ), }); return; } if (request.method === "GET" && request.url === "/api/account") { const authenticatedSession = await requireAuthenticatedSession(request); const overview = await accountStore.getUserAccountOverview(authenticatedSession.user.id); if (!overview) { sendJson(response, 404, { error: { code: "account_not_found", message: "Account was not found.", }, }); return; } sendJson(response, 200, serializePublicAccountOverview(overview)); return; } if (request.method === "GET" && request.url === "/api/billing/invoices") { const authenticatedSession = await requireAuthenticatedSession(request); const invoices = await billingStore.listUserInvoices(authenticatedSession.user.id); sendJson(response, 200, { invoices: invoices.map(serializeBillingInvoice), }); return; } if (request.method === "POST" && request.url === "/api/billing/invoices") { const authenticatedSession = await requireAuthenticatedSession(request); const invoice = await billingStore.createSubscriptionInvoice({ userId: authenticatedSession.user.id, paymentProvider: config.payment.provider, paymentProviderAdapter, }); sendJson(response, 201, { invoice: serializeBillingInvoice(invoice), }); return; } if (request.method === "POST" && request.url === "/api/auth/logout-all") { const authenticatedSession = await requireAuthenticatedSession(request); const revokedCount = await authStore.revokeAllUserSessions({ userId: authenticatedSession.user.id, exceptSessionId: authenticatedSession.session.id, }); sendJson(response, 200, { ok: true, revokedCount, }); return; } if (request.method === "POST" && request.url) { const invoiceMarkPaidMatch = request.url.match(/^\/api\/admin\/invoices\/([^/]+)\/mark-paid$/); if (invoiceMarkPaidMatch) { const authenticatedSession = await requireAuthenticatedSession(request); if (!authenticatedSession.user.isAdmin) { throw new HttpError(403, "forbidden", "Admin session is required."); } const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? ""); const invoice = await billingStore.markInvoicePaid({ invoiceId, actor: { type: "web_admin", ref: authenticatedSession.user.id, }, }); sendJson(response, 200, { invoice: serializeBillingInvoice(invoice), }); return; } } if (request.method === "DELETE" && request.url) { const sessionMatch = request.url.match(/^\/api\/auth\/sessions\/([^/]+)$/); if (sessionMatch) { const authenticatedSession = await requireAuthenticatedSession(request); const sessionId = decodeURIComponent(sessionMatch[1] ?? ""); if (sessionId === authenticatedSession.session.id) { await authStore.revokeSession(authenticatedSession.token); sendJson(response, 200, { ok: true }, clearSessionCookie()); return; } const revoked = await authStore.revokeUserSession({ userId: authenticatedSession.user.id, sessionId, }); if (!revoked) { sendJson(response, 404, { error: { code: "session_not_found", message: "Session was not found.", }, }); return; } sendJson(response, 200, { ok: true }); return; } } if (request.method === "POST" && request.url === "/api/generations") { const authenticatedSession = await requireAuthenticatedSession(request); const body = await readJsonBody(request); const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id); const result = await createGenerationRequest(generationStore, requestInput); sendJson(response, result.reusedExistingRequest ? 200 : 201, { request: serializeGenerationRequest(result.request), reusedExistingRequest: result.reusedExistingRequest, approximateQuotaBucket: result.approximateQuotaBucket, }); return; } if (request.method === "GET" && request.url) { const match = request.url.match(/^\/api\/generations\/([^/]+)$/); if (match) { const authenticatedSession = await requireAuthenticatedSession(request); const requestId = decodeURIComponent(match[1] ?? ""); const generationRequest = await generationStore.getGenerationRequest(requestId); if (!generationRequest || generationRequest.userId !== authenticatedSession.user.id) { sendJson(response, 404, { error: { code: "not_found", message: "Generation request was not found.", }, }); return; } sendJson(response, 200, { request: serializeGenerationRequest(generationRequest), }); return; } } sendJson(response, 200, { service: "web", appBaseUrl: config.urls.appBaseUrl.toString(), adminBaseUrl: config.urls.adminBaseUrl.toString(), providerModel: config.provider.nanoBananaDefaultModel, endpoints: { register: "POST /api/auth/register", login: "POST /api/auth/login", passwordResetRequest: "POST /api/auth/password-reset/request", passwordResetConfirm: "POST /api/auth/password-reset/confirm", logout: "POST /api/auth/logout", me: "GET /api/auth/me", sessions: "GET /api/auth/sessions", revokeSession: "DELETE /api/auth/sessions/:id", logoutAll: "POST /api/auth/logout-all", account: "GET /api/account", listInvoices: "GET /api/billing/invoices", createInvoice: "POST /api/billing/invoices", adminMarkInvoicePaid: "POST /api/admin/invoices/:id/mark-paid", createGenerationRequest: "POST /api/generations", getGenerationRequest: "GET /api/generations/:id", }, }); } catch (error) { handleRequestError(response, error); } }); server.listen(port, "0.0.0.0", () => { console.log(`web listening on ${port}`); }); process.once("SIGTERM", async () => { await prisma.$disconnect(); process.exit(0); }); process.once("SIGINT", async () => { await prisma.$disconnect(); process.exit(0); }); async function readJsonBody( request: IncomingMessage, ): Promise { const chunks: Uint8Array[] = []; for await (const chunk of request) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } if (chunks.length === 0) { throw new HttpError(400, "invalid_json", "Request body must not be empty."); } const rawBody = Buffer.concat(chunks).toString("utf8"); try { return JSON.parse(rawBody) as unknown; } catch { throw new HttpError(400, "invalid_json", "Request body must be valid JSON."); } } function readAuthPayload(body: unknown): { email: string; password: string } { if (!body || typeof body !== "object") { throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); } const payload = body as Record; return { email: readRequiredString(payload.email, "email"), password: readRequiredString(payload.password, "password"), }; } function readEmailOnlyPayload(body: unknown): { email: string } { if (!body || typeof body !== "object") { throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); } const payload = body as Record; return { email: readRequiredString(payload.email, "email"), }; } function readPasswordResetConfirmPayload( body: unknown, ): { token: string; password: string } { if (!body || typeof body !== "object") { throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); } const payload = body as Record; return { token: readRequiredString(payload.token, "token"), password: readRequiredString(payload.password, "password"), }; } function mapCreateGenerationRequestInput( body: unknown, userId: string, ): CreateGenerationRequestInput { if (!body || typeof body !== "object") { throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); } const payload = body as Record; return { userId, mode: readGenerationMode(payload.mode), providerModel: readRequiredString(payload.providerModel, "providerModel"), prompt: readRequiredString(payload.prompt, "prompt"), resolutionPreset: readRequiredString(payload.resolutionPreset, "resolutionPreset"), batchSize: readRequiredInteger(payload.batchSize, "batchSize"), ...(payload.sourceImageKey !== undefined ? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") } : {}), ...(payload.imageStrength !== undefined ? { imageStrength: readRequiredNumber(payload.imageStrength, "imageStrength") } : {}), ...(payload.idempotencyKey !== undefined ? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") } : {}), }; } function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] { if (value === "text_to_image" || value === "image_to_image") { return value; } throw new HttpError( 400, "invalid_mode", 'mode must be "text_to_image" or "image_to_image".', ); } function readRequiredString(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`); } return value; } function readRequiredInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isInteger(value)) { throw new HttpError(400, "invalid_field", `${field} must be an integer.`); } return value; } function readRequiredNumber(value: unknown, field: string): number { if (typeof value !== "number" || Number.isNaN(value)) { throw new HttpError(400, "invalid_field", `${field} must be a number.`); } return value; } function serializeAuthenticatedUser(user: { id: string; email: string; isAdmin: boolean; createdAt: Date; }) { return { id: user.id, email: user.email, isAdmin: user.isAdmin, createdAt: user.createdAt.toISOString(), }; } function serializeUserSession( session: { id: string; expiresAt: Date; revokedAt?: Date; lastSeenAt?: Date; createdAt: Date; }, currentSessionId: string, ) { return { id: session.id, expiresAt: session.expiresAt.toISOString(), createdAt: session.createdAt.toISOString(), isCurrent: session.id === currentSessionId, ...(session.revokedAt ? { revokedAt: session.revokedAt.toISOString() } : {}), ...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt.toISOString() } : {}), }; } function serializeBillingInvoice(invoice: { id: string; subscriptionId?: string; provider: string; providerInvoiceId?: string; status: string; currency: string; amountCrypto: number; amountUsd?: number; paymentAddress?: string; expiresAt?: Date; paidAt?: Date; createdAt: Date; updatedAt: Date; }) { return { id: invoice.id, provider: invoice.provider, status: invoice.status, currency: invoice.currency, amountCrypto: invoice.amountCrypto, createdAt: invoice.createdAt.toISOString(), updatedAt: invoice.updatedAt.toISOString(), ...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}), ...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}), ...(invoice.amountUsd !== undefined ? { amountUsd: invoice.amountUsd } : {}), ...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}), ...(invoice.expiresAt ? { expiresAt: invoice.expiresAt.toISOString() } : {}), ...(invoice.paidAt ? { paidAt: invoice.paidAt.toISOString() } : {}), }; } function serializeGenerationRequest(requestRecord: { id: string; userId: string; mode: string; status: string; providerModel: string; prompt: string; sourceImageKey?: string; resolutionPreset: string; batchSize: number; imageStrength?: number; idempotencyKey?: string; terminalErrorCode?: string; terminalErrorText?: string; requestedAt: Date; startedAt?: Date; completedAt?: Date; createdAt: Date; updatedAt: Date; }) { return { id: requestRecord.id, userId: requestRecord.userId, mode: requestRecord.mode, status: requestRecord.status, providerModel: requestRecord.providerModel, prompt: requestRecord.prompt, resolutionPreset: requestRecord.resolutionPreset, batchSize: requestRecord.batchSize, requestedAt: requestRecord.requestedAt.toISOString(), createdAt: requestRecord.createdAt.toISOString(), updatedAt: requestRecord.updatedAt.toISOString(), ...(requestRecord.sourceImageKey !== undefined ? { sourceImageKey: requestRecord.sourceImageKey } : {}), ...(requestRecord.imageStrength !== undefined ? { imageStrength: requestRecord.imageStrength } : {}), ...(requestRecord.idempotencyKey !== undefined ? { idempotencyKey: requestRecord.idempotencyKey } : {}), ...(requestRecord.terminalErrorCode !== undefined ? { terminalErrorCode: requestRecord.terminalErrorCode } : {}), ...(requestRecord.terminalErrorText !== undefined ? { terminalErrorText: requestRecord.terminalErrorText } : {}), ...(requestRecord.startedAt !== undefined ? { startedAt: requestRecord.startedAt.toISOString() } : {}), ...(requestRecord.completedAt !== undefined ? { completedAt: requestRecord.completedAt.toISOString() } : {}), }; } async function requireAuthenticatedSession(request: IncomingMessage) { const sessionToken = readSessionToken(request); if (!sessionToken) { throw new HttpError(401, "unauthorized", "Missing authenticated session."); } const authenticatedSession = await authStore.getUserBySessionToken(sessionToken); if (!authenticatedSession) { throw new HttpError(401, "unauthorized", "Authenticated session is invalid."); } return { ...authenticatedSession, token: sessionToken, }; } function readSessionToken(request: IncomingMessage): string | null { const cookieHeader = request.headers.cookie; if (!cookieHeader) { return null; } for (const part of cookieHeader.split(";")) { const [rawName, ...rawValue] = part.trim().split("="); if (rawName === sessionCookieName) { return decodeURIComponent(rawValue.join("=")); } } return null; } function createSessionCookie(token: string, expiresAt: Date): string { return [ `${sessionCookieName}=${encodeURIComponent(token)}`, "Path=/", "HttpOnly", "SameSite=Lax", "Secure", `Expires=${expiresAt.toUTCString()}`, ].join("; "); } function clearSessionCookie(): string { return [ `${sessionCookieName}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Secure", "Expires=Thu, 01 Jan 1970 00:00:00 GMT", ].join("; "); } function handleRequestError( response: ServerResponse, error: unknown, ): void { if (error instanceof HttpError) { sendJson(response, error.statusCode, { error: { code: error.code, message: error.message, }, }); return; } if (error instanceof AuthError) { const statusCode = error.code === "email_already_exists" ? 409 : error.code === "invalid_credentials" ? 401 : 400; sendJson(response, statusCode, { error: { code: error.code, message: error.message, }, }); return; } if (error instanceof BillingError) { const statusCode = error.code === "invoice_not_found" ? 404 : error.code === "invoice_transition_not_allowed" ? 409 : 400; sendJson(response, statusCode, { error: { code: error.code, message: error.message, }, }); return; } if (error instanceof GenerationRequestError) { const statusCode = error.code === "missing_active_subscription" ? 403 : error.code === "quota_exhausted" ? 409 : error.code === "request_not_found" ? 404 : error.code === "request_not_completable" ? 409 : 400; sendJson(response, statusCode, { error: { code: error.code, message: error.message, }, }); return; } console.error(error); sendJson(response, 500, { error: { code: "internal_error", message: "Internal server error.", }, }); } function sendJson( response: ServerResponse, statusCode: number, payload: unknown, setCookie?: string, ): void { response.writeHead(statusCode, { "content-type": "application/json", ...(setCookie ? { "set-cookie": setCookie } : {}), }); response.end(JSON.stringify(payload)); } class HttpError extends Error { constructor( readonly statusCode: number, readonly code: string, message: string, ) { super(message); } }