@mandujs/core 0.5.4 → 0.5.6

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,308 @@
1
+ /**
2
+ * Mandu Auth Guards - 인증/인가 헬퍼 🔐
3
+ *
4
+ * Guard에서 사용할 수 있는 타입-안전 인증 헬퍼
5
+ * 인증 실패 시 적절한 에러를 throw하여 Guard 체인 중단
6
+ */
7
+
8
+ import type { ManduContext } from "./context";
9
+
10
+ /**
11
+ * 인증 실패 에러 (401 Unauthorized)
12
+ */
13
+ export class AuthenticationError extends Error {
14
+ readonly statusCode = 401;
15
+
16
+ constructor(message: string = "Authentication required") {
17
+ super(message);
18
+ this.name = "AuthenticationError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 인가 실패 에러 (403 Forbidden)
24
+ */
25
+ export class AuthorizationError extends Error {
26
+ readonly statusCode = 403;
27
+ readonly requiredRoles?: string[];
28
+
29
+ constructor(message: string = "Access denied", requiredRoles?: string[]) {
30
+ super(message);
31
+ this.name = "AuthorizationError";
32
+ this.requiredRoles = requiredRoles;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 기본 User 인터페이스
38
+ * 프로젝트에서 확장하여 사용
39
+ */
40
+ export interface BaseUser {
41
+ id: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ /**
46
+ * Role을 가진 User 인터페이스
47
+ */
48
+ export interface UserWithRole extends BaseUser {
49
+ role: string;
50
+ }
51
+
52
+ /**
53
+ * Roles 배열을 가진 User 인터페이스
54
+ */
55
+ export interface UserWithRoles extends BaseUser {
56
+ roles: string[];
57
+ }
58
+
59
+ // ============================================
60
+ // 🔐 Auth Guard Helpers
61
+ // ============================================
62
+
63
+ /**
64
+ * 인증된 사용자 필수
65
+ * Guard에서 user가 없으면 AuthenticationError throw
66
+ *
67
+ * @param ctx ManduContext
68
+ * @param key store에서 user를 찾을 키 (기본: 'user')
69
+ * @returns 인증된 User (타입 확정)
70
+ * @throws AuthenticationError
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * import { requireUser } from '@mandujs/core'
75
+ *
76
+ * export default Mandu.filling()
77
+ * .guard(async (ctx) => {
78
+ * // JWT 토큰 검증 후 user 저장
79
+ * const user = await verifyToken(ctx.headers.get('Authorization'));
80
+ * ctx.set('user', user);
81
+ * return ctx.next();
82
+ * })
83
+ * .get((ctx) => {
84
+ * const user = requireUser(ctx); // User 타입 확정, 없으면 401
85
+ * return ctx.ok({ message: `Hello, ${user.id}!` });
86
+ * })
87
+ * ```
88
+ */
89
+ export function requireUser<T extends BaseUser = BaseUser>(
90
+ ctx: ManduContext,
91
+ key: string = "user"
92
+ ): T {
93
+ const user = ctx.get<T>(key);
94
+
95
+ if (!user) {
96
+ throw new AuthenticationError("User context is required");
97
+ }
98
+
99
+ if (typeof user !== "object" || !("id" in user)) {
100
+ throw new AuthenticationError("Invalid user context");
101
+ }
102
+
103
+ return user;
104
+ }
105
+
106
+ /**
107
+ * 특정 역할 필수 (단일 role 필드)
108
+ *
109
+ * @param ctx ManduContext
110
+ * @param roles 허용된 역할 목록
111
+ * @param key store에서 user를 찾을 키 (기본: 'user')
112
+ * @returns 인증된 User (타입 확정)
113
+ * @throws AuthenticationError (user 없음)
114
+ * @throws AuthorizationError (역할 불일치)
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * .guard((ctx) => {
119
+ * requireRole(ctx, 'admin', 'moderator'); // admin 또는 moderator만 허용
120
+ * return ctx.next();
121
+ * })
122
+ * ```
123
+ */
124
+ export function requireRole<T extends UserWithRole = UserWithRole>(
125
+ ctx: ManduContext,
126
+ ...roles: string[]
127
+ ): T {
128
+ const user = requireUser<T>(ctx);
129
+
130
+ if (!("role" in user) || typeof user.role !== "string") {
131
+ throw new AuthorizationError("User has no role defined");
132
+ }
133
+
134
+ if (!roles.includes(user.role)) {
135
+ throw new AuthorizationError(
136
+ `Required role: ${roles.join(" or ")}`,
137
+ roles
138
+ );
139
+ }
140
+
141
+ return user;
142
+ }
143
+
144
+ /**
145
+ * 특정 역할 중 하나 필수 (roles 배열 필드)
146
+ *
147
+ * @param ctx ManduContext
148
+ * @param roles 허용된 역할 목록 (하나라도 있으면 통과)
149
+ * @param key store에서 user를 찾을 키 (기본: 'user')
150
+ * @returns 인증된 User (타입 확정)
151
+ * @throws AuthenticationError (user 없음)
152
+ * @throws AuthorizationError (역할 불일치)
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * .guard((ctx) => {
157
+ * requireAnyRole(ctx, 'editor', 'admin'); // editor 또는 admin 역할 필요
158
+ * return ctx.next();
159
+ * })
160
+ * ```
161
+ */
162
+ export function requireAnyRole<T extends UserWithRoles = UserWithRoles>(
163
+ ctx: ManduContext,
164
+ ...roles: string[]
165
+ ): T {
166
+ const user = requireUser<T>(ctx);
167
+
168
+ if (!("roles" in user) || !Array.isArray(user.roles)) {
169
+ throw new AuthorizationError("User has no roles defined");
170
+ }
171
+
172
+ const hasRole = roles.some((role) => user.roles.includes(role));
173
+
174
+ if (!hasRole) {
175
+ throw new AuthorizationError(
176
+ `Required one of roles: ${roles.join(", ")}`,
177
+ roles
178
+ );
179
+ }
180
+
181
+ return user;
182
+ }
183
+
184
+ /**
185
+ * 모든 역할 필수 (roles 배열 필드)
186
+ *
187
+ * @param ctx ManduContext
188
+ * @param roles 필요한 역할 목록 (모두 있어야 통과)
189
+ * @returns 인증된 User (타입 확정)
190
+ * @throws AuthenticationError (user 없음)
191
+ * @throws AuthorizationError (역할 불일치)
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * .guard((ctx) => {
196
+ * requireAllRoles(ctx, 'verified', 'premium'); // verified AND premium 필요
197
+ * return ctx.next();
198
+ * })
199
+ * ```
200
+ */
201
+ export function requireAllRoles<T extends UserWithRoles = UserWithRoles>(
202
+ ctx: ManduContext,
203
+ ...roles: string[]
204
+ ): T {
205
+ const user = requireUser<T>(ctx);
206
+
207
+ if (!("roles" in user) || !Array.isArray(user.roles)) {
208
+ throw new AuthorizationError("User has no roles defined");
209
+ }
210
+
211
+ const missingRoles = roles.filter((role) => !user.roles.includes(role));
212
+
213
+ if (missingRoles.length > 0) {
214
+ throw new AuthorizationError(
215
+ `Missing required roles: ${missingRoles.join(", ")}`,
216
+ roles
217
+ );
218
+ }
219
+
220
+ return user;
221
+ }
222
+
223
+ // ============================================
224
+ // 🔐 Auth Guard Factory
225
+ // ============================================
226
+
227
+ /**
228
+ * 인증 Guard 생성 팩토리
229
+ * 반복되는 인증 로직을 Guard로 변환
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const authGuard = createAuthGuard(async (ctx) => {
234
+ * const token = ctx.headers.get('Authorization')?.replace('Bearer ', '');
235
+ * if (!token) return null;
236
+ * return await verifyJwt(token);
237
+ * });
238
+ *
239
+ * export default Mandu.filling()
240
+ * .guard(authGuard)
241
+ * .get((ctx) => {
242
+ * const user = requireUser(ctx);
243
+ * return ctx.ok({ user });
244
+ * })
245
+ * ```
246
+ */
247
+ export function createAuthGuard<T extends BaseUser>(
248
+ authenticator: (ctx: ManduContext) => T | null | Promise<T | null>,
249
+ options: {
250
+ key?: string;
251
+ onUnauthenticated?: (ctx: ManduContext) => Response;
252
+ } = {}
253
+ ) {
254
+ const { key = "user", onUnauthenticated } = options;
255
+
256
+ return async (ctx: ManduContext): Promise<symbol | Response> => {
257
+ try {
258
+ const user = await authenticator(ctx);
259
+
260
+ if (user) {
261
+ ctx.set(key, user);
262
+ return ctx.next();
263
+ }
264
+
265
+ if (onUnauthenticated) {
266
+ return onUnauthenticated(ctx);
267
+ }
268
+
269
+ return ctx.unauthorized("Authentication required");
270
+ } catch (error) {
271
+ if (error instanceof AuthenticationError) {
272
+ return ctx.unauthorized(error.message);
273
+ }
274
+ throw error;
275
+ }
276
+ };
277
+ }
278
+
279
+ /**
280
+ * 역할 기반 Guard 생성 팩토리
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * const adminOnly = createRoleGuard('admin');
285
+ * const editorOrAdmin = createRoleGuard('editor', 'admin');
286
+ *
287
+ * export default Mandu.filling()
288
+ * .guard(authGuard)
289
+ * .guard(adminOnly) // admin만 접근 가능
290
+ * .delete((ctx) => ctx.noContent())
291
+ * ```
292
+ */
293
+ export function createRoleGuard(...allowedRoles: string[]) {
294
+ return (ctx: ManduContext): symbol | Response => {
295
+ try {
296
+ requireRole(ctx, ...allowedRoles);
297
+ return ctx.next();
298
+ } catch (error) {
299
+ if (error instanceof AuthenticationError) {
300
+ return ctx.unauthorized(error.message);
301
+ }
302
+ if (error instanceof AuthorizationError) {
303
+ return ctx.forbidden(error.message);
304
+ }
305
+ throw error;
306
+ }
307
+ };
308
+ }
@@ -49,7 +49,13 @@ export class CookieManager {
49
49
  for (const pair of pairs) {
50
50
  const [name, ...rest] = pair.trim().split("=");
51
51
  if (name) {
52
- cookies.set(name, decodeURIComponent(rest.join("=")));
52
+ const rawValue = rest.join("=");
53
+ try {
54
+ cookies.set(name, decodeURIComponent(rawValue));
55
+ } catch {
56
+ // 잘못된 URL 인코딩 시 원본 값 사용
57
+ cookies.set(name, rawValue);
58
+ }
53
59
  }
54
60
  }
55
61
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { ManduContext, NEXT_SYMBOL, ValidationError } from "./context";
7
+ import { AuthenticationError, AuthorizationError } from "./auth";
7
8
  import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
8
9
  import { createContract, type ContractDefinition, type ContractInstance } from "../contract";
9
10
 
@@ -19,6 +20,22 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" |
19
20
  /** Loader function type - SSR 데이터 로딩 */
20
21
  export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
21
22
 
23
+ /** Loader 실행 옵션 */
24
+ export interface LoaderOptions<T = unknown> {
25
+ /** 타임아웃 (ms), 기본값 5000 */
26
+ timeout?: number;
27
+ /** 타임아웃 또는 에러 시 반환할 fallback 데이터 */
28
+ fallback?: T;
29
+ }
30
+
31
+ /** Loader 타임아웃 에러 */
32
+ export class LoaderTimeoutError extends Error {
33
+ constructor(timeout: number) {
34
+ super(`Loader timed out after ${timeout}ms`);
35
+ this.name = "LoaderTimeoutError";
36
+ }
37
+ }
38
+
22
39
  interface FillingConfig<TLoaderData = unknown> {
23
40
  handlers: Map<HttpMethod, Handler>;
24
41
  guards: Guard[];
@@ -78,12 +95,37 @@ export class ManduFilling<TLoaderData = unknown> {
78
95
  /**
79
96
  * Execute loader and return data
80
97
  * @internal Used by SSR runtime
98
+ * @param ctx ManduContext
99
+ * @param options Loader 실행 옵션 (timeout, fallback)
81
100
  */
82
- async executeLoader(ctx: ManduContext): Promise<TLoaderData | undefined> {
101
+ async executeLoader(
102
+ ctx: ManduContext,
103
+ options: LoaderOptions<TLoaderData> = {}
104
+ ): Promise<TLoaderData | undefined> {
83
105
  if (!this.config.loader) {
84
106
  return undefined;
85
107
  }
86
- return await this.config.loader(ctx);
108
+
109
+ const { timeout = 5000, fallback } = options;
110
+
111
+ try {
112
+ const loaderPromise = Promise.resolve(this.config.loader(ctx));
113
+
114
+ const timeoutPromise = new Promise<never>((_, reject) => {
115
+ setTimeout(() => reject(new LoaderTimeoutError(timeout)), timeout);
116
+ });
117
+
118
+ return await Promise.race([loaderPromise, timeoutPromise]);
119
+ } catch (error) {
120
+ if (fallback !== undefined) {
121
+ console.warn(
122
+ `[Mandu] Loader failed, using fallback:`,
123
+ error instanceof Error ? error.message : String(error)
124
+ );
125
+ return fallback;
126
+ }
127
+ throw error;
128
+ }
87
129
  }
88
130
 
89
131
  /**
@@ -203,7 +245,11 @@ export class ManduFilling<TLoaderData = unknown> {
203
245
  return result as Response;
204
246
  }
205
247
  if (!ctx.shouldContinue) {
206
- return ctx.getResponse()!;
248
+ const response = ctx.getResponse();
249
+ if (!response) {
250
+ throw new Error("Guard set shouldContinue=false but no response was provided");
251
+ }
252
+ return response;
207
253
  }
208
254
  }
209
255
 
@@ -215,7 +261,11 @@ export class ManduFilling<TLoaderData = unknown> {
215
261
  return result as Response;
216
262
  }
217
263
  if (!ctx.shouldContinue) {
218
- return ctx.getResponse()!;
264
+ const response = ctx.getResponse();
265
+ if (!response) {
266
+ throw new Error("Guard set shouldContinue=false but no response was provided");
267
+ }
268
+ return response;
219
269
  }
220
270
  }
221
271
 
@@ -235,6 +285,35 @@ export class ManduFilling<TLoaderData = unknown> {
235
285
  // Execute handler
236
286
  return await handler(ctx);
237
287
  } catch (error) {
288
+ // Handle authentication errors
289
+ if (error instanceof AuthenticationError) {
290
+ return ctx.json(
291
+ {
292
+ errorType: "AUTH_ERROR",
293
+ code: "AUTHENTICATION_REQUIRED",
294
+ message: error.message,
295
+ summary: "인증 필요 - 로그인 후 다시 시도하세요",
296
+ timestamp: new Date().toISOString(),
297
+ },
298
+ 401
299
+ );
300
+ }
301
+
302
+ // Handle authorization errors
303
+ if (error instanceof AuthorizationError) {
304
+ return ctx.json(
305
+ {
306
+ errorType: "AUTH_ERROR",
307
+ code: "ACCESS_DENIED",
308
+ message: error.message,
309
+ summary: "권한 없음 - 접근 권한이 부족합니다",
310
+ requiredRoles: error.requiredRoles,
311
+ timestamp: new Date().toISOString(),
312
+ },
313
+ 403
314
+ );
315
+ }
316
+
238
317
  // Handle validation errors with enhanced error format
239
318
  if (error instanceof ValidationError) {
240
319
  return ctx.json(
@@ -4,5 +4,18 @@
4
4
 
5
5
  export { ManduContext, NEXT_SYMBOL, ValidationError, CookieManager } from "./context";
6
6
  export type { CookieOptions } from "./context";
7
- export { ManduFilling, Mandu } from "./filling";
8
- export type { Handler, Guard, HttpMethod } from "./filling";
7
+ export { ManduFilling, Mandu, LoaderTimeoutError } from "./filling";
8
+ export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
9
+
10
+ // Auth Guards
11
+ export {
12
+ AuthenticationError,
13
+ AuthorizationError,
14
+ requireUser,
15
+ requireRole,
16
+ requireAnyRole,
17
+ requireAllRoles,
18
+ createAuthGuard,
19
+ createRoleGuard,
20
+ } from "./auth";
21
+ export type { BaseUser, UserWithRole, UserWithRoles } from "./auth";
@@ -20,6 +20,8 @@ export interface GenerateResult {
20
20
  deleted: string[];
21
21
  skipped: string[];
22
22
  errors: string[];
23
+ /** 삭제 실패 등 치명적이지 않은 경고 */
24
+ warnings: string[];
23
25
  }
24
26
 
25
27
  /**
@@ -130,6 +132,7 @@ export async function generateRoutes(
130
132
  deleted: [],
131
133
  skipped: [],
132
134
  errors: [],
135
+ warnings: [],
133
136
  };
134
137
 
135
138
  const serverRoutesDir = path.join(rootDir, "apps/server/generated/routes");
@@ -289,8 +292,14 @@ export async function generateRoutes(
289
292
  for (const file of existingServerFiles) {
290
293
  if (!expectedServerFiles.has(file)) {
291
294
  const filePath = path.join(serverRoutesDir, file);
292
- await fs.unlink(filePath);
293
- result.deleted.push(filePath);
295
+ try {
296
+ await fs.unlink(filePath);
297
+ result.deleted.push(filePath);
298
+ } catch (error) {
299
+ result.warnings.push(
300
+ `Failed to delete ${filePath}: ${error instanceof Error ? error.message : String(error)}`
301
+ );
302
+ }
294
303
  }
295
304
  }
296
305
 
@@ -298,8 +307,14 @@ export async function generateRoutes(
298
307
  for (const file of existingWebFiles) {
299
308
  if (!expectedWebFiles.has(file)) {
300
309
  const filePath = path.join(webRoutesDir, file);
301
- await fs.unlink(filePath);
302
- result.deleted.push(filePath);
310
+ try {
311
+ await fs.unlink(filePath);
312
+ result.deleted.push(filePath);
313
+ } catch (error) {
314
+ result.warnings.push(
315
+ `Failed to delete ${filePath}: ${error instanceof Error ? error.message : String(error)}`
316
+ );
317
+ }
303
318
  }
304
319
  }
305
320
 
@@ -308,8 +323,14 @@ export async function generateRoutes(
308
323
  for (const file of existingTypeFiles) {
309
324
  if (!expectedTypeFiles.has(file) && file !== "index.ts") {
310
325
  const filePath = path.join(typesDir, file);
311
- await fs.unlink(filePath);
312
- result.deleted.push(filePath);
326
+ try {
327
+ await fs.unlink(filePath);
328
+ result.deleted.push(filePath);
329
+ } catch (error) {
330
+ result.warnings.push(
331
+ `Failed to delete ${filePath}: ${error instanceof Error ? error.message : String(error)}`
332
+ );
333
+ }
313
334
  }
314
335
  }
315
336
 
@@ -1,3 +1,3 @@
1
- export * from "./generate";
2
- export * from "./templates";
3
- export * from "./contract-glue";
1
+ export * from "./generate";
2
+ export * from "./templates";
3
+ export * from "./contract-glue";
@@ -1 +1 @@
1
- export * from "./build";
1
+ export * from "./build";
@@ -1,5 +1,5 @@
1
- export * from "./ssr";
2
- export * from "./router";
3
- export * from "./server";
4
- export * from "./cors";
5
- export * from "./env";
1
+ export * from "./ssr";
2
+ export * from "./router";
3
+ export * from "./server";
4
+ export * from "./cors";
5
+ export * from "./env";