next-supa-utils 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts","../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Server entry point ──────────────────────────────────────────────\n// This module should ONLY be imported in server-side contexts:\n// middleware.ts, Server Components, Server Actions, Route Handlers.\n\nexport { withSupaAuth } from \"./middleware/withSupaAuth\";\nexport { createAction } from \"./actions/actionWrapper\";\n\n// Re-export types consumers commonly need alongside server helpers.\nexport type { SupaAuthConfig, ActionResponse, SupaError } from \"../types\";\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { SupaAuthConfig } from \"../../types\";\n\n/**\n * Create a Next.js middleware handler that protects routes based on\n * Supabase authentication state.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * protectedRoutes: [\"/dashboard\", \"/admin\"],\n * redirectTo: \"/login\",\n * publicRoutes: [\"/admin/login\"],\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(config: SupaAuthConfig) {\n const {\n protectedRoutes,\n redirectTo = \"/login\",\n publicRoutes = [],\n onAuthSuccess,\n } = config;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n // Forward cookies to the request so downstream server\n // components can read the updated session.\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n // Re-create the response so it carries the updated request.\n response = NextResponse.next({ request });\n\n // Set cookies on the outgoing response so the browser\n // stores the refreshed tokens.\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Check if current path matches a public override ────────\n const isPublicRoute = publicRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isPublicRoute) {\n return response;\n }\n\n // ── 5. Check if current path requires authentication ──────────\n const isProtectedRoute = protectedRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isProtectedRoute && !user) {\n const loginUrl = new URL(redirectTo, request.url);\n // Preserve the originally-requested URL so the app can redirect\n // back after login.\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Optional success callback ──────────────────────────────\n if (user && onAuthSuccess) {\n await onAuthSuccess({ id: user.id, email: user.email ?? undefined });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":"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","names":["import_ssr"]}
1
+ {"version":3,"sources":["../../src/server/index.ts","../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts","../../src/server/actions/routeWrapper.ts"],"sourcesContent":["// ── Server entry point ──────────────────────────────────────────────\n// This module should ONLY be imported in server-side contexts:\n// middleware.ts, Server Components, Server Actions, Route Handlers.\n\nexport { withSupaAuth } from \"./middleware/withSupaAuth\";\nexport { createAction } from \"./actions/actionWrapper\";\nexport { routeWrapper } from \"./actions/routeWrapper\";\nexport type { RouteHandlerContext } from \"./actions/routeWrapper\";\n\n// Re-export types consumers commonly need alongside server helpers.\nexport type { MiddlewareOptions, RouteConfig, RouteWrapperOptions, ActionResponse, SupaError } from \"../types\";\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { MiddlewareOptions, RouteConfig } from \"../../types\";\n\n// ── Path matching utility ───────────────────────────────────────────\n\n/**\n * Converts a route pattern into a RegExp for matching.\n *\n * Supports:\n * - Exact: `\"/settings\"` → matches only `/settings`\n * - Prefix: `\"/dashboard\"` → matches `/dashboard`, `/dashboard/stats`\n * - Wildcard: `\"/admin/:path*\"` → matches `/admin`, `/admin/users`, `/admin/a/b/c`\n */\nfunction matchPath(pattern: string, pathname: string): boolean {\n // Wildcard pattern: \"/admin/:path*\" → match \"/admin\" and everything below\n if (pattern.includes(\":path*\")) {\n const prefix = pattern.replace(/:path\\*$/, \"\").replace(/\\/$/, \"\");\n return pathname === prefix || pathname.startsWith(prefix + \"/\");\n }\n\n // Prefix matching: \"/dashboard\" matches \"/dashboard/anything\"\n return pathname === pattern || pathname.startsWith(pattern + \"/\");\n}\n\n/**\n * Finds the first `RouteConfig` whose pattern matches `pathname`, or `undefined`.\n */\nfunction findMatchingRoute(\n routes: RouteConfig[],\n pathname: string,\n): RouteConfig | undefined {\n return routes.find((route) => matchPath(route.path, pathname));\n}\n\n// ── Role extraction utility ─────────────────────────────────────────\n\ntype UserMeta = {\n user_metadata: Record<string, unknown>;\n app_metadata: Record<string, unknown>;\n};\n\nfunction extractRole(\n user: UserMeta,\n extractor: MiddlewareOptions[\"roleExtractor\"],\n): string | string[] | undefined {\n if (typeof extractor === \"function\") {\n return extractor(user);\n }\n\n const source =\n extractor === \"app_metadata\" ? user.app_metadata : user.user_metadata;\n\n const raw = source?.role;\n\n if (typeof raw === \"string\") return raw;\n if (Array.isArray(raw) && raw.every((r) => typeof r === \"string\")) {\n return raw as string[];\n }\n\n return undefined;\n}\n\n/**\n * Check whether the user's role(s) satisfy the route's `allowedRoles`.\n */\nfunction hasRequiredRole(\n userRole: string | string[] | undefined,\n allowedRoles: string[] | undefined,\n): boolean {\n // No role restriction → any authenticated user is allowed.\n if (!allowedRoles || allowedRoles.length === 0) return true;\n\n if (!userRole) return false;\n\n const roles = Array.isArray(userRole) ? userRole : [userRole];\n return roles.some((r) => allowedRoles.includes(r));\n}\n\n// ── Main middleware factory ─────────────────────────────────────────\n\n/**\n * Create a Next.js middleware handler that protects routes with\n * authentication **and** optional role-based access control (RBAC).\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * routes: [\n * { path: \"/dashboard\" }, // any authed user\n * { path: \"/admin/:path*\", allowedRoles: [\"admin\"] }, // admin only\n * { path: \"/editor/:path*\", allowedRoles: [\"admin\", \"editor\"] },\n * ],\n * redirectTo: \"/login\",\n * roleExtractor: \"user_metadata\", // or \"app_metadata\" or a custom fn\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(options: MiddlewareOptions) {\n const {\n routes,\n redirectTo = \"/login\",\n roleExtractor = \"user_metadata\",\n onAuthSuccess,\n } = options;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n response = NextResponse.next({ request });\n\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Find the matching route config ─────────────────────────\n const matchedRoute = findMatchingRoute(routes, pathname);\n\n // No matching route → not a protected path, pass through.\n if (!matchedRoute) {\n return response;\n }\n\n // ── 5. Check authentication ───────────────────────────────────\n if (!user) {\n const loginUrl = new URL(redirectTo, request.url);\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Check role-based access ────────────────────────────────\n const userRole = extractRole(user, roleExtractor);\n\n if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {\n // User is logged in but lacks the required role.\n const forbiddenUrl = new URL(redirectTo, request.url);\n forbiddenUrl.searchParams.set(\"error\", \"forbidden\");\n forbiddenUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(forbiddenUrl);\n }\n\n // ── 7. Success callback ───────────────────────────────────────\n if (onAuthSuccess) {\n await onAuthSuccess({\n id: user.id,\n email: user.email ?? undefined,\n role: userRole,\n });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { RouteWrapperOptions, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Higher-order function that wraps a Next.js App Router Route Handler\n * with standardized error handling and optional authentication gating.\n *\n * @example\n * ```ts\n * // app/api/posts/route.ts\n * import { routeWrapper } from \"next-supa-utils/server\";\n *\n * // Public endpoint (no auth required)\n * export const GET = routeWrapper(async (request) => {\n * const data = await fetchPosts();\n * return NextResponse.json({ data });\n * });\n *\n * // Protected endpoint (requires valid Supabase session)\n * export const POST = routeWrapper(\n * async (request, { supabase, user }) => {\n * const body = await request.json();\n * const { data, error } = await supabase\n * .from(\"posts\")\n * .insert({ ...body, user_id: user.id })\n * .select()\n * .single();\n *\n * if (error) throw error;\n * return NextResponse.json({ data }, { status: 201 });\n * },\n * { requireAuth: true },\n * );\n * ```\n */\nexport function routeWrapper<TContext extends Record<string, unknown> = Record<string, unknown>>(\n handler: (\n request: NextRequest,\n context: RouteHandlerContext<TContext>,\n ) => Promise<NextResponse | Response>,\n options?: RouteWrapperOptions,\n) {\n const { requireAuth = false } = options ?? {};\n\n return async (\n request: NextRequest,\n routeContext?: { params?: Promise<TContext> },\n ): Promise<NextResponse> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return NextResponse.json(\n { error: { message: \"Server configuration error\", code: \"CONFIG_ERROR\" } },\n { status: 500 },\n );\n }\n\n // ── Build context to pass to the handler ────────────────────\n const ctx: RouteHandlerContext<TContext> = {\n params: routeContext?.params ? await routeContext.params : ({} as TContext),\n supabase: null as unknown as SupabaseClient,\n user: null,\n };\n\n // ── Optionally init Supabase & check auth ───────────────────\n if (requireAuth) {\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options: opts }) => {\n cookieStore.set(name, value, opts);\n });\n } catch {\n // cookies().set() may throw in read-only contexts\n }\n },\n },\n });\n\n const {\n data: { user },\n error: authError,\n } = await supabase.auth.getUser();\n\n if (authError || !user) {\n return NextResponse.json(\n {\n error: {\n message: \"Unauthorized: valid session required\",\n code: \"UNAUTHORIZED\",\n } satisfies SupaError,\n },\n { status: 401 },\n );\n }\n\n ctx.supabase = supabase;\n ctx.user = user;\n } else {\n // Even for public routes, provide a Supabase client for convenience.\n const cookieStore = await cookies();\n\n ctx.supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options: opts }) => {\n cookieStore.set(name, value, opts);\n });\n } catch {\n // cookies().set() may throw in read-only contexts\n }\n },\n },\n });\n }\n\n // ── Execute the handler ─────────────────────────────────────\n const response = await handler(request, ctx);\n return response instanceof NextResponse\n ? response\n : NextResponse.json(await response.json(), { status: response.status });\n } catch (caught: unknown) {\n const error = handleSupaError(caught);\n const status = error.status ?? 500;\n\n return NextResponse.json({ error }, { status });\n }\n };\n}\n\n// ── Internal context type ───────────────────────────────────────────\n\n/** Context object passed to the route handler by `routeWrapper`. */\nexport interface RouteHandlerContext<\n TParams extends Record<string, unknown> = Record<string, unknown>,\n> {\n /** Resolved dynamic route params (e.g. `{ id: \"123\" }`). */\n params: TParams;\n\n /**\n * Supabase server client.\n * Always available (even on public routes) for convenience queries.\n */\n supabase: SupabaseClient;\n\n /**\n * The authenticated user, or `null` for public (non-auth) routes.\n * Guaranteed non-null when `requireAuth: true`.\n */\n user: Awaited<ReturnType<SupabaseClient[\"auth\"][\"getUser\"]>>[\"data\"][\"user\"];\n}\n"],"mappings":"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","names":["options","import_ssr","import_ssr","import_server","import_headers"]}
@@ -15,38 +15,86 @@ type ActionResponse<T> = {
15
15
  data: null;
16
16
  error: SupaError;
17
17
  };
