@chidchanun/bcp 0.1.16 → 0.1.17

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.
@@ -0,0 +1,216 @@
1
+ # Auth Route Guards
2
+
3
+ BCP Framework `0.1.17` integrates the Authentication Core with route guards through the server-only `bcp/auth` entrypoint.
4
+
5
+ ## Protect a route tree
6
+
7
+ Create `guard.ts` in the route directory you want to protect:
8
+
9
+ ```ts
10
+ import {
11
+ createAuthGuard,
12
+ } from "bcp/auth";
13
+
14
+ export const guard =
15
+ createAuthGuard({
16
+ redirectTo:
17
+ "/login",
18
+ });
19
+ ```
20
+
21
+ The guard redirects unauthenticated requests to `/login` with status `303` by default. A `303` is suitable for guards that may run before form actions because it converts a redirected mutation request into a normal GET request to the login page.
22
+
23
+ To return `401 Unauthorized` instead of redirecting:
24
+
25
+ ```ts
26
+ import {
27
+ createAuthGuard,
28
+ } from "bcp/auth";
29
+
30
+ export const guard =
31
+ createAuthGuard({
32
+ redirectTo:
33
+ null,
34
+ });
35
+ ```
36
+
37
+ ## Require a role
38
+
39
+ ```ts
40
+ import {
41
+ createRoleGuard,
42
+ } from "bcp/auth";
43
+
44
+ interface User {
45
+ id: number;
46
+ email: string;
47
+ role: string;
48
+ }
49
+
50
+ export const guard =
51
+ createRoleGuard<User>(
52
+ "admin",
53
+ {
54
+ redirectTo:
55
+ "/login",
56
+ }
57
+ );
58
+ ```
59
+
60
+ An authenticated user without the required role receives `403 Forbidden` by default.
61
+
62
+ You can redirect forbidden users instead:
63
+
64
+ ```ts
65
+ export const guard =
66
+ createRoleGuard<User>(
67
+ "admin",
68
+ {
69
+ forbiddenRedirectTo:
70
+ "/forbidden",
71
+ }
72
+ );
73
+ ```
74
+
75
+ ## Require one of several roles
76
+
77
+ The default role matching mode is `any`:
78
+
79
+ ```ts
80
+ export const guard =
81
+ createRoleGuard<User>([
82
+ "admin",
83
+ "manager",
84
+ ]);
85
+ ```
86
+
87
+ A user with either role is allowed.
88
+
89
+ ## Require all permissions
90
+
91
+ `requireRole()` and `createRoleGuard()` can read another user field and require every value:
92
+
93
+ ```ts
94
+ interface User {
95
+ id: number;
96
+ permissions: string[];
97
+ }
98
+
99
+ export const guard =
100
+ createRoleGuard<User>(
101
+ [
102
+ "users.read",
103
+ "users.write",
104
+ ],
105
+ {
106
+ roleField:
107
+ "permissions",
108
+ match:
109
+ "all",
110
+ }
111
+ );
112
+ ```
113
+
114
+ ## Inline guard logic
115
+
116
+ Use `requireAuth()` when a guard needs additional application-specific checks:
117
+
118
+ ```ts
119
+ import {
120
+ requireAuth,
121
+ } from "bcp/auth";
122
+
123
+ export async function guard() {
124
+ const result =
125
+ await requireAuth<AppUser>();
126
+
127
+ if (
128
+ result instanceof
129
+ Response
130
+ ) {
131
+ return result;
132
+ }
133
+
134
+ if (
135
+ !result.auth.user.active
136
+ ) {
137
+ return new Response(
138
+ "Forbidden",
139
+ {
140
+ status:
141
+ 403,
142
+ }
143
+ );
144
+ }
145
+
146
+ return result;
147
+ }
148
+ ```
149
+
150
+ ## Auth data in child guards and loaders
151
+
152
+ Successful auth helpers return guard data in this shape:
153
+
154
+ ```ts
155
+ {
156
+ auth: {
157
+ sid,
158
+ user,
159
+ data,
160
+ iat,
161
+ exp,
162
+ iss,
163
+ aud,
164
+ },
165
+ }
166
+ ```
167
+
168
+ Because BCP merges parent guard data into child guards and page loaders, descendants can read the authenticated session without verifying the cookie again.
169
+
170
+ Use `getGuardAuth()` for typed access:
171
+
172
+ ```ts
173
+ import {
174
+ getGuardAuth,
175
+ } from "bcp/auth";
176
+
177
+ export async function loader({
178
+ guardData,
179
+ }) {
180
+ const session =
181
+ getGuardAuth<AppUser>(
182
+ guardData
183
+ );
184
+
185
+ if (!session) {
186
+ throw new Error(
187
+ "Protected loader did not receive auth guard data."
188
+ );
189
+ }
190
+
191
+ return {
192
+ userId:
193
+ session.user.id,
194
+ };
195
+ }
196
+ ```
197
+
198
+ ## Low-level role checks
199
+
200
+ `requireRole()` is also available directly:
201
+
202
+ ```ts
203
+ import {
204
+ requireRole,
205
+ } from "bcp/auth";
206
+
207
+ export function guard() {
208
+ return requireRole<AppUser>(
209
+ "admin"
210
+ );
211
+ }
212
+ ```
213
+
214
+ ## Security boundary
215
+
216
+ `bcp/auth` is server-only. BCP blocks it from page/client graphs and maps its browser export to the server-only runtime guard. Authentication and authorization checks should stay in guards, loaders, actions, API routes, or other server modules.
@@ -0,0 +1,36 @@
1
+ # BCP Framework 0.1.17
2
+
3
+ ## Auth + Route Guard Integration
4
+
5
+ BCP Framework 0.1.17 connects the Authentication Core introduced in 0.1.16 with the existing scoped route guard system.
6
+
7
+ ### New `bcp/auth` guard helpers
8
+
9
+ - `requireAuth()` verifies the active BCP auth session and returns it as guard data.
10
+ - `requireRole()` verifies authentication plus one or more roles/permissions.
11
+ - `createAuthGuard()` creates a guard function that can be exported directly from `guard.ts`.
12
+ - `createRoleGuard()` creates a role-protected guard function.
13
+ - `getGuardAuth()` safely reads typed auth data from merged `guardData`.
14
+
15
+ Successful auth helpers return the session under `guardData.auth`, so child guards and page loaders can reuse the authenticated user without verifying the cookie again.
16
+
17
+ ### Unauthorized and forbidden behavior
18
+
19
+ - Unauthenticated requests redirect to `/login` with HTTP `303` by default.
20
+ - `redirectTo: null` returns `401 Unauthorized` instead.
21
+ - Authenticated users that fail a role requirement receive `403 Forbidden` by default.
22
+ - `forbiddenRedirectTo` can redirect insufficient-role users to a custom page.
23
+
24
+ ### Role matching
25
+
26
+ - A single role can be required with `requireRole("admin")`.
27
+ - Multiple roles default to `match: "any"`.
28
+ - `match: "all"` requires every requested value.
29
+ - `roleField` allows permission arrays or custom role fields such as `permissions` instead of the default `role` field.
30
+
31
+ ### Reliability
32
+
33
+ - Added unit coverage for authentication redirects, 401 mode, role allow/deny behavior, custom role fields and guard factories.
34
+ - Added page-guard pipeline integration coverage proving auth data flows from a parent auth guard into child guards.
35
+ - Added publish-surface regression coverage for the route guard helpers exposed through `bcp/auth`.
36
+ - Added dedicated Auth Route Guards documentation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,3 +12,18 @@ export {
12
12
  type AuthSession,
13
13
  type AuthUser,
14
14
  } from "../../server/src/auth.js";
