diff --git a/messages/de.json b/messages/de.json
index bc23d2f08..15c2e8336 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -413,6 +413,11 @@
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
"invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
+ "alreadySignedIn": {
+ "title": "Du bist bereits angemeldet",
+ "body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.",
+ "signOut": "Abmelden und Einladung annehmen"
+ },
"createAccount": "Konto erstellen",
"newAccount": "Neues Konto erstellen",
"backToPasskey": "Zurück zu Passkey",
diff --git a/messages/en.json b/messages/en.json
index dc66aa61b..4fa5a0ba7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -413,6 +413,11 @@
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
"invitedBanner": "You have been invited — create your account even while registration is otherwise closed.",
+ "alreadySignedIn": {
+ "title": "You are already signed in",
+ "body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.",
+ "signOut": "Sign out and accept the invitation"
+ },
"createAccount": "Create account",
"newAccount": "Create new account",
"backToPasskey": "Back to Passkey",
diff --git a/messages/es.json b/messages/es.json
index e5fddd66f..964f472bf 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -413,6 +413,11 @@
"loginFailed": "No se ha podido iniciar sesión",
"registerFailed": "No se ha podido completar el registro",
"invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
+ "alreadySignedIn": {
+ "title": "Ya has iniciado sesión",
+ "body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.",
+ "signOut": "Cerrar sesión y aceptar la invitación"
+ },
"createAccount": "Crear cuenta",
"newAccount": "Crear cuenta nueva",
"backToPasskey": "Volver a passkey",
diff --git a/messages/fr.json b/messages/fr.json
index 1640aa7e9..d1c5e8387 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -413,6 +413,11 @@
"loginFailed": "Échec de la connexion",
"registerFailed": "Échec de l’inscription",
"invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.",
+ "alreadySignedIn": {
+ "title": "Tu es déjà connecté",
+ "body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.",
+ "signOut": "Se déconnecter et accepter l'invitation"
+ },
"createAccount": "Créer un compte",
"newAccount": "Créer un nouveau compte",
"backToPasskey": "Revenir à la passkey",
diff --git a/messages/it.json b/messages/it.json
index d965b3827..c77cff0bc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -413,6 +413,11 @@
"loginFailed": "Accesso non riuscito",
"registerFailed": "Registrazione non riuscita",
"invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.",
+ "alreadySignedIn": {
+ "title": "Hai già effettuato l'accesso",
+ "body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.",
+ "signOut": "Esci e accetta l'invito"
+ },
"createAccount": "Crea account",
"newAccount": "Crea un nuovo account",
"backToPasskey": "Torna a passkey",
diff --git a/messages/ko.json b/messages/ko.json
index 4dabc847b..33e6e738a 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -413,6 +413,11 @@
"loginFailed": "로그인 실패",
"registerFailed": "회원가입 실패",
"invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
+ "alreadySignedIn": {
+ "title": "이미 로그인되어 있습니다",
+ "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.",
+ "signOut": "로그아웃하고 초대 수락하기"
+ },
"createAccount": "계정 만들기",
"newAccount": "새 계정 만들기",
"backToPasskey": "패스키로 돌아가기",
diff --git a/messages/pl.json b/messages/pl.json
index 9769c0838..d4ee7da18 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -413,6 +413,11 @@
"loginFailed": "Logowanie nie powiodło się",
"registerFailed": "Rejestracja nie powiodła się",
"invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.",
+ "alreadySignedIn": {
+ "title": "Masz już zalogowaną sesję",
+ "body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.",
+ "signOut": "Wyloguj się i przyjmij zaproszenie"
+ },
"createAccount": "Utwórz konto",
"newAccount": "Utwórz nowe konto",
"backToPasskey": "Wróć do passkey",
diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts
index 870a5e1b2..088f1d040 100644
--- a/src/app/api/auth/register/__tests__/route.test.ts
+++ b/src/app/api/auth/register/__tests__/route.test.ts
@@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
}));
+// The invite ledger. A refused registration must never reach it — the
+// assertion below is that `uses` is left alone, and the only way this route
+// can touch it is through `consumeInviteToken`.
+vi.mock("@/lib/auth/invite-token", () => ({
+ consumeInviteToken: vi
+ .fn()
+ .mockResolvedValue({ ok: true, inviteId: "inv-1" }),
+ recordInviteConsumer: vi.fn().mockResolvedValue(undefined),
+}));
+
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue(undefined),
+ getSession: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/auth/password", () => ({
@@ -78,7 +89,8 @@ import { POST } from "../route";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
-import { createSession } from "@/lib/auth/session";
+import { createSession, getSession } from "@/lib/auth/session";
+import { consumeInviteToken } from "@/lib/auth/invite-token";
import { Prisma } from "@/generated/prisma/client";
function postReq(body: unknown): NextRequest {
@@ -96,6 +108,7 @@ beforeEach(() => {
allowed: true,
ip: "1.2.3.4",
} as never);
+ vi.mocked(getSession).mockResolvedValue(null as never);
});
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
@@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () =>
expect(res.status).toBe(422);
});
});
+
+/**
+ * v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
+ * from inside somebody's live session.
+ *
+ * Until this gate the route read no session at all: a request carrying a valid
+ * `healthlog_session` was treated exactly like an anonymous one, and the
+ * unconditional `createSession` at the end overwrote the caller's cookie with
+ * the fresh account's. The admin who clicked his own invite link to check it
+ * was signed out of his admin account and into a stranger's onboarding, with
+ * his previous session row left valid server-side and unreachable.
+ */
+describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => {
+ const invitedBody = {
+ email: "new@example.com",
+ username: "newuser",
+ password: "a-very-strong-password-123",
+ inviteToken: `hlv_${"a".repeat(64)}`,
+ };
+
+ // Everything the happy path needs is stubbed, so the ONLY reason this
+ // request can fail is the gate under test. Without it the same request
+ // answers 201 and mints a session over the caller's cookie.
+ beforeEach(() => {
+ vi.mocked(consumeInviteToken).mockResolvedValue({
+ ok: true,
+ inviteId: "inv-1",
+ } as never);
+ vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
+ vi.mocked(checkPasswordStrength).mockReturnValue({
+ isAcceptable: true,
+ feedback: [],
+ } as never);
+ vi.mocked(hashPassword).mockResolvedValue("hashed" as never);
+ vi.mocked(prisma.$transaction).mockImplementation(((
+ fn: (tx: typeof prisma) => unknown,
+ ) => fn(prisma)) as never);
+ vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
+ vi.mocked(prisma.user.create).mockResolvedValue({
+ id: "u1",
+ username: "newuser",
+ email: "new@example.com",
+ role: "USER",
+ } as never);
+ vi.mocked(getSession).mockResolvedValue({
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
+ user: { id: "u-existing", username: "admin", email: "a@example.com" },
+ } as never);
+ });
+
+ it("answers 409 already_authenticated", async () => {
+ const res = await postPathThrough(invitedBody);
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as {
+ data: null;
+ error: string;
+ meta?: { errorCode?: string };
+ };
+ expect(body.data).toBeNull();
+ expect(body.meta?.errorCode).toBe("already_authenticated");
+ expect(body.error).toMatch(/sign/i);
+ });
+
+ it("creates no account", async () => {
+ await postPathThrough(invitedBody);
+ expect(prisma.user.create).not.toHaveBeenCalled();
+ });
+
+ it("does not burn a use of the invitation", async () => {
+ await postPathThrough(invitedBody);
+ expect(consumeInviteToken).not.toHaveBeenCalled();
+ });
+
+ it("does not touch the caller's own session", async () => {
+ await postPathThrough(invitedBody);
+ expect(createSession).not.toHaveBeenCalled();
+ });
+
+ it("refuses before the rate-limit bucket is spent", async () => {
+ // The gate is a session read, not an attempt: a signed-in visitor
+ // clicking the link three times must not lock the door for the person
+ // the invitation is actually for, who shares the IP on a home network.
+ await postPathThrough(invitedBody);
+ expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
+ });
+
+ it("still registers an anonymous caller", async () => {
+ // The control. Same request, no session — the invitation works.
+ vi.mocked(getSession).mockResolvedValue(null as never);
+ const res = await postPathThrough(invitedBody);
+ expect(res.status).toBe(201);
+ });
+});
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 8189b0789..c8b8ca48f 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -6,7 +6,7 @@ import {
} from "@/lib/auth/invite-token";
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
-import { createSession } from "@/lib/auth/session";
+import { createSession, getSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
+ // v1.38.19 — an invitation creates a NEW account, so it can never be
+ // accepted from inside a live session.
+ //
+ // The route used to read no session at all: a request carrying a valid
+ // `healthlog_session` was handled exactly like an anonymous one, and the
+ // unconditional `createSession` at the end overwrote the caller's cookie
+ // with the fresh account's. An admin opening his own invite link to check it
+ // was signed out of his admin account and into a stranger's onboarding, his
+ // previous session row still valid server-side and no longer reachable.
+ //
+ // Refused BEFORE the rate limit on purpose: this is a state, not an attempt,
+ // and spending the 5-per-15-minutes bucket on it would lock the door for the
+ // person the invitation is actually for, who may well share the IP.
+ // `getSession` is the optional resolver — it answers `null` for an anonymous
+ // caller instead of throwing, which `requireAuth` would.
+ const existingSession = await getSession();
+ if (existingSession) {
+ annotate({ action: { name: "auth.register.refused_authenticated" } });
+ return apiError(
+ "Already signed in — sign out first to use an invitation",
+ 409,
+ { errorCode: "already_authenticated" },
+ );
+ }
+
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
diff --git a/src/app/auth/register/__tests__/page.test.tsx b/src/app/auth/register/__tests__/page.test.tsx
new file mode 100644
index 000000000..47272db5a
--- /dev/null
+++ b/src/app/auth/register/__tests__/page.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * An invitation opened from inside a live session.
+ *
+ * `POST /api/auth/register` refuses that with 409 `already_authenticated`
+ * (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not
+ * make the visitor discover it by filling the form: a signed-in caller gets an
+ * explanation and a way out instead of three inputs that cannot succeed.
+ *
+ * SSR-only, per project convention (node environment, no DOM, no
+ * `@testing-library/react`), so what is asserted is the PAINT — which branch
+ * the page rendered. The sign-out click itself is driven in
+ * `e2e/invite-registration.spec.ts`.
+ *
+ * Mutation check, run: drop the `isAuthenticated` branch → the two signed-in
+ * cases go red naming the form inputs they found, the anonymous case stays
+ * green.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+const authRef = {
+ value: {
+ user: null as { username: string } | null,
+ isAuthenticated: false,
+ isLoading: false,
+ },
+};
+const inviteRef = { value: null as string | null };
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+ useSearchParams: () => ({
+ get: (key: string) => (key === "invite" ? inviteRef.value : null),
+ }),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => authRef.value,
+ clearCachesForSessionEnd: vi.fn(),
+}));
+
+import RegisterPage from "../page";
+
+const TOKEN = `hlv_${"a".repeat(64)}`;
+
+function render(): string {
+ return renderToStaticMarkup(
+
+ {t("auth.alreadySignedIn.body", { + username: user?.username ?? "", + })} +
+