18
- interface SupaAuthConfig {
18
+ /**
19
+ * Defines a protected route with optional role-based access control.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
24
+ * ```
25
+ */
26
+ interface RouteConfig {
27
+ /**
28
+ * Route pattern to match.
29
+ *
30
+ * Supports:
31
+ * - Exact paths: `"/settings"`
32
+ * - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
33
+ * - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
34
+ */
35
+ path: string;
19
36
  /**
20
- * Route prefixes that require an authenticated user.
21
- * Supports simple prefix matching.
37
+ * Roles that are allowed to access this route.
22
38
  *
23
- * @example ["/dashboard", "/admin", "/settings"]
39
+ * - If **omitted or empty**, any *authenticated* user can access the route.
40
+ * - If **provided**, only users whose role is in this list are allowed.
41
+ *
42
+ * @example ["admin", "editor"]
24
43
  */
25
- protectedRoutes: string[];
44
+ allowedRoles?: string[];
45
+ }
46
+ /**
47
+ * Configuration for the `withSupaAuth` middleware.
48
+ */
49
+ interface MiddlewareOptions {
26
50
  /**
27
- * Where to redirect unauthenticated users.
51
+ * Protected route definitions. Each entry can optionally restrict access
52
+ * to specific roles via `allowedRoles`.
53
+ */
54
+ routes: RouteConfig[];
55
+ /**
56
+ * Where to redirect unauthenticated or unauthorized users.
28
57
  * @default "/login"
29
58
  */
30
59
  redirectTo?: string;
31
60
  /**
32
- * Routes that are always public, even if they match a protected prefix.
61
+ * Strategy for extracting the user's role from the Supabase user object.
62
+ *
63
+ * - `"user_metadata"` — reads `user.user_metadata.role`
64
+ * - `"app_metadata"` — reads `user.app_metadata.role`
65
+ * - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
33
66
  *
34
- * @example ["/admin/login"]
67
+ * @default "user_metadata"
35
68
  */
36
- publicRoutes?: string[];
69
+ roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
70
+ user_metadata: Record<string, unknown>;
71
+ app_metadata: Record<string, unknown>;
72
+ }) => string | string[] | undefined);
37
73
  /**
38
- * Optional callback invoked after session refresh,
39
- * before the redirect decision. Useful for custom logging or headers.
74
+ * Optional callback invoked when a user is authenticated and authorized.
75
+ * Useful for logging, analytics, or injecting custom response headers.
40
76
  */
