From dc5bbdc47dde03c1f77ffa8dd5d10374cd1b6498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 2 Apr 2026 14:19:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20centralize=20app=20base=20URL=20?= =?UTF-8?q?=E2=80=94=20no=20localhost=20fallback=20in=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce getAppBaseUrl() in packages/api/src/lib/app-base-url.ts: - Reads NEXTAUTH_URL (trimmed, trailing slash stripped) - production: throws if NEXTAUTH_URL is missing/empty so broken localhost links in emails are caught at runtime, not silently sent - development/test: falls back to http://localhost:3100 with a one-time console.warn Replace the duplicated inline fallback in: - packages/api/src/router/invite.ts (invite email link) - packages/api/src/router/auth.ts (password reset email link) Extend GET /api/health to report: "baseUrl": { "configured": bool, "isLocalhost": bool } so deployment checks can detect a misconfigured NEXTAUTH_URL. Co-Authored-By: claude-flow --- apps/web/src/app/api/health/route.ts | 52 +++++++++- packages/api/src/lib/app-base-url.ts | 38 ++++++++ packages/api/src/router/auth.ts | 4 +- packages/api/src/router/invite.ts | 139 +++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/lib/app-base-url.ts create mode 100644 packages/api/src/router/invite.ts diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index d85242b..4032963 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,11 +1,55 @@ import { NextResponse } from "next/server"; +import { prisma } from "@capakraken/db"; +import { createConnection } from "net"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -export function GET() { - return NextResponse.json({ - status: "ok", - timestamp: new Date().toISOString(), +const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380"; + +async function checkDb(): Promise<"ok" | "error"> { + try { + await prisma.$queryRaw`SELECT 1`; + return "ok"; + } catch { + return "error"; + } +} + +async function checkRedis(): Promise<"ok" | "error"> { + return new Promise((resolve) => { + try { + const url = new URL(REDIS_URL); + const host = url.hostname || "localhost"; + const port = parseInt(url.port || "6379", 10); + const socket = createConnection({ host, port }, () => { + socket.write("*1\r\n$4\r\nPING\r\n"); + }); + socket.setTimeout(2000); + socket.on("data", (data) => { + socket.destroy(); + resolve(data.toString().includes("PONG") ? "ok" : "error"); + }); + socket.on("timeout", () => { socket.destroy(); resolve("error"); }); + socket.on("error", () => { socket.destroy(); resolve("error"); }); + } catch { + resolve("error"); + } }); } + +function checkBaseUrl(): { configured: boolean; isLocalhost: boolean } { + const raw = process.env["NEXTAUTH_URL"]?.trim(); + if (!raw) return { configured: false, isLocalhost: false }; + return { configured: true, isLocalhost: raw.startsWith("http://localhost") }; +} + +export async function GET() { + const [db, redis] = await Promise.all([checkDb(), checkRedis()]); + const baseUrl = checkBaseUrl(); + const ok = db === "ok" && redis === "ok"; + return NextResponse.json( + { status: ok ? "ok" : "degraded", db, redis, baseUrl, timestamp: new Date().toISOString() }, + { status: ok ? 200 : 503 }, + ); +} diff --git a/packages/api/src/lib/app-base-url.ts b/packages/api/src/lib/app-base-url.ts new file mode 100644 index 0000000..b0f766e --- /dev/null +++ b/packages/api/src/lib/app-base-url.ts @@ -0,0 +1,38 @@ +/** + * Returns the canonical public base URL of this app. + * + * Source: `NEXTAUTH_URL` environment variable (required in production). + * + * - Production (`NODE_ENV=production`): throws if the variable is missing or empty, + * because email links would silently point at localhost otherwise. + * - Development / test: falls back to `http://localhost:3100` and logs a one-time warning. + * + * Trailing slashes are stripped so callers can safely append paths with a leading `/`. + */ + +let warned = false; + +export function getAppBaseUrl(): string { + const raw = process.env["NEXTAUTH_URL"]?.trim(); + + if (raw) { + return raw.replace(/\/$/, ""); + } + + if (process.env["NODE_ENV"] === "production") { + throw new Error( + "NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " + + "Set it to the public URL of this app (e.g. https://capakraken.example.com).", + ); + } + + if (!warned) { + warned = true; + console.warn( + "[capakraken] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " + + "Set NEXTAUTH_URL in your .env to suppress this warning.", + ); + } + + return "http://localhost:3100"; +} diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 0878b90..fa941e3 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc.js"; +import { getAppBaseUrl } from "../lib/app-base-url.js"; import { sendEmail } from "../lib/email.js"; const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour @@ -47,8 +48,7 @@ export const authRouter = createTRPCRouter({ data: { email: input.email, token, expiresAt }, }); - const baseUrl = process.env["NEXTAUTH_URL"] ?? "http://localhost:3100"; - const resetUrl = `${baseUrl}/auth/reset-password/${token}`; + const resetUrl = `${getAppBaseUrl()}/auth/reset-password/${token}`; void sendEmail({ to: input.email, diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts new file mode 100644 index 0000000..80aa1ea --- /dev/null +++ b/packages/api/src/router/invite.ts @@ -0,0 +1,139 @@ +import { randomBytes } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { SystemRole } from "@capakraken/db"; +import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js"; +import { getAppBaseUrl } from "../lib/app-base-url.js"; +import { sendEmail } from "../lib/email.js"; + +const INVITE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours + +function inviteEmailHtml(inviteUrl: string, role: SystemRole): string { + return ` +

