Files
bsk/lib/auth/get-server-session.ts
tiennm99 129cbb7bf0 feat(phase-1): auth session wiring (proxy + layout + helpers)
- proxy.ts: composes Supabase session refresh + next-intl middleware
  into a single NextResponse via copyCookies helper. Coarse auth gate
  on /dashboard + /admin prefixes redirects unauth users to
  /[locale]/sign-in (no ?next= per trimmed plan).
- lib/supabase/session.ts: implements updateSupabaseSession() returning
  { response, user }. Cookies written onto both request.cookies (for
  downstream reads) and response.cookies (for browser). PROTECTED_PATH_PREFIXES
  exported as the gate list.
- lib/proxy/copy-cookies.ts: small helper that ports Set-Cookie entries
  between two NextResponses.
- lib/auth/get-server-session.ts: getServerSession() returning
  { user, role } | null. Derives User type from the factory's return
  type so @supabase/supabase-js stays out of allow-listed lib/auth/*
  per ESLint no-restricted-imports.
- lib/auth/session-provider.tsx: client-side context exposing user to
  client components via useSession() — populated once per request in
  the locale layout.
- app/[locale]/layout.tsx: reads user via getUser() outside any
  'use cache' scope; wraps children in SessionProvider; explicit
  'use cache' warning comment.
2026-05-25 17:30:41 +07:00

73 lines
2.6 KiB
TypeScript

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<ReturnType<typeof createSupabaseServerClient>>;
type GetUserResult = Awaited<ReturnType<SupabaseServerClient["auth"]["getUser"]>>;
export type User = NonNullable<GetUserResult["data"]["user"]>;
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<ServerSession | null> {
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 };
}