41
77
  onAuthSuccess?: (user: {
42
78
  id: string;
43
79
  email?: string;
80
+ role?: string | string[];
44
81
  }) => void | Promise<void>;
45
82
  }
83
+ /** Options for the `routeWrapper` higher-order function. */
84
+ interface RouteWrapperOptions {
85
+ /**
86
+ * If `true`, the wrapper will initialise a Supabase server client,
87
+ * verify the session via `getUser()`, and reject with 401 if invalid.
88
+ * The `context.supabase` and `context.user` are guaranteed non-null.
89
+ *
90
+ * @default false
91
+ */
92
+ requireAuth?: boolean;
93
+ }
46
94
 
47
95
  /**
48
- * Create a Next.js middleware handler that protects routes based on
49
- * Supabase authentication state.
96
+ * Create a Next.js middleware handler that protects routes with
97
+ * authentication **and** optional role-based access control (RBAC).
50
98
  *
51
99
  * @example
52
100
  * ```ts
@@ -54,9 +102,13 @@ interface SupaAuthConfig {
54
102
  * import { withSupaAuth } from "next-supa-utils/server";
55
103
  *
56
104
  * export default withSupaAuth({
57
- * protectedRoutes: ["/dashboard", "/admin"],
105
+ * routes: [
106
+ * { path: "/dashboard" }, // any authed user
107
+ * { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
108
+ * { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
109
+ * ],
58
110
  * redirectTo: "/login",
59
- * publicRoutes: ["/admin/login"],
111
+ * roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
60
112
  * });
61
113
  *
62
114
  * export const config = {
@@ -64,7 +116,7 @@ interface SupaAuthConfig {
64
116
  * };
65
117
  * ```
66
118
  */
