next-supa-utils 0.1.0 → 0.1.4

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.
package/README.md CHANGED
@@ -16,47 +16,24 @@
16
16
 
17
17
  ---
18
18
 
19
- ## Why?
19
+ ## 🚀 Why Use This Over Raw `@supabase/ssr`?
20
20
 
21
- Every Next.js + Supabase project ends up with the same boilerplate:
21
+ If you use `@supabase/ssr` directly, you have to write boilerplate for *every* environment. **next-supa-utils** eliminates this entirely:
22
22
 
23
- - Creating Supabase clients with cookie handling for middleware, server components, and client components
24
- - Writing try/catch wrappers around every server action
25
- - Manually checking auth state in middleware and redirecting
23
+ 1. **One-Line Middleware**: No more manually copying the 40-line chunking/cookie-setting logic from the Supabase docs. Just pass your routes to `withSupaAuth()`.
24
+ 2. **Type-Safe Server Actions**: Stop writing `try/catch` and `cookies().getAll()` in every server action. `createAction()` handles it automatically and forces you to check for errors.
25
+ 3. **Instant Client Hooks**: `useSupaUser()` and `useSupaSession()` wrap `createBrowserClient`, fetch the initial state, *and* subscribe to real-time `onAuthStateChange` events out of the box.
26
+ 4. **App Router Ready**: Strictly separated entry points (`/client` and `/server`) guarantee you won't accidentally import server code into client components.
26
27
 
27
- **next-supa-utils** extracts these patterns into a single, type-safe library with separate entry points for client and server code — fully compatible with the Next.js App Router architecture.
28
-
29
- ## Features
30
-
31
- - 🔐 **`withSupaAuth`** — Drop-in middleware for route protection with redirect support
32
- - ⚡ **`createAction`** — Higher-order function that wraps server actions with automatic Supabase client creation and error handling
33
- - 👤 **`useSupaUser`** — React hook for real-time user state with auth change subscriptions
34
- - 🔑 **`useSupaSession`** — React hook for real-time session state
35
- - 🧩 **Separate entry points** — `next-supa-utils/client` and `next-supa-utils/server` to respect the `"use client"` boundary
36
- - 📦 **Dual format** — Ships ESM and CJS with full TypeScript declarations
37
-
38
- ## Installation
28
+ ## 📦 Installation
39
29
 
40
30
  ```bash
41
31
  npm install next-supa-utils
42
32
  ```
43
33
 
44
- ### Peer Dependencies
34
+ *Requires `react >=18`, `next >=14`, `@supabase/supabase-js ^2`, and `@supabase/ssr >=0.5`.*
45
35
 
46
- Make sure you have the following installed in your project:
47
-
48
- ```bash
49
- npm install react next @supabase/supabase-js @supabase/ssr
50
- ```
51
-
52
- | Package | Version |
53
- |---|---|
54
- | `react` | `>=18` |
55
- | `next` | `>=14` |
56
- | `@supabase/supabase-js` | `^2.0.0` |
57
- | `@supabase/ssr` | `>=0.5.0` |
58
-
59
- ## Environment Variables
36
+ ### Environment Variables
60
37
 
61
38
  Add these to your `.env.local`:
62
39
 
@@ -65,73 +42,61 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
65
42
  NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
66
43
  ```
67
44
 
68
- Both variables are required. All helpers in this library read from these environment variables automatically.
69
-
70
45
  ---
71
46
 
72
- ## Quick Start
47
+ ## Quick Start
73
48
 
74
- ### 1. Protect Routes with Middleware
49
+ ### 1. Middleware (Route Protection in 1 Line)
50
+
51
+ Protect your routes and auto-refresh sessions without copying boilerplate.
75
52
 
76
53
  ```ts
77
54
  // middleware.ts
78
55
  import { withSupaAuth } from "next-supa-utils/server";
79
56
 
80
57
  export default withSupaAuth({
81
- protectedRoutes: ["/dashboard", "/admin", "/settings"],
58
+ protectedRoutes: ["/dashboard", "/admin"],
82
59
  redirectTo: "/login",
83
- publicRoutes: ["/admin/login"],
84
60
  });
85
61
 
