@chidchanun/bcp 0.1.16 → 0.1.18

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.
@@ -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
+ }
@@ -5,6 +5,7 @@ import {
5
5
  } from "node:url";
6
6
 
7
7
  import type {
8
+ MiddlewareHandler,
8
9
  MiddlewareModule,
9
10
  } from "./middleware.js";
10
11
 
@@ -93,16 +94,17 @@ export function assertMiddlewareModule(
93
94
  module: MiddlewareModule,
94
95
  filePath = "middleware"
95
96
  ): void {
96
- const handler =
97
+ const value =
97
98
  module.middleware ??
98
99
  module.default;
99
100
 
100
101
  if (
101
- typeof handler !==
102
- "function"
102
+ !isMiddlewareHandlerValue(
103
+ value
104
+ )
103
105
  ) {
104
106
  throw new Error(
105
- `BCP Framework: ${filePath} must export a middleware(request) function or a default middleware function.`
107
+ `BCP Framework: ${filePath} must export a middleware function, a middleware function array, or a default equivalent.`
106
108
  );
107
109
  }
108
110
 
@@ -125,12 +127,12 @@ export function assertMiddlewareModule(
125
127
  ];
126
128
 
127
129
  for (
128
- const value
130
+ const item
129
131
  of values
130
132
  ) {
131
133
  if (
132
- typeof value !== "string" &&
133
- !(value instanceof RegExp)
134
+ typeof item !== "string" &&
135
+ !(item instanceof RegExp)
134
136
  ) {
135
137
  throw new Error(
136
138
  `BCP Framework: middleware matcher in ${filePath} must be a string, RegExp, or an array of them.`
@@ -138,3 +140,28 @@ export function assertMiddlewareModule(
138
140
  }
139
141
  }
140
142
  }
143
+
144
+ function isMiddlewareHandlerValue(
145
+ value: unknown
146
+ ): value is
147
+ | MiddlewareHandler
148
+ | readonly MiddlewareHandler[] {
149
+ if (
150
+ typeof value ===
151
+ "function"
152
+ ) {
153
+ return true;
154
+ }
155
+
156
+ return (
157
+ Array.isArray(
158
+ value
159
+ ) &&
160
+ value.length > 0 &&
161
+ value.every(
162
+ (handler) =>
163
+ typeof handler ===
164
+ "function"
165
+ )
166
+ );
167
+ }