67
- declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) => Promise<NextResponse>;
119
+ declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
68
120
 
69
121
  /**
70
122
  * Create a type-safe Server Action that automatically:
@@ -96,4 +148,55 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
96
148
  */
97
149
  declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
98
150
 
99
- export { type ActionResponse, type SupaAuthConfig, type SupaError, createAction, withSupaAuth };
151
+ /**
152
+ * Higher-order function that wraps a Next.js App Router Route Handler
153
+ * with standardized error handling and optional authentication gating.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * // app/api/posts/route.ts
158
+ * import { routeWrapper } from "next-supa-utils/server";
159
+ *
160
+ * // Public endpoint (no auth required)
161
+ * export const GET = routeWrapper(async (request) => {
162
+ * const data = await fetchPosts();
163
+ * return NextResponse.json({ data });
164
+ * });
165
+ *
166
+ * // Protected endpoint (requires valid Supabase session)
167
+ * export const POST = routeWrapper(
168
+ * async (request, { supabase, user }) => {
169
+ * const body = await request.json();
170
+ * const { data, error } = await supabase
171
+ * .from("posts")
172
+ * .insert({ ...body, user_id: user.id })
173
+ * .select()
174
+ * .single();
175
+ *
176
+ * if (error) throw error;
177
+ * return NextResponse.json({ data }, { status: 201 });
178
+ * },
179
+ * { requireAuth: true },
180
+ * );
181
+ * ```
182
+ */
183
+ declare function routeWrapper<TContext extends Record<string, unknown> = Record<string, unknown>>(handler: (request: NextRequest, context: RouteHandlerContext<TContext>) => Promise<NextResponse | Response>, options?: RouteWrapperOptions): (request: NextRequest, routeContext?: {
184
+ params?: Promise<TContext>;
185
+ }) => Promise<NextResponse>;
186
+ /** Context object passed to the route handler by `routeWrapper`. */
187
+ interface RouteHandlerContext<TParams extends Record<string, unknown> = Record<string, unknown>> {
188
+ /** Resolved dynamic route params (e.g. `{ id: "123" }`). */
189
+ params: TParams;
190
+ /**
191
+ * Supabase server client.
192
+ * Always available (even on public routes) for convenience queries.
193
+ */
194
+ supabase: SupabaseClient;
195
+ /**
196
+ * The authenticated user, or `null` for public (non-auth) routes.
197
+ * Guaranteed non-null when `requireAuth: true`.
198
+ */
199
+ user: Awaited<ReturnType<SupabaseClient["auth"]["getUser"]>>["data"]["user"];
200
+ }
201
+
202
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type RouteHandlerContext, type RouteWrapperOptions, type SupaError, createAction, routeWrapper, withSupaAuth };
@@ -15,38 +15,86 @@ type ActionResponse<T> = {
15
15
  data: null;
16
16
  error: SupaError;
17
17
  };