86
- export const config = {
87
- matcher: [
88
- "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
89
- ],
90
- };
62
+ export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
91
63
  ```
92
64
 
93
- ### 2. Create Type-Safe Server Actions
65
+ ### 2. Server Actions (No More Try/Catch)
66
+
67
+ Automatically initializes the server client with cookies and returns a type-safe `{ data, error }` object.
94
68
 
95
69
  ```ts
96
- // app/actions/profile.ts
70
+ // app/actions.ts
97
71
  "use server";
98
72
  import { createAction } from "next-supa-utils/server";
99
73
 
100
74
  export const getProfile = createAction(async (supabase, userId: string) => {
101
- const { data, error } = await supabase
102
- .from("profiles")
103
- .select("*")
104
- .eq("id", userId)
105
- .single();
106
-
107
- if (error) throw error;
75
+ const { data, error } = await supabase.from("profiles").select().eq("id", userId).single();
76
+ if (error) throw error; // Handled automatically!
108
77
  return data;
109
78
  });
110
79
  ```
111
80
 
112
81
  ```tsx
113
- // Usage in any component
114
- const result = await getProfile("user-uuid");
115
-
116
- if (result.error) {
117
- console.error(result.error.message);
118
- } else {
119
- console.log(result.data);
120
- }
82
+ // Usage
83
+ const { data, error } = await getProfile("123");
84
+ if (error) console.error(error.message);
121
85
  ```
122
86
 
123
- ### 3. Use Auth State in Client Components
87
+ ### 3. Client Components (Real-Time User State)
88
+
89
+ Get the user and listen to auth changes immediately.
124
90
 
125
91
  ```tsx
126
92
  "use client";
127
93
  import { useSupaUser } from "next-supa-utils/client";
128
94
 
129
95
  export default function Avatar() {
130
- const { user, loading, error } = useSupaUser();
96
+ const { user, loading } = useSupaUser();
131
97
 
132
- if (loading) return <p>Loading…</p>;
133
- if (error) return <p>Error: {error.message}</p>;
134
- if (!user) return <p>Not signed in</p>;
98
+ if (loading) return <p>Loading...</p>;
99
+ if (!user) return <p>Please sign in</p>;
135
100
 
136
101
  return <p>Hello, {user.email}!</p>;
137
102
  }
@@ -307,6 +272,31 @@ src/
307
272
  | `next-supa-utils/server` | Server Components, Middleware, Server Actions | `withSupaAuth`, `createAction` |
308
273
  | `next-supa-utils` | Anywhere | `handleSupaError`, all types |
309
274
 
275
+ ## Changelog
276
+
277
+ ### v0.1.3
278
+
279
+ - **🔐 RBAC Support** — `withSupaAuth` now supports **role-based access control**.
280
+ - New `RouteConfig` type: define `allowedRoles` per route (e.g. `["admin", "editor"]`).
281
+ - New `MiddlewareOptions` replaces the old `SupaAuthConfig`.
282
+ - New `roleExtractor` option: read roles from `user_metadata`, `app_metadata`, or a custom function.
283
+ - Wildcard path matching supported (e.g. `"/admin/:path*"`).
284
+ - Unauthorized users are redirected with `?error=forbidden&next=<path>`.
285
+ - **⚠️ Breaking:** `SupaAuthConfig` type removed. Use `MiddlewareOptions` with `routes: RouteConfig[]` instead.
286
+
287
+ ### v0.1.0
288
+
289
+ - 🎉 **Initial release**
290
+ - `withSupaAuth` — Middleware helper for route protection with session refresh.
291
+ - `createAction` — Type-safe server action wrapper with automatic `{ data, error }` responses.
292
+ - `useSupaUser` — React hook for real-time user state.
293
+ - `useSupaSession` — React hook for real-time session state.
294
+ - `handleSupaError` — Universal error normalizer.
295
+ - Multi-entry package: `next-supa-utils/client`, `next-supa-utils/server`.
296
+ - Dual format output (ESM + CJS) with TypeScript declarations.
297
+
298
+ ---
299
+
310
300
  ## Contributing
311
301
 
312
302
  Contributions are welcome! Please feel free to submit a Pull Request.
@@ -28,13 +28,41 @@ module.exports = __toCommonJS(server_exports);
28
28
  // src/server/middleware/withSupaAuth.ts
29
29
  var import_ssr = require("@supabase/ssr");
30
30
  var import_server = require("next/server");
