mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(i18n): survive Safari's 7-day cookie cap on the chosen locale
The first-paint ladder consulted only the healthlog-locale cookie and Accept-Language — never User.locale. The cookie is written client-side via document.cookie, which Safari ITP caps at 7 days, so a chosen language silently reverted to the browser language once a week. Three seams close it: - resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts) now reads User.locale on a cookie miss via a lightweight session read (no sliding-expiry write, no cookie mutation, fail-soft) before the Accept-Language fallback. - PUT /api/auth/me/locale mirrors the accepted locale into the cookie via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via shouldEmitSecureCookie) — server-set cookies are exempt from the ITP cap, and the mount-time backfill hitting the route doubles as the weekly refresh. - getSession re-emits the cookie when the user has a persisted locale but the cookie is absent (absent-only, so a mid-switch cookie is never reverted before the PUT lands). Users who never chose a language stay on Accept-Language.
This commit is contained in:
1 parent
ef82545870
commit
e9692f0b81
6 files changed
+233
-20
No files matched your search
@@ -20,11 +20,13 @@
|
||||
* holds a value the resolver would later have to defend against.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -58,5 +60,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ meta: { locale_next: locale } });
|
||||
}
|
||||
|
||||
// Mirror the accepted locale into the `healthlog-locale` cookie via
|
||||
// Set-Cookie. The client's own `document.cookie` write is capped at 7
|
||||
// days by Safari ITP; the server-set copy is not, so an explicit choice
|
||||
// survives on every device. Runs on the idempotent path too — the
|
||||
// mount-time backfill hitting this route is exactly the weekly refresh
|
||||
// that keeps the cookie alive. Harmless no-op payload for Bearer
|
||||
// callers (native clients ignore cookies).
|
||||
const cookieStore = await cookies();
|
||||
setLocaleCookie(cookieStore, locale);
|
||||
|
||||
return apiSuccess({ locale });
|
||||
});
|
||||
+2
-20
@@ -1,32 +1,14 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
async function resolveInitialLocale(): Promise<Locale> {
|
||||
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
|
||||
// fall back to the default so a locale hiccup never crashes the root
|
||||
// layout into global-error.tsx.
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { isP2025 } from "@/lib/prisma-errors";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
||||
|
||||
const SESSION_COOKIE = "healthlog_session";
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -170,12 +172,56 @@ export async function getSession(): Promise<{
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Locale-cookie refresh: Safari's ITP expires the script-written
|
||||
// `healthlog-locale` cookie after 7 days, so a returning user's first
|
||||
// paint fell back to the browser language once a week. When the user
|
||||
// has a persisted locale but the cookie is gone, re-emit it here —
|
||||
// Set-Cookie is exempt from the ITP cap. Absent-only on purpose: a
|
||||
// locale switch writes the cookie client-side before the PUT persists
|
||||
// the column, and overwriting a *differing* cookie here would revert
|
||||
// that fresh choice mid-flight. Fail-soft: in a server-component
|
||||
// render the cookie store is read-only and `set` throws — the next
|
||||
// route-handler request re-attempts.
|
||||
if (
|
||||
session.user.locale &&
|
||||
(locales as readonly string[]).includes(session.user.locale) &&
|
||||
!cookieStore.get(LOCALE_COOKIE)
|
||||
) {
|
||||
try {
|
||||
setLocaleCookie(cookieStore, session.user.locale as Locale);
|
||||
} catch {
|
||||
// Read-only cookie context — skip; nothing depends on this write.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: { id: session.id, expiresAt: session.expiresAt },
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight read of the signed-in user's persisted locale for the root
|
||||
* layout's first-paint language resolution. Deliberately NOT
|
||||
* `getSession()`: no db-compat check, no sliding-expiry write, no cookie
|
||||
* mutation — the root layout renders on every request in a context where
|
||||
* the cookie store is read-only, so the full session touch is both dead
|
||||
* weight and a throw hazard there. Returns null for missing/expired
|
||||
* sessions and users without a persisted locale.
|
||||
*/
|
||||
export async function getSessionUserLocale(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { expiresAt: true, user: { select: { locale: true } } },
|
||||
});
|
||||
if (!session || session.expiresAt < new Date()) return null;
|
||||
return session.user.locale;
|
||||
}
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* First-paint locale ladder (root layout): cookie → User.locale →
|
||||
* Accept-Language → "en" hard floor.
|
||||
*
|
||||
* The User.locale step is the Safari-ITP fix: the script-written
|
||||
* `healthlog-locale` cookie expires after 7 days there, and before this
|
||||
* ladder existed the first paint fell back to the browser language once
|
||||
* a week even though the user had explicitly chosen one.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCookies = vi.fn();
|
||||
const mockHeaders = vi.fn();
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: (...args: unknown[]) => mockCookies(...args),
|
||||
headers: (...args: unknown[]) => mockHeaders(...args),
|
||||
}));
|
||||
|
||||
const mockGetSessionUserLocale = vi.fn();
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSessionUserLocale: (...args: unknown[]) =>
|
||||
mockGetSessionUserLocale(...args),
|
||||
}));
|
||||
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
|
||||
function cookieStoreWith(value: string | undefined) {
|
||||
return {
|
||||
get: (name: string) =>
|
||||
name === "healthlog-locale" && value !== undefined
|
||||
? { name, value }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function headersWith(acceptLanguage: string | null) {
|
||||
return {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "accept-language" ? acceptLanguage : null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCookies.mockResolvedValue(cookieStoreWith(undefined));
|
||||
mockHeaders.mockResolvedValue(headersWith(null));
|
||||
mockGetSessionUserLocale.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("resolveInitialLocale", () => {
|
||||
it("returns the cookie locale without consulting the session", async () => {
|
||||
mockCookies.mockResolvedValue(cookieStoreWith("de"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("de");
|
||||
expect(mockGetSessionUserLocale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores an unsupported cookie value and walks the ladder", async () => {
|
||||
mockCookies.mockResolvedValue(cookieStoreWith("tlh"));
|
||||
mockGetSessionUserLocale.mockResolvedValue("fr");
|
||||
await expect(resolveInitialLocale()).resolves.toBe("fr");
|
||||
});
|
||||
|
||||
it("falls back to User.locale on a cookie miss (the ITP case)", async () => {
|
||||
mockGetSessionUserLocale.mockResolvedValue("pl");
|
||||
mockHeaders.mockResolvedValue(headersWith("en-US,en;q=0.9"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("pl");
|
||||
});
|
||||
|
||||
it("ignores an unsupported User.locale value", async () => {
|
||||
mockGetSessionUserLocale.mockResolvedValue("tlh");
|
||||
mockHeaders.mockResolvedValue(headersWith("it-IT,it;q=0.9"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("it");
|
||||
});
|
||||
|
||||
it("uses Accept-Language when there is no cookie and no session", async () => {
|
||||
mockHeaders.mockResolvedValue(headersWith("es-ES,es;q=0.9,en;q=0.5"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("es");
|
||||
});
|
||||
|
||||
it("survives a session-read failure and still honours Accept-Language", async () => {
|
||||
mockGetSessionUserLocale.mockRejectedValue(new Error("db down"));
|
||||
mockHeaders.mockResolvedValue(headersWith("de-DE,de;q=0.9"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("de");
|
||||
});
|
||||
|
||||
it("hard-floors to en when cookies() itself throws", async () => {
|
||||
mockCookies.mockRejectedValue(new Error("DynamicServerError"));
|
||||
await expect(resolveInitialLocale()).resolves.toBe("en");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { cookies } from "next/headers";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* The SSR handoff cookie for the active UI locale. The client-side
|
||||
* switcher writes it via `document.cookie` (src/lib/i18n/context.tsx),
|
||||
* but Safari's ITP caps script-written cookies at 7 days — a user who
|
||||
* doesn't open the app for a week silently falls back to the browser
|
||||
* language on first paint. Server-side `Set-Cookie` writes are exempt
|
||||
* from that cap, so every server seam that knows the persisted locale
|
||||
* re-emits the cookie through this one helper.
|
||||
*/
|
||||
export const LOCALE_COOKIE = "healthlog-locale";
|
||||
|
||||
// One year — mirrors the client-side `persistLocale` write.
|
||||
const LOCALE_COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
/**
|
||||
* Emit the locale cookie server-side. Throws when the cookie store is
|
||||
* read-only (server-component render) — callers on that path catch and
|
||||
* skip; the next route-handler request re-attempts.
|
||||
*/
|
||||
export function setLocaleCookie(cookieStore: CookieStore, locale: Locale) {
|
||||
cookieStore.set(LOCALE_COOKIE, locale, {
|
||||
// NOT HttpOnly — the client reads it (format helpers, hydration
|
||||
// handoff in src/lib/format.ts and i18n/context.tsx).
|
||||
httpOnly: false,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: LOCALE_COOKIE_MAX_AGE_S,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { LOCALE_COOKIE } from "@/lib/i18n/locale-cookie";
|
||||
import { getSessionUserLocale } from "@/lib/auth/session";
|
||||
|
||||
/**
|
||||
* First-paint locale ladder for the root layout:
|
||||
*
|
||||
* 1. `healthlog-locale` cookie — the SSR handoff the client writes on
|
||||
* every switch and the server re-emits on session touch.
|
||||
* 2. `User.locale` — the persisted preference. Consulted only on a
|
||||
* cookie miss (Safari ITP expires the script-written cookie after
|
||||
* 7 days; without this step the app fell back to the browser
|
||||
* language once a week even though the user had chosen one).
|
||||
* 3. Accept-Language — users who never chose stay on the browser
|
||||
* language ("automatic").
|
||||
*
|
||||
* Fail-soft throughout: cookies()/headers() can throw
|
||||
* (DynamicServerError, …) and the session read can hit a DB hiccup — a
|
||||
* locale resolution problem must never crash the root layout into
|
||||
* global-error.tsx.
|
||||
*/
|
||||
export async function resolveInitialLocale(): Promise<Locale> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
|
||||
let userLocale: string | null = null;
|
||||
try {
|
||||
userLocale = await getSessionUserLocale();
|
||||
} catch {
|
||||
// DB hiccup — fall through to Accept-Language rather than "en".
|
||||
}
|
||||
if (userLocale && (locales as readonly string[]).includes(userLocale)) {
|
||||
return userLocale as Locale;
|
||||
}
|
||||
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user