import { randomBytes, scryptSync, timingSafeEqual, } from "node:crypto"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import type { McpScope, Prisma, User, UserRole } from "@prisma/client"; import { db } from "@/lib/db"; import { sweepArchive } from "@/lib/data/archive"; import { sweepRevisions } from "@/lib/data/revisions"; import { getSettings } from "@/lib/settings"; import { baseUrlFrom } from "@/lib/request-url"; const SESSION_COOKIE = "hired_session"; /** * The name this cookie had before the rename. Sessions are rows in the * database keyed by token, not by cookie name, so reading the old name keeps * everyone signed in through the deploy that renamed it — otherwise a branding * change silently logs out every person on the instance. * * Safe to delete once every live session predating the rename has expired, * which is 30 days after it shipped. */ const LEGACY_SESSION_COOKIE = "resume_os_session"; async function sessionToken(jar: Awaited>) { return jar.get(SESSION_COOKIE)?.value ?? jar.get(LEGACY_SESSION_COOKIE)?.value; } const SESSION_DAYS = 30; /** * How long a session lasts when the person did not tick "keep me signed in". * Its cookie also loses its expiry, so closing the browser ends it — this is * the backstop for the browser that is never closed. */ const UNREMEMBERED_HOURS = 12; // --------------------------------------------------------------------------- // The signed-in hint — how a landing page knows to send somebody to the app // --------------------------------------------------------------------------- /** * A cookie that says "somebody is signed in on this instance", and nothing else. * * hired.tools is a static site on its own origin, so it cannot ask this app * anything: the session cookie is SameSite=Lax and is simply not sent on a * cross-site request, and relaxing it to None so that one would work trades a * real defence against cross-site requests for a convenience. What two hosts * under one domain can share is a cookie on the domain above them both, so * signing in leaves a "1" up there and the landing page reads it in two lines * of script. * * Readable by script on purpose, and it carries no authority — the session * token stays httpOnly on the app's own host. The worst a forged one does is * send somebody to a sign-in page. */ const SIGNED_IN_COOKIE = "hired_signed_in"; /** * The domain the app and its landing page have in common, or null when they * have none worth writing to. * * It is the longest run of labels the two hosts share, which means it can never * be wider than the landing page an admin wrote down, whatever Host header a * request arrives with. Two labels is the floor. `.co.uk` is the case this * deliberately does not get clever about — a browser refuses a cookie on a * public suffix, so the hint is simply never stored and the landing page shows * the landing page. */ export function sharedCookieDomain(appHost: string, landingUrl: string): string | null { const app = appHost.split(":")[0].trim().toLowerCase(); let landing = ""; try { const url = /^https?:\/\//i.test(landingUrl) ? landingUrl : `https://${landingUrl}`; landing = new URL(url).hostname.toLowerCase(); } catch { return null; } if (!app || !landing) return null; const left = app.split("."); const right = landing.split("."); const shared: string[] = []; while (left.length > 0 && right.length > 0 && left[left.length - 1] === right[right.length - 1]) { shared.unshift(left.pop() as string); right.pop(); } return shared.length >= 2 ? shared.join(".") : null; } /** * Where the hint goes on this instance, or null when it goes nowhere — which is * every instance that has not configured a landing page, meaning almost every * self-hosted one. * * The Public URL setting first and the request second, the same order the rest * of the app resolves its own address in; `baseUrlFrom` is what knows about the * forwarded headers every supported deployment sits behind. */ export async function signedInHintDomain(): Promise { const { landingUrl, publicUrl } = await getSettings(); if (!landingUrl.trim()) return null; const hostOf = (url: string) => { try { return new URL(url).host; } catch { return ""; } }; // A Public URL too malformed to parse should cost the redirect, not the login. const host = (publicUrl.trim() && hostOf(publicUrl)) || hostOf(baseUrlFrom(await headers())); return host ? sharedCookieDomain(host, landingUrl) : null; } type Jar = Awaited>; /** Leave the hint. No expiry means it dies with the browser, like the session. */ async function writeSignedInHint(jar: Jar, expires?: Date) { const domain = await signedInHintDomain(); if (!domain) return; jar.set(SIGNED_IN_COOKIE, "1", { // Not httpOnly: a script on the landing page is the only thing that reads it. httpOnly: false, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", domain, ...(expires ? { expires } : {}), }); } /** Take it away again. Deleting a cookie means naming the domain it was set on. */ async function clearSignedInHint(jar: Jar) { const domain = await signedInHintDomain(); if (!domain) return; jar.set(SIGNED_IN_COOKIE, "", { path: "/", domain, maxAge: 0 }); } // --------------------------------------------------------------------------- // Passwords — scrypt from the standard library, no native dependency. // --------------------------------------------------------------------------- const SCRYPT_N = 16384; const SCRYPT_r = 8; const SCRYPT_p = 1; const KEY_LEN = 64; export function hashPassword(password: string): string { const salt = randomBytes(16); const key = scryptSync(password.normalize("NFKC"), salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_r, p: SCRYPT_p, }); return `scrypt:${SCRYPT_N}:${SCRYPT_r}:${SCRYPT_p}:${salt.toString("hex")}:${key.toString("hex")}`; } export function verifyPassword(password: string, stored: string): boolean { // An empty hash is the unclaimed-placeholder marker; nothing may match it. if (!stored) return false; const parts = stored.split(":"); if (parts.length !== 6 || parts[0] !== "scrypt") return false; const [, n, r, p, saltHex, keyHex] = parts; try { const salt = Buffer.from(saltHex, "hex"); const expected = Buffer.from(keyHex, "hex"); const actual = scryptSync(password.normalize("NFKC"), salt, expected.length, { N: Number(n), r: Number(r), p: Number(p), }); return actual.length === expected.length && timingSafeEqual(actual, expected); } catch { return false; } } export function generateMcpToken() { return `rsm_${randomBytes(24).toString("hex")}`; } function generateSessionToken() { return randomBytes(32).toString("hex"); } export function generateInviteToken() { return randomBytes(24).toString("hex"); } /** * The capture link's token. Same shape as an MCP token, different prefix, so a * glance at a string says which credential it is and the endpoint can refuse a * connection token pasted into the capture address. * * Here rather than beside the data layer that uses it because this file is * where this codebase mints tokens, and a fourth one somewhere else is how * conventions rot. */ export function generateCaptureToken() { return `cap_${randomBytes(24).toString("hex")}`; } // --------------------------------------------------------------------------- // Instance setup // --------------------------------------------------------------------------- /** * What makes an account real. * * The migration to multi-user left behind a placeholder row owning any * single-user-era data: no password, and nothing may sign in as it. For a long * time "has a password" and "is a real account" were the same sentence, so the * check was spelled `passwordHash != ""` in sixteen places. * * Google sign-in breaks that: someone who has only ever used Google has no * password and is entirely real. So the rule gets a name and one * implementation — a Prisma fragment for queries and a predicate for rows in * hand — because sixteen copies of a rule is sixteen chances for the next * sign-in method to lock somebody out of one screen and not the others. */ export const CLAIMED: Prisma.UserWhereInput = { OR: [{ passwordHash: { not: "" } }, { googleId: { not: null } }], }; export function isClaimed(user: { passwordHash: string; googleId: string | null }) { return Boolean(user.passwordHash || user.googleId); } /** * The address the owner account is created with at first boot. Nobody's real * address, so the first sign-in asks for one: see the change-password page. */ export const PLACEHOLDER_OWNER_EMAIL = "owner@localhost"; /** The instance is unclaimed until somebody who can sign in exists. */ export async function instanceNeedsSetup() { return (await db.user.count({ where: CLAIMED })) === 0; } export function setupKeyIsRequired() { return Boolean(process.env.APP_PASSWORD); } export function setupKeyMatches(candidate: string) { const expected = process.env.APP_PASSWORD ?? ""; if (!expected) return true; // nothing configured — nothing to check const a = Buffer.from(candidate); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); } /** * Claim the instance as super admin. Adopts the placeholder owner when one * exists so data from the single-user era keeps its ids and simply changes * hands, rather than being orphaned or deleted. */ export async function claimInstance(input: { email: string; name: string; password: string; }) { if (!(await instanceNeedsSetup())) { throw new Error("This instance has already been set up."); } const email = input.email.trim().toLowerCase(); const placeholder = await db.user.findFirst({ where: { passwordHash: "", googleId: null } }); const data = { email, name: input.name.trim(), passwordHash: hashPassword(input.password), role: "SUPER_ADMIN" as const, isActive: true, emailProvenAt: new Date(), }; const user = placeholder ? await db.user.update({ where: { id: placeholder.id }, data }) : await db.user.create({ data }); // Same as acceptInvite: the name and address they just typed are the header // of every resume, and without this they would have to type them again. await db.profile.upsert({ where: { userId: user.id }, create: { userId: user.id, fullName: input.name.trim(), email }, update: {}, }); await ensureDefaultConnection(user.id); return user; } // --------------------------------------------------------------------------- // Sessions // --------------------------------------------------------------------------- /** * Sign this browser in. `remember` is the sign-in page's "keep me signed in": * on, a session lasts a month and survives the browser closing; off, it is a * few hours and dies with the window, which is what somebody on a borrowed * machine is asking for. Everything else that starts a session — setup, * accepting an invitation, changing your own password — is already a * deliberate act on a machine you own, so it takes the default. */ export async function startSession(userId: string, options: { remember?: boolean } = {}) { const remember = options.remember !== false; const token = generateSessionToken(); const expiresAt = new Date( Date.now() + (remember ? SESSION_DAYS * 86400_000 : UNREMEMBERED_HOURS * 3600_000), ); const headerList = await headers(); await db.session.create({ data: { token, userId, expiresAt, userAgent: (headerList.get("user-agent") ?? "").slice(0, 200), }, }); await db.user.update({ where: { id: userId }, data: { lastLoginAt: new Date() } }); const jar = await cookies(); jar.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", // No expiry when they did not ask to be remembered: the cookie goes when // the browser does, and the row above expires on its own soon after. ...(remember ? { expires: expiresAt } : {}), }); await writeSignedInHint(jar, remember ? expiresAt : undefined); } /** * A session that exists only long enough for the PDF renderer to load one page. * * Deliberately an ordinary Session row rather than a new kind of token: the * print page keeps its `requireUser()` guard, so there is no new authentication * bypass to reason about, and this inherits every check that already applies — * a suspended user's ephemeral session is rejected exactly like any other. * Callers must delete it when they're done; the short expiry is the backstop. */ export async function createEphemeralSession(userId: string, seconds = 120) { const token = generateSessionToken(); await db.session.create({ data: { token, userId, expiresAt: new Date(Date.now() + seconds * 1000), userAgent: "hired-pdf-renderer", }, }); return token; } export async function destroySession(token: string) { await db.session.deleteMany({ where: { token } }); } export async function endSession() { const jar = await cookies(); const token = await sessionToken(jar); if (token) await db.session.deleteMany({ where: { token } }); jar.delete(SESSION_COOKIE); jar.delete(LEGACY_SESSION_COOKIE); await clearSignedInHint(jar); } /** Sign every device out — used when a password changes. */ export async function endAllSessions(userId: string) { await db.session.deleteMany({ where: { userId } }); } export async function getCurrentUser(): Promise { const jar = await cookies(); const token = await sessionToken(jar); if (!token) return null; const session = await db.session.findUnique({ where: { token }, include: { user: true } }); if (!session) return null; if (session.expiresAt < new Date()) { await db.session.delete({ where: { id: session.id } }).catch(() => {}); return null; } if (!session.user.isActive || !isClaimed(session.user)) return null; return session.user; } /** * The signed-in person for a route that must answer rather than redirect — * the assistant's stream, the OAuth hops — with requireUser's password gate * kept. An account still on a password somebody handed it gets null here, the * same as no session: otherwise the drawer could drive every tool, and the * Google link route could turn a leaked temporary password into a lasting * sign-in, without the person ever setting their own. */ export async function currentUserForApi(): Promise { const user = await getCurrentUser(); if (!user || user.mustChangePassword) return null; return user; } /** * Sign in with a password. Deliberately still tests `passwordHash` rather than * `isClaimed`: a Google-only account has no password, so there is nothing here * for it to match, and saying so is the point. */ export async function authenticate(email: string, password: string): Promise { const user = await db.user.findUnique({ where: { email: email.trim().toLowerCase() } }); // Spend the same work whether or not the account exists. const hash = user?.passwordHash || "scrypt:16384:8:1:00:00"; const ok = verifyPassword(password, hash); if (!user || !ok || !user.isActive || !user.passwordHash) return null; return user; } // --------------------------------------------------------------------------- // Guards // --------------------------------------------------------------------------- /** * The signed-in person, or the sign-in page. * * It also enforces the one gate that is not about identity: an account whose * password was handed to it by an admin — an invitation with a password on it, * or a reset marked "must change" — gets nothing but `/change-password` until * it sets its own. The check lives here rather than in the app layout because * every server action goes through this function too, and a gate that only the * chrome enforces is a gate you can walk around by posting a form. * * `/change-password` itself uses `requireUserPendingPasswordChange`, which is * the same function without the redirect — otherwise the one screen that can * clear the flag would bounce off it. */ export async function requireUser(): Promise { const user = await requireUserPendingPasswordChange(); if (user.mustChangePassword) redirect("/change-password"); return user; } /** `requireUser` minus the password gate. Only the screen behind it may call this. */ export async function requireUserPendingPasswordChange(): Promise { const user = await getCurrentUser(); if (!user) redirect("/login"); return user; } export function isAdmin(user: { role: UserRole }) { return user.role === "ADMIN" || user.role === "SUPER_ADMIN"; } export async function requireAdmin(): Promise { const user = await requireUser(); if (!isAdmin(user)) redirect("/"); return user; } export async function requireSuperAdmin(): Promise { const user = await requireUser(); if (user.role !== "SUPER_ADMIN") redirect("/"); return user; } // --------------------------------------------------------------------------- // MCP tokens — each connection belongs to exactly one user, so a connection URL // only ever reaches that person's data. // --------------------------------------------------------------------------- /** Don't write a timestamp on every single tool call. */ const LAST_USED_RESOLUTION_MS = 60_000; /** * The caller, and the connection they came in on. * * The connection id travels with the user because a tool that manages * connections has to know which one it is speaking through — that is what lets * "disconnect the old laptop" refuse to cut the wire it is standing on. */ /** * `connectionName` rides along because the change log stores the name as it was * at the time: a log that resolved the name at read time would relabel every * historic row the day somebody renamed a connection, and go blank the day they * deleted one. Same call AdminAudit makes about actorId. */ export type McpCaller = { user: User; connectionId: string; connectionName: string; /** * What this connection is served. Re-read from the row on EVERY post — no * cache, because the transport is stateless and that is what makes "narrow it * now" take effect on the client's next call rather than after a restart. */ scope: McpScope; }; export async function userByMcpToken( token: string | null | undefined, userAgent = "", ): Promise { if (!token || !token.startsWith("rsm_")) return null; const connection = await db.mcpConnection.findUnique({ where: { token }, include: { user: true }, }); if (!connection) return null; const { user } = connection; if (!user.isActive || !isClaimed(user)) return null; const now = Date.now(); const stale = !connection.lastUsedAt || now - connection.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS; if (stale) { // Piggybacking on the once-a-minute-per-connection throttle already here, // which closes the assistant-only case: an instance nobody signs into and // nobody restarts still clears its archive. Never let bookkeeping fail a // real request. void sweepArchive().catch(() => {}); // The version store's sweep rides the same throttle, for the same reason. void sweepRevisions().catch(() => {}); void db.mcpConnection .update({ where: { id: connection.id }, data: { lastUsedAt: new Date(now), lastUsedFrom: userAgent.slice(0, 200), }, }) .catch(() => {}); } return { user, connectionId: connection.id, connectionName: connection.name, scope: connection.scope }; } /** Every user starts with one connection so Settings is never an empty page. */ export async function ensureDefaultConnection(userId: string) { const existing = await db.mcpConnection.count({ where: { userId } }); if (existing > 0) return; await db.mcpConnection.create({ data: { userId, name: "Claude", client: "claude", token: generateMcpToken() }, }); } export { SESSION_COOKIE, SIGNED_IN_COOKIE };