31
- function withSupaAuth(config) {
31
+ function matchPath(pattern, pathname) {
32
+ if (pattern.includes(":path*")) {
33
+ const prefix = pattern.replace(/:path\*$/, "").replace(/\/$/, "");
34
+ return pathname === prefix || pathname.startsWith(prefix + "/");
35
+ }
36
+ return pathname === pattern || pathname.startsWith(pattern + "/");
37
+ }
38
+ function findMatchingRoute(routes, pathname) {
39
+ return routes.find((route) => matchPath(route.path, pathname));
40
+ }
41
+ function extractRole(user, extractor) {
42
+ if (typeof extractor === "function") {
43
+ return extractor(user);
44
+ }
45
+ const source = extractor === "app_metadata" ? user.app_metadata : user.user_metadata;
46
+ const raw = source?.role;
47
+ if (typeof raw === "string") return raw;
48
+ if (Array.isArray(raw) && raw.every((r) => typeof r === "string")) {
49
+ return raw;
50
+ }
51
+ return void 0;
52
+ }
53
+ function hasRequiredRole(userRole, allowedRoles) {
54
+ if (!allowedRoles || allowedRoles.length === 0) return true;
55
+ if (!userRole) return false;
56
+ const roles = Array.isArray(userRole) ? userRole : [userRole];
57
+ return roles.some((r) => allowedRoles.includes(r));
58
+ }
59
+ function withSupaAuth(options) {
32
60
  const {
33
- protectedRoutes,
61
+ routes,
34
62
  redirectTo = "/login",
35
- publicRoutes = [],
63
+ roleExtractor = "user_metadata",
36
64
  onAuthSuccess
37
- } = config;
65
+ } = options;
38
66
  return async function middleware(request) {
39
67
  let response = import_server.NextResponse.next({
40
68
  request: { headers: request.headers }
@@ -57,8 +85,8 @@ function withSupaAuth(config) {
57
85
  request.cookies.set(name, value);
58
86
  });
59
87
  response = import_server.NextResponse.next({ request });
60
- cookiesToSet.forEach(({ name, value, options }) => {
61
- response.cookies.set(name, value, options);
88
+ cookiesToSet.forEach(({ name, value, options: options2 }) => {
89
+ response.cookies.set(name, value, options2);
62
90
  });
63
91
  }
64
92
  }
@@ -67,22 +95,28 @@ function withSupaAuth(config) {
67
95
  data: { user }
68
96
  } = await supabase.auth.getUser();
69
97
  const { pathname } = request.nextUrl;
70
- const isPublicRoute = publicRoutes.some(
71
- (route) => pathname.startsWith(route)
72
- );
73
- if (isPublicRoute) {
98
+ const matchedRoute = findMatchingRoute(routes, pathname);
99
+ if (!matchedRoute) {
74
100
  return response;
75
101
  }
76
- const isProtectedRoute = protectedRoutes.some(
77
- (route) => pathname.startsWith(route)
78
- );
79
- if (isProtectedRoute && !user) {
102
+ if (!user) {
80
103
  const loginUrl = new URL(redirectTo, request.url);
81
104
  loginUrl.searchParams.set("next", pathname);
82
105
  return import_server.NextResponse.redirect(loginUrl);
83
106
  }
84
- if (user && onAuthSuccess) {
85
- await onAuthSuccess({ id: user.id, email: user.email ?? void 0 });
107
+ const userRole = extractRole(user, roleExtractor);
108
+ if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {
109
+ const forbiddenUrl = new URL(redirectTo, request.url);
110
+ forbiddenUrl.searchParams.set("error", "forbidden");
111
+ forbiddenUrl.searchParams.set("next", pathname);
112
+ return import_server.NextResponse.redirect(forbiddenUrl);
113
+ }
114
+ if (onAuthSuccess) {
115
+ await onAuthSuccess({
116
+ id: user.id,
117
+ email: user.email ?? void 0,
118
+ role: userRole
119
+ });
86
120
  }
87
121
  return response;
88
122
  };
@@ -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"],"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 { MiddlewareOptions, RouteConfig, 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"],"mappings":"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","names":["options","import_ssr"]}
@@ -15,38 +15,75 @@ 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.
38
+ *
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.
22
41
  *
23
- * @example ["/dashboard", "/admin", "/settings"]
42
+ * @example ["admin", "editor"]
43
+ */
44
+ allowedRoles?: string[];
45
+ }
46
+ /**
47
+ * Configuration for the `withSupaAuth` middleware.
48
+ */
49
+ interface MiddlewareOptions {
50
+ /**
51
+ * Protected route definitions. Each entry can optionally restrict access
52
+ * to specific roles via `allowedRoles`.
24
53
  */
25
- protectedRoutes: string[];
54
+ routes: RouteConfig[];
26
55
  /**
27
- * Where to redirect unauthenticated users.
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
  }
46
83
 
47
84
  /**
48
- * Create a Next.js middleware handler that protects routes based on
49
- * Supabase authentication state.
85
+ * Create a Next.js middleware handler that protects routes with
86
+ * authentication **and** optional role-based access control (RBAC).
50
87
  *
51
88
  * @example
52
89
  * ```ts
@@ -54,9 +91,13 @@ interface SupaAuthConfig {
54
91
  * import { withSupaAuth } from "next-supa-utils/server";
55
92
  *
56
93
  * export default withSupaAuth({
57
- * protectedRoutes: ["/dashboard", "/admin"],
94
+ * routes: [
95
+ * { path: "/dashboard" }, // any authed user
96
+ * { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
97
+ * { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
98
+ * ],
58
99
  * redirectTo: "/login",
59
- * publicRoutes: ["/admin/login"],
100
+ * roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
60
101
  * });
61
102
  *
62
103
  * export const config = {
@@ -64,7 +105,7 @@ interface SupaAuthConfig {
64
105
  * };
65
106
  * ```
66
107
  */
67
- declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) => Promise<NextResponse>;
108
+ declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
68
109
 
69
110
  /**
70
111
  * Create a type-safe Server Action that automatically:
@@ -96,4 +137,4 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
96
137
  */
97
138
  declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
98
139
 
99
- export { type ActionResponse, type SupaAuthConfig, type SupaError, createAction, withSupaAuth };
140
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, createAction, withSupaAuth };
@@ -15,38 +15,75 @@ 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.
38
+ *
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.
22
41
  *
23
- * @example ["/dashboard", "/admin", "/settings"]
42
+ * @example ["admin", "editor"]
43
+ */
44
+ allowedRoles?: string[];
45
+ }
46
+ /**
47
+ * Configuration for the `withSupaAuth` middleware.
48
+ */
49
+ interface MiddlewareOptions {
50
+ /**
51
+ * Protected route definitions. Each entry can optionally restrict access
52
+ * to specific roles via `allowedRoles`.
24
53
  */
25
- protectedRoutes: string[];
54
+ routes: RouteConfig[];
26
55
  /**
27
- * Where to redirect unauthenticated users.
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
  }
46
83
 
47
84
  /**
48
- * Create a Next.js middleware handler that protects routes based on
49
- * Supabase authentication state.
85
+ * Create a Next.js middleware handler that protects routes with
86
+ * authentication **and** optional role-based access control (RBAC).
50
87
  *
51
88
  * @example
52
89
  * ```ts
@@ -54,9 +91,13 @@ interface SupaAuthConfig {
54
91
  * import { withSupaAuth } from "next-supa-utils/server";
55
92
  *
56
93
  * export default withSupaAuth({
57
- * protectedRoutes: ["/dashboard", "/admin"],
94
+ * routes: [
95
+ * { path: "/dashboard" }, // any authed user
96
+ * { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
97
+ * { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
98
+ * ],
58
99
  * redirectTo: "/login",
59
- * publicRoutes: ["/admin/login"],
100
+ * roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
60
101
  * });
61
102
  *
62
103
  * export const config = {
@@ -64,7 +105,7 @@ interface SupaAuthConfig {
64
105
  * };
65
106
  * ```
66
107
  */
67
- declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) => Promise<NextResponse>;
108
+ declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
68
109
 
69
110
  /**
70
111
  * Create a type-safe Server Action that automatically:
@@ -96,4 +137,4 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
96
137
  */
97
138
  declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
98
139
 
99
- export { type ActionResponse, type SupaAuthConfig, type SupaError, createAction, withSupaAuth };
140
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, createAction, 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
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["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":["createServerClient","createServerClient"]}
1
+ {"version":3,"sources":["../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["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"],"mappings":"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","names":["options","createServerClient","createServerClient"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shared/index.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Shared entry point ──────────────────────────────────────────────\n// Re-exports types and utilities used across client & server modules.\n\nexport { handleSupaError } from \"./utils/error-handler\";\n\nexport type {\n SupaError,\n ActionResponse,\n SupaAuthConfig,\n UseSupaUserReturn,\n UseSupaSessionReturn,\n} from \"../types\";\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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,SAAS,gBAAgB,OAA2B;AAEzD,MAAI,iBAAiB,OAAO;AAC1B,UAAM,SAAS;AACf,WAAO;AAAA,MACL,SAAS,MAAM;AAAA,MACf,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,MACtD,QAAQ,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AAAA,IAC9D;AAAA,EACF;AAGA,MACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAAkC,YAAY,UACtD;AACA,UAAM,MAAM;AACZ,WAAO;AAAA,MACL,SAAS,IAAI;AAAA,MACb,MAAM,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAAA,MAChD,QAAQ,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS;AAAA,IACxD;AAAA,EACF;AAGA,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,EAAE,SAAS,MAAM;AAAA,EAC1B;AAGA,SAAO,EAAE,SAAS,4BAA4B;AAChD;","names":[]}
1
+ {"version":3,"sources":["../../src/shared/index.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Shared entry point ──────────────────────────────────────────────\n// Re-exports types and utilities used across client & server modules.\n\nexport { handleSupaError } from \"./utils/error-handler\";\n\nexport type {\n SupaError,\n ActionResponse,\n MiddlewareOptions,\n RouteConfig,\n UseSupaUserReturn,\n UseSupaSessionReturn,\n} from \"../types\";\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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,SAAS,gBAAgB,OAA2B;AAEzD,MAAI,iBAAiB,OAAO;AAC1B,UAAM,SAAS;AACf,WAAO;AAAA,MACL,SAAS,MAAM;AAAA,MACf,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,MACtD,QAAQ,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AAAA,IAC9D;AAAA,EACF;AAGA,MACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAAkC,YAAY,UACtD;AACA,UAAM,MAAM;AACZ,WAAO;AAAA,MACL,SAAS,IAAI;AAAA,MACb,MAAM,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAAA,MAChD,QAAQ,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS;AAAA,IACxD;AAAA,EACF;AAGA,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,EAAE,SAAS,MAAM;AAAA,EAC1B;AAGA,SAAO,EAAE,SAAS,4BAA4B;AAChD;","names":[]}
@@ -14,32 +14,69 @@ type ActionResponse<T> = {
14
14
  data: null;
15
15
  error: SupaError;
16
16
  };
17
- interface SupaAuthConfig {
17
+ /**
18
+ * Defines a protected route with optional role-based access control.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
23
+ * ```
24
+ */
25
+ interface RouteConfig {
18
26
  /**
19
- * Route prefixes that require an authenticated user.
20
- * Supports simple prefix matching.
27
+ * Route pattern to match.
21
28
  *
22
- * @example ["/dashboard", "/admin", "/settings"]
29
+ * Supports:
30
+ * - Exact paths: `"/settings"`
31
+ * - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
32
+ * - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
23
33
  */
24
- protectedRoutes: string[];
34
+ path: string;
25
35
  /**
26
- * Where to redirect unauthenticated users.
36
+ * Roles that are allowed to access this route.
37
+ *
38
+ * - If **omitted or empty**, any *authenticated* user can access the route.
39
+ * - If **provided**, only users whose role is in this list are allowed.
40
+ *
41
+ * @example ["admin", "editor"]
42
+ */
43
+ allowedRoles?: string[];
44
+ }
45
+ /**
46
+ * Configuration for the `withSupaAuth` middleware.
47
+ */
48
+ interface MiddlewareOptions {
49
+ /**
50
+ * Protected route definitions. Each entry can optionally restrict access
51
+ * to specific roles via `allowedRoles`.
52
+ */
53
+ routes: RouteConfig[];
54
+ /**
55
+ * Where to redirect unauthenticated or unauthorized users.
27
56
  * @default "/login"
28
57
  */
29
58
  redirectTo?: string;
30
59
  /**
31
- * Routes that are always public, even if they match a protected prefix.
60
+ * Strategy for extracting the user's role from the Supabase user object.
61
+ *
62
+ * - `"user_metadata"` — reads `user.user_metadata.role`
63
+ * - `"app_metadata"` — reads `user.app_metadata.role`
64
+ * - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
32
65
  *
33
- * @example ["/admin/login"]
66
+ * @default "user_metadata"
34
67
  */
35
- publicRoutes?: string[];
68
+ roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
69
+ user_metadata: Record<string, unknown>;
70
+ app_metadata: Record<string, unknown>;
71
+ }) => string | string[] | undefined);
36
72
  /**
37
- * Optional callback invoked after session refresh,
38
- * before the redirect decision. Useful for custom logging or headers.
73
+ * Optional callback invoked when a user is authenticated and authorized.
74
+ * Useful for logging, analytics, or injecting custom response headers.
39
75
  */
40
76
  onAuthSuccess?: (user: {
41
77
  id: string;
42
78
  email?: string;
79
+ role?: string | string[];
43
80
  }) => void | Promise<void>;
44
81
  }
45
82
  interface UseSupaUserReturn {
@@ -64,4 +101,4 @@ interface UseSupaSessionReturn {
64
101
  */
65
102
  declare function handleSupaError(error: unknown): SupaError;
66
103
 
67
- export { type ActionResponse, type SupaAuthConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
104
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
@@ -14,32 +14,69 @@ type ActionResponse<T> = {
14
14
  data: null;
15
15
  error: SupaError;
16
16
  };
17
- interface SupaAuthConfig {
17
+ /**
18
+ * Defines a protected route with optional role-based access control.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
23
+ * ```
24
+ */
25
+ interface RouteConfig {
18
26
  /**
19
- * Route prefixes that require an authenticated user.
20
- * Supports simple prefix matching.
27
+ * Route pattern to match.
21
28
  *
22
- * @example ["/dashboard", "/admin", "/settings"]
29
+ * Supports:
30
+ * - Exact paths: `"/settings"`
31
+ * - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
32
+ * - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
23
33
  */
24
- protectedRoutes: string[];
34
+ path: string;
25
35
  /**
26
- * Where to redirect unauthenticated users.
36
+ * Roles that are allowed to access this route.
37
+ *
38
+ * - If **omitted or empty**, any *authenticated* user can access the route.
39
+ * - If **provided**, only users whose role is in this list are allowed.
40
+ *
41
+ * @example ["admin", "editor"]
42
+ */
43
+ allowedRoles?: string[];
44
+ }
45
+ /**
46
+ * Configuration for the `withSupaAuth` middleware.
47
+ */
48
+ interface MiddlewareOptions {
49
+ /**
50
+ * Protected route definitions. Each entry can optionally restrict access
51
+ * to specific roles via `allowedRoles`.
52
+ */
53
+ routes: RouteConfig[];
54
+ /**
55
+ * Where to redirect unauthenticated or unauthorized users.
27
56
  * @default "/login"
28
57
  */
29
58
  redirectTo?: string;
30
59
  /**
31
- * Routes that are always public, even if they match a protected prefix.
60
+ * Strategy for extracting the user's role from the Supabase user object.
61
+ *
62
+ * - `"user_metadata"` — reads `user.user_metadata.role`
63
+ * - `"app_metadata"` — reads `user.app_metadata.role`
64
+ * - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
32
65
  *
33
- * @example ["/admin/login"]
66
+ * @default "user_metadata"
34
67
  */
35
- publicRoutes?: string[];
68
+ roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
69
+ user_metadata: Record<string, unknown>;
70
+ app_metadata: Record<string, unknown>;
71
+ }) => string | string[] | undefined);
36
72
  /**
37
- * Optional callback invoked after session refresh,
38
- * before the redirect decision. Useful for custom logging or headers.
73
+ * Optional callback invoked when a user is authenticated and authorized.
74
+ * Useful for logging, analytics, or injecting custom response headers.
39
75
  */
40
76
  onAuthSuccess?: (user: {
41
77
  id: string;
42
78
  email?: string;
79
+ role?: string | string[];
43
80
  }) => void | Promise<void>;
44
81
  }
45
82
  interface UseSupaUserReturn {
@@ -64,4 +101,4 @@ interface UseSupaSessionReturn {
64
101
  */
65
102
  declare function handleSupaError(error: unknown): SupaError;
66
103
 
67
- export { type ActionResponse, type SupaAuthConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
104
+ export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-supa-utils",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Eliminate Supabase boilerplate in Next.js App Router — hooks, middleware helpers, and server action wrappers.",
5
5
  "license": "MIT",
6
6
  "type": "module",