diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx
index d6fafce..1cf20af 100644
--- a/src/app/(public)/login/page.tsx
+++ b/src/app/(public)/login/page.tsx
@@ -6,12 +6,13 @@ export const metadata: Metadata = {
};
/**
- * Sits in the (public) group on purpose: no shell, no nav, no store scope, and
- * a GuestGuard instead of an AuthGuard — see PublicLayout.
+ * Sits in the (public) group on purpose: no shell, no nav, no store scope and
+ * no auth guard — see PublicLayout.
*
- * Reaching this page with a live session is already impossible via a fresh
- * request (src/proxy.ts redirects it to /dashboard); the guard covers the
- * client-side navigation the proxy never sees.
+ * This page renders whenever it is asked for, with or without a live session in
+ * this browser. Sessions are per tab, so "somebody is signed in here" is not a
+ * reason to refuse the sign-in form to a tab that has no session of its own —
+ * and a page that always renders cannot take part in a redirect cycle.
*/
export default function LoginPage() {
return ;
diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts
index 961287b..7cf416a 100644
--- a/src/app/api/auth/session/route.ts
+++ b/src/app/api/auth/session/route.ts
@@ -1,8 +1,12 @@
import {NextResponse} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
-import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
-import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
+import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
+import {
+ sessionCookieFor,
+ tokenCookieFor,
+} from '@/features/auth/services/tabScope';
+import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {AuthSession} from '@/features/auth/types/auth';
@@ -64,14 +68,37 @@ export async function GET() {
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
- res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
- res.cookies.set(TOKEN_COOKIE, '', {
- httpOnly: true,
- sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
- path: '/',
- maxAge: 0,
- });
+
+ /**
+ * Clear THIS TAB's cookies, by their tab-scoped names.
+ *
+ * This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
+ * names from before sessions were per-tab. Those cookies do not exist any
+ * more, so the clear silently did nothing and a confirmed 401 left the
+ * tab's real `loyaly_session_` in place. The result was the
+ * half-authenticated state upstreamSession warns about, with a twist: the
+ * client set itself unauthenticated and went to /login, the proxy saw a
+ * still-valid session cookie and sent it straight back, and the two flapped.
+ *
+ * Same two helpers the logout route uses, so there is one naming scheme and
+ * the two paths cannot drift. Scoped to the resolved tab and no other: a
+ * dead session in one tab says nothing about the others, and clearing more
+ * than asked would sign out a tab that is working fine.
+ *
+ * A request with no resolvable tab clears nothing. There is no cookie to
+ * name, and guessing would reach into somebody else's session.
+ */
+ const tabId = await resolveTabId();
+ if (tabId) {
+ res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
+ res.cookies.set(tokenCookieFor(tabId), '', {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ path: '/',
+ maxAge: 0,
+ });
+ }
return res;
}
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2d148d0..dd5c42b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
+import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {tabSessionScript} from '@/features/auth/services/tabSession';
import {
htmlThemeAttr,
@@ -88,6 +89,22 @@ export default async function RootLayout({
* already answered in this very request.
*/
const session = await getServerSession();
+ /**
+ * WHICH tab that session was resolved from, handed to the client alongside it.
+ *
+ * A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves
+ * through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for
+ * a newly opened tab is somebody else. The seed is still worth sending (it is
+ * what saves a guard spinner on every load), but the client has to be able to
+ * tell whether the seed is its own. Without this it could not: it received an
+ * identity with nothing attached saying who it belonged to, so a second tab
+ * rendered the first tab's user in the shell and only found out when a data
+ * call answered 401.
+ *
+ * Not a credential and not a decision — just the label that lets the client
+ * recognise a seed that is not about it. See SessionProvider.
+ */
+ const tabId = await resolveTabId();
const themeMode = await readThemeMode();
return (
@@ -117,7 +134,11 @@ export default async function RootLayout({
services/tabSession.ts for what it replaced and why.
*/}
-
+
{children}