18
- interface SupaAuthConfig {
18
+ /**
19
+ * Defines a protected route with optional role-based access control.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
24
+ * ```
25
+ */
26
+ interface RouteConfig {
27
+ /**
28
+ * Route pattern to match.
29
+ *
30
+ * Supports:
31
+ * - Exact paths: `"/settings"`
32
+ * - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
33
+ * - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
34
+ */
35
+ path: string;
19
36
  /**
20
- * Route prefixes that require an authenticated user.
21
- * Supports simple prefix matching.
37
+ * Roles that are allowed to access this route.
22
38
  *
23
- * @example ["/dashboard", "/admin", "/settings"]
39
+ * - If **omitted or empty**, any *authenticated* user can access the route.
40
+ * - If **provided**, only users whose role is in this list are allowed.
41
+ *
42
+ * @example ["admin", "editor"]
24
43
  */
25
- protectedRoutes: string[];
44
+ allowedRoles?: string[];
45
+ }
46
+ /**
47
+ * Configuration for the `withSupaAuth` middleware.
48
+ */
49
+ interface MiddlewareOptions {
26
50
  /**
27
- * Where to redirect unauthenticated users.
51
+ * Protected route definitions. Each entry can optionally restrict access
52
+ * to specific roles via `allowedRoles`.
53
+ */
54
+ routes: RouteConfig[];
55
+ /**
56
+ * Where to redirect unauthenticated or unauthorized users.
28
57
  * @default "/login"
29
58
  */
30
59
  redirectTo?: string;
31
60
  /**
32
- * Routes that are always public, even if they match a protected prefix.
61
+ * Strategy for extracting the user's role from the Supabase user object.
62
+ *
63
+ * - `"user_metadata"` — reads `user.user_metadata.role`
64
+ * - `"app_metadata"` — reads `user.app_metadata.role`
65
+ * - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
33
66
  *
34
- * @example ["/admin/login"]
67
+ * @default "user_metadata"
35
68
  */
36
- publicRoutes?: string[];
69
+ roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
70
+ user_metadata: Record<string, unknown>;
71
+ app_metadata: Record<string, unknown>;
72
+ }) => string | string[] | undefined);
37
73
  /**
38
- * Optional callback invoked after session refresh,
39
- * before the redirect decision. Useful for custom logging or headers.
74
+ * Optional callback invoked when a user is authenticated and authorized.
75
+ * Useful for logging, analytics, or injecting custom response headers.
40
76
  */
