diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index ee48e74..8e8ba21 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -1,9 +1,14 @@
+// WARNING: Do NOT add `'use cache'` to this layout — it calls
+// createSupabaseServerClient() which reads cookies(). Caching this scope would
+// either throw at build time or serve stale auth state across users.
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { routing } from "@/i18n/routing";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+import { SessionProvider } from "@/lib/auth/session-provider";
import "../globals.css";
export const metadata: Metadata = {
@@ -37,10 +42,28 @@ export default async function LocaleLayout({
setRequestLocale(locale);
+ // Read the authenticated user here, outside any `'use cache'` scope.
+ // getUser() validates the JWT server-side on every render — do not move this
+ // call into a cached helper. Cached helpers that need the user receive it as
+ // a function argument (see phase 06 patterns).
+ const supabase = await createSupabaseServerClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ // user is null when unauthenticated or when Supabase Auth is unreachable.
+ // No redirect here — route gating is phase 06's responsibility.
+ // The proxy already handles the coarse "unauth → /sign-in" redirect for
+ // explicitly protected path prefixes (/dashboard, /admin).
+
return (
- {children}
+
+ {/* SessionProvider makes `user` available to client components via
+ useSession() without any additional Supabase calls from the client. */}
+ {children}
+
);
diff --git a/lib/auth/get-server-session.ts b/lib/auth/get-server-session.ts
new file mode 100644
index 0000000..6da1255
--- /dev/null
+++ b/lib/auth/get-server-session.ts
@@ -0,0 +1,72 @@
+import "server-only";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+import { isAppRole, type AppRole } from "@/lib/db/roles";
+
+// Derive the User type from the factory's return type so we never import
+// @supabase/supabase-js directly (ESLint no-restricted-imports enforces that
+// only the named factory files in lib/supabase/* may do so).
+type SupabaseServerClient = Awaited>;
+type GetUserResult = Awaited>;
+export type User = NonNullable;
+
+export type ServerSession = {
+ user: User;
+ role: AppRole | null;
+};
+
+/**
+ * Reads the authenticated user and their BSK role from the current request.
+ *
+ * Returns `null` when unauthenticated or when `getUser()` fails (transient
+ * Supabase outage). Returns `{ user, role: null }` when the user is
+ * authenticated but has no row in `bsk.app_users` (e.g. just signed up,
+ * awaiting role assignment by admin).
+ *
+ * MUST be called outside any `'use cache'` scope — it calls
+ * `createSupabaseServerClient()` which reads `cookies()`. Cached helpers that
+ * need the session must receive `user` / `role` as arguments, never re-read
+ * cookies internally.
+ *
+ * Used by: `[locale]/layout.tsx` (phase 02 establishes the pattern),
+ * protected route layouts (phase 06), and Server Actions that need role checks.
+ */
+export async function getServerSession(): Promise {
+ let supabase: SupabaseServerClient;
+
+ try {
+ supabase = await createSupabaseServerClient();
+ } catch {
+ // Cookie store unavailable (e.g. called during static generation).
+ return null;
+ }
+
+ // getUser() round-trips to Supabase Auth and validates the JWT server-side.
+ // Do NOT use getSession() here — it trusts the cookie blob without validation.
+ const {
+ data: { user },
+ error: userError,
+ } = await supabase.auth.getUser();
+
+ if (userError ?? !user) {
+ return null;
+ }
+
+ // Query the BSK role via the RPC defined in migration 20260525163300.
+ // Returns null when the user has no row in bsk.app_users.
+ // The RPC relies on auth.uid() matching a row in bsk.app_users; if the
+ // migration has not been applied yet (pre-provisioning), the RPC will throw —
+ // treated as role: null, not as an auth failure.
+ let role: AppRole | null = null;
+
+ try {
+ const { data: rpcRole, error: rpcError } = await supabase.rpc("current_role");
+
+ if (!rpcError && rpcRole !== null && isAppRole(rpcRole)) {
+ role = rpcRole;
+ }
+ } catch {
+ // Pre-provisioning or transient DB error: proceed with role: null.
+ }
+
+ return { user, role };
+}
diff --git a/lib/auth/session-provider.tsx b/lib/auth/session-provider.tsx
new file mode 100644
index 0000000..6dffb56
--- /dev/null
+++ b/lib/auth/session-provider.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import type { ReactNode } from "react";
+import type { User } from "@/lib/auth/get-server-session";
+
+// ── Context ───────────────────────────────────────────────────────────────────
+
+const SessionContext = createContext(null);
+
+// ── Provider ──────────────────────────────────────────────────────────────────
+
+/**
+ * Thin client wrapper that makes the server-read `user` available to any
+ * client component in the subtree via `useSession()`.
+ *
+ * The `user` value is read once per request in `[locale]/layout.tsx` (outside
+ * any `'use cache'` scope) and passed in as a prop — this component does NOT
+ * make any Supabase calls itself.
+ *
+ * Phase 06 consumes this via `useSession()` for: sidebar user badge,
+ * sign-out button, and client-side role checks.
+ */
+export function SessionProvider({ user, children }: { user: User | null; children: ReactNode }) {
+ return {children};
+}
+
+// ── Hook ──────────────────────────────────────────────────────────────────────
+
+/**
+ * Returns the current authenticated user, or `null` when unauthenticated.
+ * Must be called from a client component inside ``.
+ */
+export function useSession(): User | null {
+ return useContext(SessionContext);
+}
diff --git a/lib/proxy/copy-cookies.ts b/lib/proxy/copy-cookies.ts
new file mode 100644
index 0000000..52a39ce
--- /dev/null
+++ b/lib/proxy/copy-cookies.ts
@@ -0,0 +1,16 @@
+import type { NextResponse } from "next/server";
+
+/**
+ * Copies all Set-Cookie entries from `from` onto `to`.
+ *
+ * Used when two middleware layers each produce a NextResponse — we must funnel
+ * both sets of cookies onto the single response that is ultimately returned.
+ * Specifically: Supabase session-refresh writes sb-* cookies onto a response;
+ * if next-intl then produces its own redirect/rewrite response, we must port
+ * the Supabase cookies onto it before returning or the auth token is lost.
+ */
+export function copyCookies(from: NextResponse, to: NextResponse): void {
+ for (const cookie of from.cookies.getAll()) {
+ to.cookies.set(cookie.name, cookie.value, cookie);
+ }
+}
diff --git a/lib/supabase/session.ts b/lib/supabase/session.ts
index 3d44b25..3e95d2b 100644
--- a/lib/supabase/session.ts
+++ b/lib/supabase/session.ts
@@ -2,17 +2,36 @@ import "server-only";
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server";
+import type { User } from "@supabase/supabase-js";
+
+/**
+ * Coarse list of path prefixes that require authentication.
+ * Checked by proxy.ts AFTER stripping the locale segment.
+ * Extend here as new protected route groups are added (phases 05–06).
+ * `/sign-in` is intentionally absent — it must be reachable while unauth'd.
+ */
+export const PROTECTED_PATH_PREFIXES: ReadonlyArray = ["/dashboard", "/admin"];
/**
* Refreshes the Supabase auth session for an incoming request.
*
- * NOT yet wired into `proxy.ts` — that integration lands in Phase 1 along with
- * the sign-in flow. When wiring it in, call this BEFORE handing the request to
- * `next-intl/middleware`, and merge the returned response's Set-Cookie headers
- * into the response next-intl produces (the two run in sequence; do not build
- * two independent NextResponse instances).
+ * Call this BEFORE handing the request to `next-intl/middleware`. Merge the
+ * returned `response`'s Set-Cookie headers onto whichever response next-intl
+ * ultimately returns — do NOT build two independent NextResponse instances or
+ * one set of cookies will be silently dropped (use `copyCookies` helper).
+ *
+ * Returns `{ response, user }` where `user` is `null` on auth failure or when
+ * no session exists. The `response` always carries refreshed (or unchanged)
+ * Supabase cookie deltas.
+ *
+ * Error-swallow on `getUser()` is intentional: a transient Supabase Auth
+ * outage should not hard-fail every request. The caller treats `user: null` as
+ * unauthenticated and applies the protected-path redirect; Supabase cookies are
+ * preserved so the next request retries. (Confirmed acceptable — code-reviewer N6.)
*/
-export async function updateSupabaseSession(request: NextRequest) {
+export async function updateSupabaseSession(
+ request: NextRequest,
+): Promise<{ response: NextResponse; user: User | null }> {
const response = NextResponse.next({ request });
const supabase = createServerClient(
@@ -26,7 +45,9 @@ export async function updateSupabaseSession(request: NextRequest) {
},
setAll(cookiesToSet) {
for (const { name, value, options } of cookiesToSet) {
+ // Write onto the request so downstream middleware see the fresh token.
request.cookies.set(name, value);
+ // Write onto the response so the browser receives the refreshed cookie.
response.cookies.set(name, value, options);
}
},
@@ -34,11 +55,14 @@ export async function updateSupabaseSession(request: NextRequest) {
},
);
+ let user: User | null = null;
+
try {
- await supabase.auth.getUser();
+ const { data } = await supabase.auth.getUser();
+ user = data.user;
} catch {
// Transient Supabase Auth outage: keep stale cookies; next request retries.
}
- return response;
+ return { response, user };
}
diff --git a/proxy.ts b/proxy.ts
index fcf488a..85094c4 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,7 +1,94 @@
import createMiddleware from "next-intl/middleware";
-import { routing } from "./i18n/routing";
+import { NextResponse, type NextRequest } from "next/server";
+import { routing } from "@/i18n/routing";
+import { updateSupabaseSession, PROTECTED_PATH_PREFIXES } from "@/lib/supabase/session";
+import { copyCookies } from "@/lib/proxy/copy-cookies";
-export default createMiddleware(routing);
+/**
+ * Built once at module load — not per request.
+ * next-intl composition pattern: call createMiddleware() here, then invoke the
+ * returned function inside proxy() with the actual request.
+ * Ref: next-intl docs "Usage without framework integration".
+ */
+const handleI18nRouting = createMiddleware(routing);
+
+/**
+ * Returns true when the path (locale-stripped) starts with a protected prefix.
+ * Input: raw pathname from NextRequest (e.g. "/vi/dashboard/patients").
+ * Strips the leading locale segment before comparing so "/vi/dashboard" and
+ * "/en/dashboard" both match "/dashboard".
+ */
+function isProtectedPath(pathname: string): boolean {
+ // Remove a leading locale segment if present (e.g. "/vi" or "/en").
+ const localeSegmentRe = new RegExp(`^\\/(${routing.locales.join("|")})(\\/.*)?(\\?.*)?$`);
+ const match = localeSegmentRe.exec(pathname);
+ // After stripping: "/vi/dashboard" → "/dashboard"; "/dashboard" stays as-is.
+ const stripped = match ? (match[2] ?? "/") : pathname;
+
+ return PROTECTED_PATH_PREFIXES.some((prefix) => stripped.startsWith(prefix));
+}
+
+/**
+ * Unified middleware entry point.
+ *
+ * Request flow:
+ * 1. Run Supabase session refresh → writes refreshed sb-* cookies onto BOTH
+ * request.cookies (for downstream reads) and a NextResponse (for Set-Cookie).
+ * 2. Coarse auth gate: unauthenticated requests to protected paths get a 307
+ * redirect to /${locale}/sign-in?next=.
+ * Supabase cookies are copied onto the redirect response so the browser
+ * receives the token delta even on redirect.
+ * 3. Hand off to next-intl for locale detection / prefix rewrites.
+ * • If next-intl produces a redirect/rewrite (status !== 200 OR x-middleware-rewrite
+ * header present), copy Supabase cookies onto it and return it.
+ * • Otherwise return the Supabase response directly (cookies already attached).
+ *
+ * Single response guarantee: only one NextResponse is ever returned per
+ * request. Cookie sets from both layers are always merged before returning.
+ * Two competing responses would silently drop one set of Set-Cookie headers,
+ * appearing as a sign-out on the next request.
+ *
+ * NOTE: No `export const runtime = 'edge'` — proxy runs on the Node.js runtime
+ * in Next.js 16+. Supabase SSR is not certified for the Edge runtime.
+ */
+export default async function proxy(request: NextRequest): Promise {
+ // ── Step 1: Supabase session refresh ─────────────────────────────────────
+ const { response: supabaseResponse, user } = await updateSupabaseSession(request);
+
+ // ── Step 2: Coarse protected-path gate ───────────────────────────────────
+ // Always redirects to `/${locale}/sign-in` with NO `?next=` param —
+ // the trimmed plan dropped post-login redirect plumbing (original BSK has no
+ // URL deep-linking surface; signInAction always lands users on /dashboard).
+ if (!user && isProtectedPath(request.nextUrl.pathname)) {
+ const locale =
+ routing.locales.find((l) => request.nextUrl.pathname.startsWith(`/${l}`)) ??
+ routing.defaultLocale;
+
+ const signInUrl = new URL(`/${locale}/sign-in`, request.url);
+
+ const redirectResponse = NextResponse.redirect(signInUrl, { status: 307 });
+ // Port Supabase cookie deltas onto the redirect so the browser stores them.
+ copyCookies(supabaseResponse, redirectResponse);
+ return redirectResponse;
+ }
+
+ // ── Step 3: next-intl locale routing ─────────────────────────────────────
+ const intlResponse = handleI18nRouting(request);
+
+ // next-intl produced a redirect (3xx) or a rewrite (x-middleware-rewrite header).
+ // In both cases it is a distinct NextResponse — merge Supabase cookies onto it.
+ const isRedirect = intlResponse.status >= 300 && intlResponse.status < 400;
+ const isRewrite = intlResponse.headers.has("x-middleware-rewrite");
+
+ if (isRedirect || isRewrite) {
+ copyCookies(supabaseResponse, intlResponse);
+ return intlResponse;
+ }
+
+ // next-intl returned a plain next() response — the Supabase response already
+ // carries the correct cookies (and the request rewrites from step 1).
+ return supabaseResponse;
+}
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],