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:
Marc-André Bombeck committed 2026-07-05 21:01:50 +02:00
1 parent ef82545870
commit e9692f0b81
6 files changed
+233 -20

No files matched your search

+12
View File
@@ -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 });
});