41
77
  onAuthSuccess?: (user: {
42
78
  id: string;
43
79
  email?: string;
80
+ role?: string | string[];
44
81
  }) => void | Promise<void>;
45
82
  }
83
+ /** Options for the `routeWrapper` higher-order function. */
84
+ interface RouteWrapperOptions {
85
+ /**
86
+ * If `true`, the wrapper will initialise a Supabase server client,
87
+ * verify the session via `getUser()`, and reject with 401 if invalid.
88
+ * The `context.supabase` and `context.user` are guaranteed non-null.
89
+ *
90
+ * @default false
91
+ */
92
+ requireAuth?: boolean;
93
+ }
46
94
 
47
95
  /**
48
- * Create a Next.js middleware handler that protects routes based on
49
- * Supabase authentication state.
96
+ * Create a Next.js middleware handler that protects routes with
97
+ * authentication **and** optional role-based access control (RBAC).
50
98
  *
51
99
  * @example
52
100
  * ```ts
@@ -54,9 +102,13 @@ interface SupaAuthConfig {
54
102
  * import { withSupaAuth } from "next-supa-utils/server";
55
103
  *
56
104
  * export default withSupaAuth({
57
- * protectedRoutes: ["/dashboard", "/admin"],
105
+ * routes: [
106
+ * { path: "/dashboard" }, // any authed user
107
+ * { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
108
+ * { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
109
+ * ],
58
110
  * redirectTo: "/login",
59
- * publicRoutes: ["/admin/login"],
111
+ * roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
60
112
  * });
61
113
  *
62
114
  * export const config = {
@@ -64,7 +116,7 @@ interface SupaAuthConfig {
64
116
  * };
65
117
  * ```
66
118
  */
67
- declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) => Promise<NextResponse>;
119
+ declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
68
120
 
69
121
  /**
70
122
  * Create a type-safe Server Action that automatically:
@@ -96,4 +148,55 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
96
148
  */
97
149
  declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
98
150
 
99
- export { type ActionResponse, type SupaAuthConfig, type SupaError, createAction, withSupaAuth };
151
+ /**
152
+ * Higher-order function that wraps a Next.js App Router Route Handler
153
+ * with standardized error handling and optional authentication gating.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * // app/api/posts/route.ts
158
+ * import { routeWrapper } from "next-supa-utils/server";
159
+ *
160
+ * // Public endpoint (no auth required)
161
+ * export const GET = routeWrapper(async (request) => {
162
+ * const data = await fetchPosts();
163
+ * return NextResponse.json({ data });
164
+ * });
165
+ *
166
+ * // Protected endpoint (requires valid Supabase session)
167
+ * export const POST = routeWrapper(
168
+ * async (request, { supabase, user }) => {
169
+ * const body = await request.json();
170
+ * const { data, error } = await supabase
171
+ * .from("posts")
172
+ * .insert({ ...body, user_id: user.id })
173
+ * .select()
174
+ * .single();
175
+ *
176
+ * if (error) throw error;
177
+ * return NextResponse.json({ data }, { status: 201 });
178
+ * },
179
+ * { requireAuth: true },
180
+ * );
181
+ * ```
182
+ */
183
+ declare function routeWrapper<TContext extends Record<string, unknown> = Record<string, unknown>>(handler: (request: NextRequest, context: RouteHandlerContext<TContext>) => Promise<NextResponse | Response>, options?: RouteWrapperOptions): (request: NextRequest, routeContext?: {
184
+ params?: Promise<TContext>;
185
+ }) => Promise<NextResponse>;
186
+ /** Context object passed to the route handler by `routeWrapper`. */
187
+ interface RouteHandlerContext<TParams extends Record<string, unknown> = Record<string, unknown>> {
188
+ /** Resolved dynamic route params (e.g. `{ id: "123" }`). */
189
+ params: TParams;
190
+ /**
191
+ * Supabase server client.
192
+ * Always available (even on public routes) for convenience queries.
193
+ */
194
+ supabase: SupabaseClient;
195
+ /**
196
+ * The authenticated user, or `null` for public (non-auth) routes.
197
+ * Guaranteed non-null when `requireAuth: true`.
198
+ */
199
+ user: Awaited<ReturnType<SupabaseClient["auth"]["getUser"]>>["data"]["user"];
200
+ }
201
+
202
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type RouteHandlerContext, type RouteWrapperOptions, type SupaError, createAction, routeWrapper, withSupaAuth };
@@ -1,13 +1,41 @@
1
1
  // src/server/middleware/withSupaAuth.ts