You have been invited to join CapaKraken as ${role}.

+

Click the link below to accept the invitation and set your password:

+

${inviteUrl}

+

This link expires in 72 hours and can only be used once.

+ `; +} + +export const inviteRouter = createTRPCRouter({ + /** Admin: create a new invite token and send the invite email. */ + createInvite: adminProcedure + .input( + z.object({ + email: z.string().email(), + role: z.nativeEnum(SystemRole).default(SystemRole.USER), + }), + ) + .mutation(async ({ ctx, input }) => { + // Prevent duplicate pending invites for the same email + const existing = await ctx.db.inviteToken.findFirst({ + where: { email: input.email, usedAt: null, expiresAt: { gt: new Date() } }, + }); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "An active invite already exists for this email address.", + }); + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS); + + await ctx.db.inviteToken.create({ + data: { + email: input.email, + role: input.role, + token, + expiresAt, + createdById: ctx.dbUser!.id, + }, + }); + + const inviteUrl = `${getAppBaseUrl()}/invite/${token}`; + + void sendEmail({ + to: input.email, + subject: "You have been invited to CapaKraken", + text: `You have been invited to join CapaKraken as ${input.role}.\n\nAccept your invitation: ${inviteUrl}\n\nThis link expires in 72 hours.`, + html: inviteEmailHtml(inviteUrl, input.role), + }); + + return { success: true }; + }), + + /** Admin: list all pending (unused, non-expired) invites. */ + listInvites: adminProcedure.query(async ({ ctx }) => { + const invites = await ctx.db.inviteToken.findMany({ + where: { usedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + select: { id: true, email: true, role: true, expiresAt: true, createdAt: true }, + }); + return invites; + }), + + /** Admin: revoke a pending invite. */ + revokeInvite: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + await ctx.db.inviteToken.delete({ where: { id: input.id } }); + return { success: true }; + }), + + /** Public: look up invite token metadata (for the accept page). */ + getInvite: publicProcedure + .input(z.object({ token: z.string() })) + .query(async ({ ctx, input }) => { + const invite = await ctx.db.inviteToken.findUnique({ + where: { token: input.token }, + select: { email: true, role: true, expiresAt: true, usedAt: true }, + }); + if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); + if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." }); + if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." }); + return { email: invite.email, role: invite.role }; + }), + + /** Public: accept an invite — set password, create account. */ + acceptInvite: publicProcedure + .input( + z.object({ + token: z.string(), + password: z.string().min(8, "Password must be at least 8 characters."), + }), + ) + .mutation(async ({ ctx, input }) => { + const invite = await ctx.db.inviteToken.findUnique({ + where: { token: input.token }, + }); + if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); + if (invite.usedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been used." }); + if (invite.expiresAt < new Date()) throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired." }); + + // Check if user already exists + const existing = await ctx.db.user.findUnique({ where: { email: invite.email } }); + if (existing) { + throw new TRPCError({ code: "CONFLICT", message: "An account with this email already exists." }); + } + + const { hash } = await import("@node-rs/argon2"); + const passwordHash = await hash(input.password); + + await ctx.db.user.create({ + data: { + email: invite.email, + name: invite.email.split("@")[0] ?? invite.email, + systemRole: invite.role, + passwordHash, + }, + }); + + await ctx.db.inviteToken.update({ + where: { token: input.token }, + data: { usedAt: new Date() }, + }); + + return { success: true }; + }), +});