15
+
16
+ export {
17
+ createAuthGuard,
18
+ createRoleGuard,
19
+ getGuardAuth,
20
+ requireAuth,
21
+ requireRole,
22
+
23
+ type AuthGuardData,
24
+ type AuthGuardFunction,
25
+ type AuthGuardResult,
26
+ type RequireAuthOptions,
27
+ type RequireRoleOptions,
28
+ type RequiredRole,
29
+ } from "../../server/src/auth-guard.js";
@@ -0,0 +1,403 @@
1
+ import {
2
+ auth,
3
+ type AuthOptions,
4
+ type AuthSession,
5
+ type AuthUser,
6
+ } from "./auth.js";
7
+ import {
8
+ redirect,
9
+ type RedirectStatus,
10
+ } from "./server-response.js";
11
+
12
+ export interface AuthGuardData<
13
+ TUser extends AuthUser = AuthUser,
14
+ TData extends object = Record<string, never>
15
+ > extends Record<string, unknown> {
16
+ auth: AuthSession<TUser, TData>;
17
+ }
18
+
19
+ export interface RequireAuthOptions
20
+ extends AuthOptions {
21
+ redirectTo?:
22
+ string |
23
+ URL |
24
+ null;
25
+ redirectStatus?: RedirectStatus;
26
+ }
27
+
28
+ export interface RequireRoleOptions
29
+ extends RequireAuthOptions {
30
+ roleField?: string;
31
+ match?: "any" | "all";
32
+ forbiddenRedirectTo?:
33
+ string |
34
+ URL |
35
+ null;
36
+ forbiddenRedirectStatus?: RedirectStatus;
37
+ }
38
+
39
+ export type RequiredRole =
40
+ string |
41
+ readonly string[];
42
+
43
+ export type AuthGuardResult<
44
+ TUser extends AuthUser = AuthUser,
45
+ TData extends object = Record<string, never>
46
+ > =
47
+ | AuthGuardData<TUser, TData>
48
+ | Response;
49
+
50
+ export type AuthGuardFunction<
51
+ TUser extends AuthUser = AuthUser,
52
+ TData extends object = Record<string, never>
53
+ > = () => Promise<
54
+ AuthGuardResult<
55
+ TUser,
56
+ TData
57
+ >
58
+ >;
59
+
60
+ export async function requireAuth<
61
+ TUser extends AuthUser = AuthUser,
62
+ TData extends object = Record<string, never>
63
+ >(
64
+ options: RequireAuthOptions = {}
65
+ ): Promise<AuthGuardResult<TUser, TData>> {
66
+ const {
67
+ redirectTo = "/login",
68
+ redirectStatus = 303,
69
+ ...authOptions
70
+ } = options;
71
+ const session =
72
+ await auth<
73
+ TUser,
74
+ TData
75
+ >(
76
+ authOptions
77
+ );
78
+
79
+ if (!session) {
80
+ if (
81
+ redirectTo ===
82
+ null
83
+ ) {
84
+ return new Response(
85
+ "Unauthorized",
86
+ {
87
+ status:
88
+ 401,
89
+ }
90
+ );
91
+ }
92
+
93
+ return redirect(
94
+ redirectTo,
95
+ redirectStatus
96
+ );
97
+ }
98
+
99
+ return {
100
+ auth:
101
+ session,
102
+ };
103
+ }
104
+
105
+ export async function requireRole<
106
+ TUser extends AuthUser = AuthUser,
107
+ TData extends object = Record<string, never>
108
+ >(
109
+ requiredRole: RequiredRole,
110
+ options: RequireRoleOptions = {}
111
+ ): Promise<AuthGuardResult<TUser, TData>> {
112
+ const roles =
113
+ normalizeRequiredRoles(
114
+ requiredRole
115
+ );
116
+ const {
117
+ roleField = "role",
118
+ match = "any",
119
+ forbiddenRedirectTo = null,
120
+ forbiddenRedirectStatus = 303,
121
+ ...authOptions
122
+ } = options;
123
+ const authenticated =
124
+ await requireAuth<
125
+ TUser,
126
+ TData
127
+ >(
128
+ authOptions
129
+ );
130
+
131
+ if (
132
+ authenticated instanceof
133
+ Response
134
+ ) {
135
+ return authenticated;
136
+ }
137
+
138
+ const assignedRoles =
139
+ readAssignedRoles(
140
+ authenticated.auth.user,
141
+ roleField
142
+ );
143
+ const allowed =
144
+ match === "all"
145
+ ? roles.every(
146
+ (role) =>
147
+ assignedRoles.includes(
148
+ role
149
+ )
150
+ )
151
+ : roles.some(
152
+ (role) =>
153
+ assignedRoles.includes(
154
+ role
155
+ )
156
+ );
157
+
158
+ if (!allowed) {
159
+ if (
160
+ forbiddenRedirectTo !==
161
+ null
162
+ ) {
163
+ return redirect(
164
+ forbiddenRedirectTo,
165
+ forbiddenRedirectStatus
166
+ );
167
+ }
168
+
169
+ return new Response(
170
+ "Forbidden",
171
+ {
172
+ status:
173
+ 403,
174
+ }
175
+ );
176
+ }
177
+
178
+ return authenticated;
179
+ }
180
+
181
+ export function createAuthGuard<
182
+ TUser extends AuthUser = AuthUser,
183
+ TData extends object = Record<string, never>
184
+ >(
185
+ options: RequireAuthOptions = {}
186
+ ): AuthGuardFunction<TUser, TData> {
187
+ return () =>
188
+ requireAuth<
189
+ TUser,
190
+ TData
191
+ >(
192
+ options
193
+ );
194
+ }
195
+
196
+ export function createRoleGuard<
197
+ TUser extends AuthUser = AuthUser,
198
+ TData extends object = Record<string, never>
199
+ >(
200
+ requiredRole: RequiredRole,
201
+ options: RequireRoleOptions = {}
202
+ ): AuthGuardFunction<TUser, TData> {
203
+ return () =>
204
+ requireRole<
205
+ TUser,
206
+ TData
207
+ >(
208
+ requiredRole,
209
+ options
210
+ );
211
+ }
212
+
213
+ export function getGuardAuth<
214
+ TUser extends AuthUser = AuthUser,
215
+ TData extends object = Record<string, never>
216
+ >(
217
+ guardData:
218
+ Readonly<{
219
+ auth?: unknown;
220
+ }>
221
+ ): AuthSession<TUser, TData> | null {
222
+ const value =
223
+ guardData.auth;
224
+
225
+ if (
226
+ !value ||
227
+ typeof value !==
228
+ "object" ||
229
+ Array.isArray(
230
+ value
231
+ )
232
+ ) {
233
+ return null;
234
+ }
235
+
236
+ const session =
237
+ value as Partial<
238
+ AuthSession<
239
+ TUser,
240
+ TData
241
+ >
242
+ >;
243
+
244
+ if (
245
+ typeof session.sid !==
246
+ "string" ||
247
+ session.sid.trim().length ===
248
+ 0 ||
249
+ typeof session.iat !==
250
+ "number" ||
251
+ !Number.isFinite(
252
+ session.iat
253
+ ) ||
254
+ typeof session.exp !==
255
+ "number" ||
256
+ !Number.isFinite(
257
+ session.exp
258
+ ) ||
259
+ !isGuardAuthUser(
260
+ session.user
261
+ )
262
+ ) {
263
+ return null;
264
+ }
265
+
266
+ return value as
267
+ AuthSession<
268
+ TUser,
269
+ TData
270
+ >;
271
+ }
272
+
273
+ function normalizeRequiredRoles(
274
+ value: RequiredRole
275
+ ): string[] {
276
+ const roles =
277
+ typeof value ===
278
+ "string"
279
+ ? [
280
+ value,
281
+ ]
282
+ : [
283
+ ...value,
284
+ ];
285
+ const normalized =
286
+ roles.map(
287
+ (role) =>
288
+ role.trim()
289
+ );
290
+
291
+ if (
292
+ normalized.length ===
293
+ 0 ||
294
+ normalized.some(
295
+ (role) =>
296
+ role.length ===
297
+ 0
298
+ )
299
+ ) {
300
+ throw new TypeError(
301
+ "BCP Auth Guard: required roles must contain non-empty strings."
302
+ );
303
+ }
304
+
305
+ return Array.from(
306
+ new Set(
307
+ normalized
308
+ )
309
+ );
310
+ }
311
+
312
+ function readAssignedRoles(
313
+ user: AuthUser,
314
+ roleField: string
315
+ ): string[] {
316
+ const field =
317
+ roleField.trim();
318
+
319
+ if (!field) {
320
+ throw new TypeError(
321
+ "BCP Auth Guard: roleField must be a non-empty string."
322
+ );
323
+ }
324
+
325
+ const value =
326
+ (
327
+ user as
328
+ unknown as
329
+ Record<string, unknown>
330
+ )[field];
331
+
332
+ if (
333
+ typeof value ===
334
+ "string"
335
+ ) {
336
+ const role =
337
+ value.trim();
338
+
339
+ return role
340
+ ? [
341
+ role,
342
+ ]
343
+ : [];
344
+ }
345
+
346
+ if (
347
+ Array.isArray(
348
+ value
349
+ )
350
+ ) {
351
+ return value
352
+ .filter(
353
+ (
354
+ role
355
+ ): role is string =>
356
+ typeof role ===
357
+ "string"
358
+ )
359
+ .map(
360
+ (role) =>
361
+ role.trim()
362
+ )
363
+ .filter(
364
+ Boolean
365
+ );
366
+ }
367
+
368
+ return [];
369
+ }
370
+
371
+ function isGuardAuthUser(
372
+ value: unknown
373
+ ): value is AuthUser {
374
+ if (
375
+ !value ||
376
+ typeof value !==
377
+ "object" ||
378
+ Array.isArray(
379
+ value
380
+ )
381
+ ) {
382
+ return false;
383
+ }
384
+
385
+ const id =
386
+ (
387
+ value as
388
+ Record<string, unknown>
389
+ ).id;
390
+
391
+ return (
392
+ typeof id ===
393
+ "string" &&
394
+ id.trim().length >
395
+ 0
396
+ ) || (
397
+ typeof id ===
398
+ "number" &&
399
+ Number.isFinite(
400
+ id
401
+ )
402
+ );
403
+ }