2
2
  import { createServerClient } from "@supabase/ssr";
3
3
  import { NextResponse } from "next/server";
4
- function withSupaAuth(config) {
4
+ function matchPath(pattern, pathname) {
5
+ if (pattern.includes(":path*")) {
6
+ const prefix = pattern.replace(/:path\*$/, "").replace(/\/$/, "");
7
+ return pathname === prefix || pathname.startsWith(prefix + "/");
8
+ }
9
+ return pathname === pattern || pathname.startsWith(pattern + "/");
10
+ }
11
+ function findMatchingRoute(routes, pathname) {
12
+ return routes.find((route) => matchPath(route.path, pathname));
13
+ }
14
+ function extractRole(user, extractor) {
15
+ if (typeof extractor === "function") {
16
+ return extractor(user);
17
+ }
18
+ const source = extractor === "app_metadata" ? user.app_metadata : user.user_metadata;
19
+ const raw = source?.role;
20
+ if (typeof raw === "string") return raw;
21
+ if (Array.isArray(raw) && raw.every((r) => typeof r === "string")) {
22
+ return raw;
23
+ }
24
+ return void 0;
25
+ }
26
+ function hasRequiredRole(userRole, allowedRoles) {
27
+ if (!allowedRoles || allowedRoles.length === 0) return true;
28
+ if (!userRole) return false;
29
+ const roles = Array.isArray(userRole) ? userRole : [userRole];
30
+ return roles.some((r) => allowedRoles.includes(r));
31
+ }
32
+ function withSupaAuth(options) {
5
33
  const {
6
- protectedRoutes,
34
+ routes,
7
35
  redirectTo = "/login",
8
- publicRoutes = [],
36
+ roleExtractor = "user_metadata",
9
37
  onAuthSuccess
10
- } = config;
38
+ } = options;
11
39
  return async function middleware(request) {
12
40
  let response = NextResponse.next({
13
41
  request: { headers: request.headers }
@@ -30,8 +58,8 @@ function withSupaAuth(config) {
30
58
  request.cookies.set(name, value);
31
59
  });
32
60
  response = NextResponse.next({ request });
33
- cookiesToSet.forEach(({ name, value, options }) => {
34
- response.cookies.set(name, value, options);
61
+ cookiesToSet.forEach(({ name, value, options: options2 }) => {
62
+ response.cookies.set(name, value, options2);
35
63
  });
36
64
  }
37
65
  }
@@ -40,22 +68,28 @@ function withSupaAuth(config) {
40
68
  data: { user }
41
69
  } = await supabase.auth.getUser();
42
70
  const { pathname } = request.nextUrl;
43
- const isPublicRoute = publicRoutes.some(
44
- (route) => pathname.startsWith(route)
45
- );
46
- if (isPublicRoute) {
71
+ const matchedRoute = findMatchingRoute(routes, pathname);
72
+ if (!matchedRoute) {
47
73
  return response;
48
74
  }
49
- const isProtectedRoute = protectedRoutes.some(
50
- (route) => pathname.startsWith(route)
51
- );
52
- if (isProtectedRoute && !user) {
75
+ if (!user) {
53
76
  const loginUrl = new URL(redirectTo, request.url);
54
77
  loginUrl.searchParams.set("next", pathname);
55
78
  return NextResponse.redirect(loginUrl);
56
79
  }
57
- if (user && onAuthSuccess) {
58
- await onAuthSuccess({ id: user.id, email: user.email ?? void 0 });
80
+ const userRole = extractRole(user, roleExtractor);
81
+ if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {
82
+ const forbiddenUrl = new URL(redirectTo, request.url);
83
+ forbiddenUrl.searchParams.set("error", "forbidden");
84
+ forbiddenUrl.searchParams.set("next", pathname);
85
+ return NextResponse.redirect(forbiddenUrl);
86
+ }
87
+ if (onAuthSuccess) {
88
+ await onAuthSuccess({
89
+ id: user.id,
90
+ email: user.email ?? void 0,
91
+ role: userRole
92
+ });
59
93
  }
60
94
  return response;
61
95
  };
@@ -128,8 +162,92 @@ function createAction(fn) {
128
162
  }
129
163
  };
130
164
  }
165
+
166
+ // src/server/actions/routeWrapper.ts
167
+ import { createServerClient as createServerClient3 } from "@supabase/ssr";
168
+ import { NextResponse as NextResponse2 } from "next/server";
169
+ import { cookies as cookies2 } from "next/headers";
170
+ function routeWrapper(handler, options) {
171
+ const { requireAuth = false } = options ?? {};
172
+ return async (request, routeContext) => {
173
+ try {
174
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
175
+ const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
176
+ if (!supabaseUrl || !supabaseAnonKey) {
177
+ return NextResponse2.json(
178
+ { error: { message: "Server configuration error", code: "CONFIG_ERROR" } },
179
+ { status: 500 }
180
+ );
181
+ }
182
+ const ctx = {
183
+ params: routeContext?.params ? await routeContext.params : {},
184
+ supabase: null,
185
+ user: null
186
+ };
187
+ if (requireAuth) {
188
+ const cookieStore = await cookies2();
189
+ const supabase = createServerClient3(supabaseUrl, supabaseAnonKey, {
190
+ cookies: {
191
+ getAll() {
192
+ return cookieStore.getAll();
193
+ },
194
+ setAll(cookiesToSet) {
195
+ try {
196
+ cookiesToSet.forEach(({ name, value, options: opts }) => {
197
+ cookieStore.set(name, value, opts);
198
+ });
199
+ } catch {
200
+ }
201
+ }
202
+ }
203
+ });
204
+ const {
205
+ data: { user },
206
+ error: authError
207
+ } = await supabase.auth.getUser();
208
+ if (authError || !user) {
209
+ return NextResponse2.json(
210
+ {
211
+ error: {
212
+ message: "Unauthorized: valid session required",
213
+ code: "UNAUTHORIZED"
214
+ }
215
+ },
216
+ { status: 401 }
217
+ );
218
+ }
219
+ ctx.supabase = supabase;
220
+ ctx.user = user;
221
+ } else {
222
+ const cookieStore = await cookies2();
223
+ ctx.supabase = createServerClient3(supabaseUrl, supabaseAnonKey, {
224
+ cookies: {
225
+ getAll() {
226
+ return cookieStore.getAll();
227
+ },
228
+ setAll(cookiesToSet) {
229
+ try {
230
+ cookiesToSet.forEach(({ name, value, options: opts }) => {
231
+ cookieStore.set(name, value, opts);
232
+ });
233
+ } catch {
234
+ }
235
+ }
236
+ }
237
+ });
238
+ }
239
+ const response = await handler(request, ctx);
240
+ return response instanceof NextResponse2 ? response : NextResponse2.json(await response.json(), { status: response.status });
241
+ } catch (caught) {
242
+ const error = handleSupaError(caught);
243
+ const status = error.status ?? 500;
244
+ return NextResponse2.json({ error }, { status });
245
+ }
246
+ };
247
+ }
131
248
  export {
132
249
  createAction,
250
+ routeWrapper,
133
251
  withSupaAuth
134
252
  };
135
253
  //# sourceMappingURL=index.js.map