@iyulab/enterprise 0.3.0 → 0.4.0

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
@@ -17,6 +17,10 @@ npm install @iyulab/enterprise
17
17
  | `FormSection` | React | 제목 + 세로 스택 폼 섹션 |
18
18
  | `FormRow` | React | 2컬럼 그리드 폼 행(`full`로 1컬럼) |
19
19
  | `ApiConfig` | class | baseUrl/OData·API prefix·dev 판별 중앙 설정 |
20
+ | `createODataService` | factory | OData v4 + custom REST CRUD 서비스(401·토스트·에러파싱) |
21
+ | `ApiError` | class | HTTP status 를 실은 API 호출 실패 에러 |
22
+ | `createAuthClient` | factory | 쿠키 세션 인증(fetchMe/login/logout) — 제네릭 user/자격증명 |
23
+ | `createPermissionStore` · `hasPermission` 외 | store | 권한 스냅샷 store + 판정 free 함수 |
20
24
  | `CurrencyHelper` | class | 통화 포맷(`formatKRW` 등) |
21
25
  | `DateHelper` | class | 날짜 포맷/파싱 |
22
26
  | `ProgressHelper` | class | 진행률 계산 |
@@ -62,6 +66,71 @@ ApiConfig.getUrlWithParams('report', { year: 2026, active: true })
62
66
  ApiConfig.isDevelopment // 환경 판별
63
67
  ```
64
68
 
69
+ ### OData 서비스 (`createODataService`)
70
+
71
+ OData v4 + custom REST CRUD 를 한 번에 구성한다. 401 세션 처리·에러 메시지 추출·성공 토스트·204 빈 바디 안전 파싱이 내장돼 있고, **도메인/로케일 요소는 전부 주입**으로 앱 adapter 에 남긴다.
72
+
73
+ ```typescript
74
+ import { createODataService } from '@iyulab/enterprise'
75
+ import { app } from '@iyulab/modern-app'
76
+
77
+ // 앱 adapter (예: src/lib/odata.ts) — 라이브러리는 이 배선만 받는다.
78
+ export const svc = createODataService({
79
+ baseUrl: window.location.origin,
80
+ onUnauthorized: () => { window.location.href = '/' }, // 세션 만료 리다이렉트는 앱이 결정
81
+ notify: { success: (m) => app.success(m), error: (m) => app.error(m) },
82
+ messages: { // 기본은 영어 — 로케일 오버라이드
83
+ saved: '저장되었습니다', updated: '수정되었습니다', deleted: '삭제되었습니다',
84
+ sessionExpired: '세션이 만료되었습니다. 다시 로그인하세요.',
85
+ },
86
+ })
87
+
88
+ await svc.odataGet<Order>('Orders', { $top: '20' }) // value 배열 언랩
89
+ await svc.odataPost<Order>('Orders', { name: 'A', note: '' }) // '' → null 정규화 + 성공 토스트
90
+ await svc.apiDelete('orders/7') // 204 안전
91
+ svc.odataUrl('Orders') // flex-table useODataSource 엔드포인트
92
+ svc.sourceDefaults // { baseUrl, onUnauthorized } 주입용
93
+ ```
94
+
95
+ 주입 항목:
96
+
97
+ | config | 용도 |
98
+ |--------|------|
99
+ | `baseUrl` | 모든 요청의 오리진 (필수) |
100
+ | `odataPrefix` / `apiPrefix` | 엔드포인트 prefix (기본 `$data` / `api`) |
101
+ | `onUnauthorized(status)` | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
102
+ | `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수) |
103
+ | `messages` | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
104
+ | `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) |
105
+
106
+ > 도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.
107
+
108
+ ### 인증 + 권한 (`createAuthClient` · 권한 store)
109
+
110
+ 쿠키 세션 인증 흐름과 권한 스냅샷을 승격. 사용자·자격증명 **형태는 앱이 제네릭으로 정의**하고, 권한 코드는 불투명 문자열로만 다룬다. `getPermissions` 를 주면 로그인/세션 조회 성공 시 권한 store 가 자동 갱신된다.
111
+
112
+ ```typescript
113
+ import { createAuthClient, hasPermission, setPermissions } from '@iyulab/enterprise'
114
+
115
+ interface User { Id: string; Permissions: string[] } // 도메인 형태 = 앱 소유
116
+
117
+ export const auth = createAuthClient<User, { Username: string; Password: string }>({
118
+ meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout',
119
+ getPermissions: (u) => u.Permissions, // 성공 시 권한 store 자동 set
120
+ messages: { invalidCredentials: '사용자명 또는 비밀번호가 올바르지 않습니다.' },
121
+ })
122
+
123
+ // 부팅 게이트
124
+ const user = await auth.fetchMe() // null → 미인증(로그인 화면)
125
+
126
+ // 어디서나 권한 판정(부팅 스냅샷)
127
+ if (hasPermission('orders.write')) { /* 저장 버튼 노출 */ }
128
+ ```
129
+
130
+ - `fetchMe()` 는 401/네트워크 오류 시 `null` — 이 신호가 로그인 게이트를 구동한다(라이브러리가 리다이렉트하지 않음).
131
+ - 격리가 필요하면 `createPermissionStore()` 로 별도 store 를 만들어 `permissionStore` 로 주입한다.
132
+ - 도메인 판정(`isPortalUser` 등)·권한 코드 상수는 라이브러리가 아니라 앱 adapter 에 둔다.
133
+
65
134
  ### 도메인 헬퍼
66
135
 
67
136
  ```typescript
@@ -0,0 +1,46 @@
1
+ import { PermissionStore } from './permissions';
2
+ export interface AuthClientMessages {
3
+ /** 401 로그인 실패 */
4
+ invalidCredentials: string;
5
+ /** 기타 로그인 실패(non-401) 폴백 */
6
+ loginFailed: string;
7
+ /** 네트워크/예외 */
8
+ networkError: string;
9
+ }
10
+ export interface LoginResult<TUser> {
11
+ ok: boolean;
12
+ user?: TUser;
13
+ message?: string;
14
+ }
15
+ export interface AuthClientConfig<TUser> {
16
+ /** 현재 세션 조회 URL (GET) */
17
+ meUrl: string;
18
+ /** 로그인 URL (POST, 자격증명을 JSON 바디로) */
19
+ loginUrl: string;
20
+ /** 로그아웃 URL (POST) */
21
+ logoutUrl: string;
22
+ /** 상대 URL 앞에 붙일 오리진(기본 '' = same-origin). */
23
+ baseUrl?: string;
24
+ /** fetch credentials 모드(기본 'same-origin' — 쿠키 세션). */
25
+ credentials?: RequestCredentials;
26
+ /** 사용자 대면 문구(로케일). 기본 영어. */
27
+ messages?: Partial<AuthClientMessages>;
28
+ /** 로그인 실패(non-401) 응답 바디에서 서버 메시지 추출. 기본: `body.Message ?? body.message`. */
29
+ extractLoginError?: (body: unknown) => string | undefined;
30
+ /**
31
+ * user 에서 권한 코드 배열을 추출. 지정하면 `fetchMe`/`login` 성공 시 권한 store 를 자동 갱신,
32
+ * `fetchMe`→null / `logout` 시 자동 clear.
33
+ */
34
+ getPermissions?: (user: TUser) => string[];
35
+ /** 권한 자동 갱신 대상 store(기본: `defaultPermissionStore`). */
36
+ permissionStore?: PermissionStore;
37
+ }
38
+ export interface AuthClient<TUser, TCredentials> {
39
+ /** 현재 세션 조회. 미인증/세션 만료 시 `null`. 성공 시 권한 store 자동 갱신(getPermissions 지정 시). */
40
+ fetchMe(): Promise<TUser | null>;
41
+ /** 로그인. 성공 시 권한 store 자동 갱신(getPermissions 지정 시). */
42
+ login(credentials: TCredentials): Promise<LoginResult<TUser>>;
43
+ /** 로그아웃. 권한 store 자동 clear(getPermissions 지정 시). */
44
+ logout(): Promise<void>;
45
+ }
46
+ export declare function createAuthClient<TUser, TCredentials = Record<string, unknown>>(config: AuthClientConfig<TUser>): AuthClient<TUser, TCredentials>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 권한 스냅샷 store — 부팅 시점의 현재 사용자 권한 코드 집합을 보관하고
3
+ * `hasPermission`/`hasAny`/`hasAll` 판정을 제공한다. 프레임워크 무관(React·Lit 공통)이며
4
+ * 권한 코드는 **불투명 문자열**로만 다룬다(도메인 의미는 앱이 소유).
5
+ *
6
+ * 부팅 시점 스냅샷 모델 — 권한 변경은 다음 로그인(재-set)까지 반영되지 않는다. 단일 운영자
7
+ * 모델에 충분하며, 실시간 권한 회수/멀티테넌트가 필요하면 `subscribe` 로 반응형 확장 가능.
8
+ */
9
+ export interface PermissionStore {
10
+ /** 현재 권한 집합을 통째로 교체한다(로그인/세션 조회 성공 시). */
11
+ set(codes: Iterable<string>): void;
12
+ /** 현재 권한 집합(읽기 전용). */
13
+ get(): ReadonlySet<string>;
14
+ /** 단일 권한 코드 보유 여부. */
15
+ has(code: string): boolean;
16
+ /** 주어진 코드 중 하나라도 보유. 빈 목록은 `true`(제약 없음). */
17
+ hasAny(codes: Iterable<string>): boolean;
18
+ /** 주어진 코드를 모두 보유. 빈 목록은 `true`. */
19
+ hasAll(codes: Iterable<string>): boolean;
20
+ /** 권한을 비운다(로그아웃/세션 만료 시). */
21
+ clear(): void;
22
+ /** 권한 변경 구독. 해제 함수를 반환한다. */
23
+ subscribe(listener: (codes: ReadonlySet<string>) => void): () => void;
24
+ }
25
+ /** 독립적인 권한 store 를 생성한다(테스트 격리·다중 컨텍스트에 유리). */
26
+ export declare function createPermissionStore(initial?: Iterable<string>): PermissionStore;
27
+ /**
28
+ * 기본 싱글톤 권한 store. 앱 전역에서 `hasPermission(code)` 를 어디서나 호출하는
29
+ * 흔한 패턴을 위해 free 함수로 바인딩해 노출한다. 격리가 필요하면 `createPermissionStore()` 를 쓴다.
30
+ */
31
+ export declare const defaultPermissionStore: PermissionStore;
32
+ /** 기본 store 의 권한을 교체한다(부팅 시 `setPermissions(user.Permissions)`). */
33
+ export declare const setPermissions: (codes: Iterable<string>) => void;
34
+ /** 기본 store 의 현재 권한 집합. */
35
+ export declare const getPermissions: () => ReadonlySet<string>;
36
+ /** 기본 store 기준 단일 권한 보유 여부. */
37
+ export declare const hasPermission: (code: string) => boolean;
38
+ /** 기본 store 기준 하나라도 보유. */
39
+ export declare const hasAnyPermission: (codes: Iterable<string>) => boolean;
40
+ /** 기본 store 기준 모두 보유. */
41
+ export declare const hasAllPermissions: (codes: Iterable<string>) => boolean;
42
+ /** 기본 store 권한을 비운다. */
43
+ export declare const clearPermissions: () => void;
@@ -0,0 +1,99 @@
1
+ import { HttpResponse } from '@iyulab/http-client';
2
+ /**
3
+ * API 호출 실패 에러 — HTTP status 를 실어 호출부가 상태별 분기(예: 404 도메인 문구)를 할 수 있게 한다.
4
+ * `Error` 를 상속하므로 기존 `e instanceof Error`/`e.message` 소비처는 그대로 동작한다.
5
+ */
6
+ export declare class ApiError extends Error {
7
+ readonly status: number;
8
+ constructor(message: string, status: number);
9
+ }
10
+ /** 서비스가 토스트/에러에 쓰는 사용자 대면 문구. 기본값은 영어 — 앱이 로케일별로 오버라이드한다. */
11
+ export interface ODataServiceMessages {
12
+ /** POST 성공 토스트 */
13
+ saved: string;
14
+ /** PATCH 성공 토스트 */
15
+ updated: string;
16
+ /** DELETE 성공 토스트 */
17
+ deleted: string;
18
+ /** 401 세션 만료 시 throw 되는 에러 메시지 */
19
+ sessionExpired: string;
20
+ /** status별 매핑이 없을 때의 폴백 */
21
+ requestFailed: string;
22
+ /** 서버 raw 메시지가 없거나 너무 길 때 status → 친화 메시지 */
23
+ http: Record<number, string>;
24
+ }
25
+ export interface ODataServiceConfig {
26
+ /** 모든 요청의 베이스 URL (예: `window.location.origin`). 슬래시 없이 오리진만. */
27
+ baseUrl: string;
28
+ /** OData 엔드포인트 prefix (기본 `$data`) */
29
+ odataPrefix?: string;
30
+ /** custom REST 엔드포인트 prefix (기본 `api`) */
31
+ apiPrefix?: string;
32
+ /**
33
+ * 401 응답 시 호출된다(모든 메서드 공통). 세션 만료 리다이렉트는 앱이 결정한다.
34
+ * 재진입 가드(한 번만 리다이렉트)도 앱 콜백 쪽에서 처리한다 — 라이브러리는 status만 통지.
35
+ */
36
+ onUnauthorized?: (status: number) => void;
37
+ /** 성공/실패 토스트 훅. 생략하면 토스트를 내지 않는다(순수 · 테스트 용이). */
38
+ notify?: {
39
+ success?: (message: string) => void;
40
+ error?: (message: string) => void;
41
+ };
42
+ /** 사용자 대면 문구 오버라이드(로케일). 지정한 키만 기본값을 대체한다. */
43
+ messages?: Partial<ODataServiceMessages>;
44
+ /**
45
+ * 에러 메시지 포매팅 오버라이드. 반환값이 있으면 그것을 에러 메시지로 사용한다.
46
+ * (앱별 정책 — 예: 영문 raw OData 메시지를 로케일 친화 문구로 치환 — 을 여기에 둔다.)
47
+ */
48
+ formatError?: (info: {
49
+ status: number;
50
+ statusText: string;
51
+ rawMessage?: string;
52
+ body?: unknown;
53
+ }) => string | undefined;
54
+ }
55
+ export interface ODataService {
56
+ /** 설정된 prefix + baseUrl 로 OData 엔티티 URL 을 만든다(flex-table useODataSource 등에서 사용). */
57
+ odataUrl(entity: string): string;
58
+ /** 설정된 prefix + baseUrl 로 REST 엔드포인트 URL 을 만든다. */
59
+ apiUrl(path: string): string;
60
+ /** OData GET(목록). `value` 배열을 벗겨 반환한다. */
61
+ odataGet<T>(entity: string, params?: Record<string, string>): Promise<T[]>;
62
+ /** OData GET(단건, key). */
63
+ odataGetById<T>(entity: string, id: string): Promise<T>;
64
+ /** `$count=true&$top=0` — 데이터 없이 총 건수만. */
65
+ odataCount(entity: string, filter?: Record<string, unknown>): Promise<number>;
66
+ /** OData POST(생성) — 토스트 없이 결과만(일괄 처리에서 토스트 폭주 방지). */
67
+ odataPostQuiet<T>(entity: string, body: Partial<T>): Promise<T>;
68
+ /** OData POST(생성) — 성공 시 `saved` 토스트. */
69
+ odataPost<T>(entity: string, body: Partial<T>): Promise<T>;
70
+ /** OData PATCH(수정) — 성공 시 `updated` 토스트. */
71
+ odataPatch<T>(entity: string, id: string, body: Partial<T>): Promise<void>;
72
+ /** OData DELETE — 성공 시 `deleted` 토스트. */
73
+ odataDelete(entity: string, id: string): Promise<void>;
74
+ /** custom REST GET — 204 등 빈 바디를 안전 파싱. */
75
+ apiGet<T>(path: string): Promise<T>;
76
+ /** custom REST POST. */
77
+ apiPost<T>(path: string, body?: unknown): Promise<T>;
78
+ /** custom REST PATCH. */
79
+ apiPatch<T>(path: string, body?: unknown): Promise<T>;
80
+ /** custom REST DELETE — 대부분 204 No Content. */
81
+ apiDelete<T = void>(path: string): Promise<T>;
82
+ /** URL 을 직접 조립한 커스텀 조회(csv-export 등)를 위해 raw 응답을 반환. */
83
+ fetchRaw(url: string): Promise<HttpResponse>;
84
+ /**
85
+ * flex-table `useODataSource` 에 주입할 공용 transport 옵션.
86
+ * useODataSource 는 자체 fetcher 를 쓰므로 별도로 `onUnauthorized` 를 배선해야 401 처리가 걸린다.
87
+ */
88
+ readonly sourceDefaults: {
89
+ baseUrl: string;
90
+ onUnauthorized: () => void;
91
+ };
92
+ /** `instanceof` 판정을 위해 재노출. (모듈 export `ApiError` 와 동일 클래스) */
93
+ readonly ApiError: typeof ApiError;
94
+ }
95
+ /**
96
+ * OData v4 + custom REST 서비스를 생성한다.
97
+ * 반환된 서비스는 상태를 공유하지 않는 순수 클로저이므로 여러 개를 만들어도 안전하다(테스트 격리에도 유리).
98
+ */
99
+ export declare function createODataService(config: ODataServiceConfig): ODataService;
package/dist/index.d.ts CHANGED
@@ -6,3 +6,6 @@ export { FormSection } from './FormSection';
6
6
  export { FormRow } from './FormRow';
7
7
  export * from './helpers';
8
8
  export * from './ApiConfig';
9
+ export * from './data/ODataService';
10
+ export * from './auth/permissions';
11
+ export * from './auth/AuthClient';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { HttpClient } from "@iyulab/http-client";
2
3
  //#region src/FormSection.tsx
3
4
  function FormSection({ title, children }) {
4
5
  return /* @__PURE__ */ jsxs("div", {
@@ -511,6 +512,656 @@ var ApiConfig = class {
511
512
  this._apiPrefix = "api";
512
513
  this._isDevelopment = void 0;
513
514
  }
515
+ }, e = [
516
+ "eq",
517
+ "ne",
518
+ "gt",
519
+ "ge",
520
+ "lt",
521
+ "le"
522
+ ], t = [
523
+ "and",
524
+ "or",
525
+ "not"
526
+ ], n = ["any", "all"], r = [
527
+ "startswith",
528
+ "endswith",
529
+ "contains",
530
+ "matchespattern"
531
+ ], i = [
532
+ "expand",
533
+ "levels",
534
+ "select",
535
+ "skip",
536
+ "top",
537
+ "count",
538
+ "orderby",
539
+ "filter"
540
+ ], a = /\((.*)\)/, o = /(?!indexof)\((\w+)\)/;
541
+ function g(e, t) {
542
+ if (t) {
543
+ let n = e === "", r = o.test(e), i = a.test(e);
544
+ return n ? t : r ? y(e, t, o) : i ? y(e, t, a) : `${t}/${e}`;
545
+ }
546
+ return e;
547
+ }
548
+ function _(e) {
549
+ return t.indexOf(e) !== -1;
550
+ }
551
+ function v(e) {
552
+ return `not (${e.join(" and ")})`;
553
+ }
554
+ function y(e, t, n) {
555
+ return e.replace(n, (e, n) => n.trim() === "" ? `(${t})` : `(${t}/${n.trim()})`);
556
+ }
557
+ function b(e, t, n = []) {
558
+ return `${e} eq ${w(t, n)}`;
559
+ }
560
+ function x({ key: e, value: t, aliases: n, propPrefix: r }) {
561
+ let i = [], a = e, o = _(a), s = t.map((e) => O(e, n, r)).filter((e) => e).map((e) => o ? `(${e})` : e);
562
+ return s.length && (o ? a === "not" ? i.push(v(s)) : i.push(`(${s.join(` ${a} `)})`) : i.push(s.join(` ${a} `))), i;
563
+ }
564
+ function S({ propName: e, value: t }) {
565
+ let n = [], r = e, i = Object.keys(t).map((e) => k({ [e]: t[e] }));
566
+ return i.length && (r === "not" ? n.push(v(i)) : n.push(`(${i.join(` ${r} `)})`)), n;
567
+ }
568
+ function C({ key: i, propName: a, value: o, aliases: s }) {
569
+ let c = [];
570
+ return "type" in o ? c.push(b(a, o, s)) : Object.keys(o).forEach((l) => {
571
+ if (o[l] !== void 0) if (e.indexOf(l) !== -1) c.push(`${a} ${l} ${w(o[l], s)}`);
572
+ else if (t.indexOf(l) !== -1) Array.isArray(o[l]) ? c.push(o[l].map((e) => "(" + O(e, s, a) + ")").join(` ${l} `)) : c.push("(" + O(o[l], s, a) + ")");
573
+ else if (n.indexOf(l) !== -1) {
574
+ let e = A({
575
+ lambdaParameter: i.toLowerCase(),
576
+ value: o[l],
577
+ op: l,
578
+ propName: a,
579
+ aliases: s
580
+ });
581
+ e && c.push(e);
582
+ } else if (l === "has") c.push(`${a} ${l} ${w(o[l], s)}`);
583
+ else if (l === "in") {
584
+ let e = Array.isArray(o[l]) ? o[l] : o[l].value.map((e) => ({
585
+ type: o[l].type,
586
+ value: e
587
+ }));
588
+ c.push(a + " in (" + e.map((e) => w(e, s)).join(",") + ")");
589
+ } else if (r.indexOf(l) !== -1) c.push(`${l}(${a},${w(o[l], s)})`);
590
+ else {
591
+ let e = /\/not/g.test(a), t = e ? a.replace(/\/not/g, "") : a, n = e ? v([O({ [l]: o[l] }, s, t)]) : O({ [l]: o[l] }, s, t);
592
+ n && c.push(n);
593
+ }
594
+ }), c;
595
+ }
596
+ function w(e, t) {
597
+ if (typeof e == "string") return `'${D(e)}'`;
598
+ if (e instanceof Date) return e.toISOString();
599
+ if (typeof e == "number") return e;
600
+ if (Array.isArray(e)) return `[${e.map((e) => w(e)).join(",")}]`;
601
+ if (e === null) return e;
602
+ if (typeof e == "object") switch (e.type) {
603
+ case "raw":
604
+ case "guid": return e.value;
605
+ case "duration": return `duration'${e.value}'`;
606
+ case "binary": return `binary'${e.value}'`;
607
+ case "alias": return Array.isArray(t) && t.push(e), `@${e.name}`;
608
+ case "json": return escape(JSON.stringify(e.value));
609
+ case "decimal": return `${e.value}M`;
610
+ default: return Object.entries(e).filter(([, e]) => e !== void 0).map(([e, n]) => `${e}=${w(n, t)}`).join(",");
611
+ }
612
+ return e;
613
+ }
614
+ function T(e, t, n, r) {
615
+ return `${r}/${n}(${e}: ${e} ${n == "all" ? "ne" : "eq"} '${t}')`;
616
+ }
617
+ function E(e) {
618
+ return e.replace(/'/g, "''");
619
+ }
620
+ function D(e) {
621
+ return encodeURIComponent(E(e));
622
+ }
623
+ function O(e = {}, t = [], n = "") {
624
+ return (Array.isArray(e) ? e : [e]).reduce((e, r) => {
625
+ if (r) {
626
+ let i = k(r, t, n);
627
+ i && e.push(i);
628
+ }
629
+ return e;
630
+ }, []).join(" and ");
631
+ }
632
+ function k(e = {}, t = [], n = "") {
633
+ let r = "";
634
+ return typeof e == "string" ? r = e : e && typeof e == "object" && (r = Object.keys(e).reduce((r, i) => {
635
+ let a = e[i];
636
+ if (a === void 0) return r;
637
+ let o = i === "", s = g(i, n);
638
+ if (o && Array.isArray(a)) return r.concat(a.map((e) => b(s, e)));
639
+ let c = [
640
+ "number",
641
+ "string",
642
+ "boolean"
643
+ ].indexOf(typeof a) !== -1 || a instanceof Date || a === null, l = Array.isArray(a), u = typeof a == "object";
644
+ if (c) r.push(b(s, a, t));
645
+ else if (l) r.push(...x({
646
+ key: i,
647
+ value: a,
648
+ aliases: t,
649
+ propPrefix: n
650
+ }));
651
+ else if (_(s)) r.push(...S({
652
+ propName: s,
653
+ value: a
654
+ }));
655
+ else if (u) r.push(...C({
656
+ key: i,
657
+ propName: s,
658
+ value: a,
659
+ aliases: t
660
+ }));
661
+ else throw Error(`Unexpected value type: ${a}`);
662
+ return r;
663
+ }, []).join(" and ")), r;
664
+ }
665
+ function A({ lambdaParameter: e, value: t, op: n, propName: r, aliases: i }) {
666
+ let a = "";
667
+ if (typeof t == "string" || t instanceof String) a = T(e, t, n, r);
668
+ else if (t) {
669
+ let o = O(Array.isArray(t) ? t.reduce((e, t) => t.hasOwnProperty("") ? (e.hasOwnProperty("") || (e[""] = []), e[""].push(t[""]), e) : {
670
+ ...e,
671
+ ...t
672
+ }, {}) : t, i, e);
673
+ a = `${r}/${n}(${o ? `${e}:${o}` : ""})`;
674
+ }
675
+ return a;
676
+ }
677
+ function j(e) {
678
+ if (typeof e == "number") return e;
679
+ if (typeof e == "string") return e.indexOf("/") === -1 ? e : e.split("/").reverse().reduce((e, t, n, r) => n === 0 ? `$expand=${t}` : n === r.length - 1 ? `${t}(${e})` : `$expand=${t}(${e})`, "");
680
+ if (Array.isArray(e)) return `${e.map((e) => j(e)).join(",")}`;
681
+ if (typeof e == "object") {
682
+ let t = Object.keys(e);
683
+ return t.some((e) => i.indexOf(e.toLowerCase()) !== -1) ? t.map((t) => {
684
+ let n;
685
+ switch (t) {
686
+ case "filter":
687
+ n = O(e[t]);
688
+ break;
689
+ case "orderBy":
690
+ n = F(e[t]);
691
+ break;
692
+ case "levels":
693
+ case "count":
694
+ case "skip":
695
+ case "top":
696
+ n = `${e[t]}`;
697
+ break;
698
+ default: n = j(e[t]);
699
+ }
700
+ return `$${t.toLowerCase()}=${n}`;
701
+ }).join(";") : t.map((t) => {
702
+ let n = j(e[t]);
703
+ return n ? `${t}(${n})` : t;
704
+ }).join(",");
705
+ }
706
+ return "";
707
+ }
708
+ function M(e) {
709
+ return (Array.isArray(e) ? e : [e]).reduce((e, t) => {
710
+ for (let n of Object.keys(t)) switch (n) {
711
+ case "aggregate": {
712
+ let r = t[n];
713
+ r && e.push(`aggregate(${N(r)})`);
714
+ break;
715
+ }
716
+ case "filter": {
717
+ let r = t[n];
718
+ if (r) {
719
+ let t = O(r);
720
+ t && e.push(`filter(${t})`);
721
+ }
722
+ break;
723
+ }
724
+ case "groupBy": {
725
+ let r = t[n];
726
+ r && e.push(`groupby(${P(r)})`);
727
+ break;
728
+ }
729
+ default: throw Error(`Unsupported transform: ${n}`);
730
+ }
731
+ return e;
732
+ }, []).join("/") || void 0;
733
+ }
734
+ function N(e) {
735
+ return (Array.isArray(e) ? e : [e]).map((e) => typeof e == "string" ? e : Object.keys(e).map((t) => {
736
+ let n = e[t];
737
+ if (!n.with && n.as) return `${t} as ${n.as}`;
738
+ if (!n.with) throw Error(`'with' property required for '${t}'`);
739
+ if (!n.as) throw Error(`'as' property required for '${t}'`);
740
+ return `${t} with ${n.with} as ${n.as}`;
741
+ })).join(",");
742
+ }
743
+ function P(e) {
744
+ if (!e.properties) throw Error("'properties' property required for groupBy");
745
+ let t = `(${e.properties.join(",")})`;
746
+ return e.transform && (t += `,${M(e.transform)}`), t;
747
+ }
748
+ function F(e, t = "") {
749
+ return Array.isArray(e) ? e.map((e) => Array.isArray(e) && e.length === 2 && ["asc", "desc"].indexOf(e[1]) !== -1 ? e.join(" ") : e).map((e) => `${t}${String(e)}`).join(",") : typeof e == "object" ? Object.entries(e).map(([e, t]) => F(t, `${e}/`)).map((e) => `${t}${e}`).join(",") : `${t}${String(e)}`;
750
+ }
751
+ function I(e, t) {
752
+ let n = Object.getOwnPropertyNames(t).filter((e) => t[e] !== void 0 && t[e] !== "").map((e) => `${e}=${t[e]}`);
753
+ return n.length ? `${e}?${n.join("&")}` : e;
754
+ }
755
+ function L({ select: e, search: t, skiptoken: n, format: r, top: i, skip: a, filter: o, transform: s, orderBy: c, key: l, count: u, expand: d, action: f, func: p } = {}) {
756
+ let m = "", h = [], g = {};
757
+ if (l != null && (m += `(${w(l, h)})`), (o || typeof u == "object") && (g.$filter = O(typeof u == "object" ? u : o, h)), s && (g.$apply = M(s)), d && (g.$expand = j(d)), c && (g.$orderby = F(c)), u && (typeof u == "boolean" ? g.$count = !0 : m += "/$count"), typeof i == "number" && (g.$top = i), typeof a == "number" && (g.$skip = a), f && (m += `/${f}`), p) {
758
+ if (typeof p == "string") m += `/${p}`;
759
+ else if (typeof p == "object") {
760
+ let [e] = Object.keys(p), t = w(p[e], h);
761
+ m += `/${e}`, t !== "" && (m += `(${t})`);
762
+ }
763
+ }
764
+ return h.length > 0 && Object.assign(g, h.reduce((e, t) => Object.assign(e, { [`@${t.name}`]: w(t.value) }), {})), t && (g.$search = encodeURIComponent(t)), I(m, {
765
+ $select: e,
766
+ $skiptoken: n,
767
+ $format: r,
768
+ ...g
769
+ });
770
+ }
771
+ //#endregion
772
+ //#region src/data/ODataService.ts
773
+ /**
774
+ * OData v4 + custom REST 서비스 팩토리.
775
+ *
776
+ * 여러 소비앱이 글자 단위로 동일하게 재작성하던 OData/REST CRUD 래퍼 —
777
+ * 401 세션 처리, 에러 메시지 추출, 성공 토스트, 빈-바디(204) 안전 파싱 — 를
778
+ * 범용 primitive 로 승격한 것. 도메인/로케일 요소(엔티티 목록, 한국어 문구,
779
+ * 도메인 액션)는 라이브러리에 넣지 않고 주입(config)으로 앱 adapter 에 남긴다.
780
+ *
781
+ * @example
782
+ * const svc = createODataService({
783
+ * baseUrl: window.location.origin,
784
+ * onUnauthorized: () => { window.location.href = '/' },
785
+ * notify: { success: (m) => app.success(m), error: (m) => app.error(m) },
786
+ * messages: { saved: '저장되었습니다', updated: '수정되었습니다', deleted: '삭제되었습니다' },
787
+ * })
788
+ * const rows = await svc.odataGet<BankAccount>('BankAccounts', { $top: '20' })
789
+ */
790
+ /**
791
+ * API 호출 실패 에러 — HTTP status 를 실어 호출부가 상태별 분기(예: 404 도메인 문구)를 할 수 있게 한다.
792
+ * `Error` 를 상속하므로 기존 `e instanceof Error`/`e.message` 소비처는 그대로 동작한다.
793
+ */
794
+ var ApiError = class extends Error {
795
+ constructor(message, status) {
796
+ super(message);
797
+ this.name = "ApiError";
798
+ this.status = status;
799
+ }
800
+ };
801
+ var DEFAULT_MESSAGES = {
802
+ saved: "Saved",
803
+ updated: "Updated",
804
+ deleted: "Deleted",
805
+ sessionExpired: "Your session has expired. Please sign in again.",
806
+ requestFailed: "Request failed",
807
+ http: {
808
+ 400: "Bad request",
809
+ 403: "Forbidden",
810
+ 404: "Not found",
811
+ 409: "Conflict",
812
+ 500: "Server error"
813
+ }
814
+ };
815
+ /** 빈 문자열을 null 로 정규화 — OData 서버가 빈 문자열을 400으로 거부하는 패턴 방지. */
816
+ function normalizeBody(body) {
817
+ return Object.fromEntries(Object.entries(body).map(([k, v]) => [k, v === "" ? null : v]));
818
+ }
819
+ /**
820
+ * 응답 바디를 안전하게 JSON 파싱 — custom REST 엔드포인트 다수가 성공 시 204 No Content(빈 바디)를
821
+ * 반환한다. `res.json()` 은 빈 바디에서 SyntaxError 를 던지므로 항상 `.text()` 로 먼저 읽고
822
+ * 내용이 있을 때만 파싱한다.
823
+ */
824
+ async function parseJsonBody(res) {
825
+ const text = await res.text();
826
+ return text ? JSON.parse(text) : void 0;
827
+ }
828
+ /**
829
+ * OData v4 + custom REST 서비스를 생성한다.
830
+ * 반환된 서비스는 상태를 공유하지 않는 순수 클로저이므로 여러 개를 만들어도 안전하다(테스트 격리에도 유리).
831
+ */
832
+ function createODataService(config) {
833
+ const baseUrl = config.baseUrl;
834
+ const odataPrefix = config.odataPrefix ?? "$data";
835
+ const apiPrefix = config.apiPrefix ?? "api";
836
+ const messages = {
837
+ ...DEFAULT_MESSAGES,
838
+ ...config.messages,
839
+ http: {
840
+ ...DEFAULT_MESSAGES.http,
841
+ ...config.messages?.http
842
+ }
843
+ };
844
+ const notifySuccess = config.notify?.success;
845
+ const notifyError = config.notify?.error;
846
+ const client = new HttpClient({});
847
+ const odataUrl = (entity) => `${baseUrl}/${odataPrefix}/${entity}`;
848
+ const apiUrl = (path) => {
849
+ const clean = path.startsWith("/") ? path.slice(1) : path;
850
+ return `${baseUrl}/${apiPrefix}/${clean}`;
851
+ };
852
+ /** OData 응답에서 사용자 친화적 에러 메시지 추출. */
853
+ async function extractErrorMsg(res) {
854
+ let body;
855
+ try {
856
+ body = await res.json();
857
+ } catch {
858
+ body = void 0;
859
+ }
860
+ const rawVal = (body?.error)?.message ?? body?.message ?? body?.Message;
861
+ const rawMessage = typeof rawVal === "string" ? rawVal : void 0;
862
+ if (config.formatError) {
863
+ const m = config.formatError({
864
+ status: res.status,
865
+ statusText: res.statusText,
866
+ rawMessage,
867
+ body
868
+ });
869
+ if (m) return m;
870
+ }
871
+ if (rawMessage && rawMessage.length <= 200) return rawMessage;
872
+ return messages.http[res.status] ?? `${messages.requestFailed} (${res.status})`;
873
+ }
874
+ /** 에러 확인 후 throw. 401 은 onUnauthorized 통지 후 세션 만료 에러로 단락. */
875
+ async function throwIfError(res) {
876
+ if (res.ok) return;
877
+ if (res.status === 401) {
878
+ config.onUnauthorized?.(401);
879
+ throw new ApiError(messages.sessionExpired, 401);
880
+ }
881
+ throw new ApiError(await extractErrorMsg(res), res.status);
882
+ }
883
+ async function odataGet(entity, params) {
884
+ const u = new URL(odataUrl(entity));
885
+ if (params) for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
886
+ const res = await client.get(u.toString());
887
+ await throwIfError(res);
888
+ const json = await res.json();
889
+ return json.value ?? json;
890
+ }
891
+ async function odataGetById(entity, id) {
892
+ const res = await client.get(`${odataUrl(entity)}(${id})`);
893
+ await throwIfError(res);
894
+ return res.json();
895
+ }
896
+ async function odataCount(entity, filter) {
897
+ const qs = L({
898
+ filter,
899
+ top: 0,
900
+ count: true
901
+ });
902
+ const res = await client.get(`${odataUrl(entity)}${qs}`);
903
+ await throwIfError(res);
904
+ return (await res.json())["@odata.count"] ?? 0;
905
+ }
906
+ async function odataPostQuiet(entity, body) {
907
+ const res = await client.post(odataUrl(entity), normalizeBody(body));
908
+ await throwIfError(res);
909
+ return res.json();
910
+ }
911
+ async function odataPost(entity, body) {
912
+ try {
913
+ const result = await odataPostQuiet(entity, body);
914
+ notifySuccess?.(messages.saved);
915
+ return result;
916
+ } catch (e) {
917
+ if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
918
+ throw e;
919
+ }
920
+ }
921
+ async function odataPatch(entity, id, body) {
922
+ const res = await client.patch(`${odataUrl(entity)}(${id})`, normalizeBody(body));
923
+ if (!res.ok) {
924
+ if (res.status === 401) {
925
+ config.onUnauthorized?.(401);
926
+ throw new ApiError(messages.sessionExpired, 401);
927
+ }
928
+ const msg = await extractErrorMsg(res);
929
+ notifyError?.(msg);
930
+ throw new ApiError(msg, res.status);
931
+ }
932
+ notifySuccess?.(messages.updated);
933
+ }
934
+ async function odataDelete(entity, id) {
935
+ const res = await client.delete(`${odataUrl(entity)}(${id})`);
936
+ if (!res.ok) {
937
+ if (res.status === 401) {
938
+ config.onUnauthorized?.(401);
939
+ throw new ApiError(messages.sessionExpired, 401);
940
+ }
941
+ const msg = await extractErrorMsg(res);
942
+ notifyError?.(msg);
943
+ throw new ApiError(msg, res.status);
944
+ }
945
+ notifySuccess?.(messages.deleted);
946
+ }
947
+ async function apiGet(path) {
948
+ const [p, ...q] = path.split("?");
949
+ const url = q.length ? `${apiUrl(p)}?${q.join("?")}` : apiUrl(p);
950
+ const res = await client.get(url);
951
+ await throwIfError(res);
952
+ return parseJsonBody(res);
953
+ }
954
+ async function apiPost(path, body) {
955
+ const res = await client.post(apiUrl(path), body ?? {});
956
+ await throwIfError(res);
957
+ return parseJsonBody(res);
958
+ }
959
+ async function apiPatch(path, body) {
960
+ const res = await client.patch(apiUrl(path), body ?? {});
961
+ await throwIfError(res);
962
+ return parseJsonBody(res);
963
+ }
964
+ async function apiDelete(path) {
965
+ const res = await client.delete(apiUrl(path));
966
+ await throwIfError(res);
967
+ return parseJsonBody(res);
968
+ }
969
+ async function fetchRaw(url) {
970
+ const res = await client.get(url);
971
+ await throwIfError(res);
972
+ return res;
973
+ }
974
+ return {
975
+ odataUrl,
976
+ apiUrl,
977
+ odataGet,
978
+ odataGetById,
979
+ odataCount,
980
+ odataPostQuiet,
981
+ odataPost,
982
+ odataPatch,
983
+ odataDelete,
984
+ apiGet,
985
+ apiPost,
986
+ apiPatch,
987
+ apiDelete,
988
+ fetchRaw,
989
+ sourceDefaults: {
990
+ baseUrl,
991
+ onUnauthorized: () => config.onUnauthorized?.(401)
992
+ },
993
+ ApiError
994
+ };
995
+ }
996
+ //#endregion
997
+ //#region src/auth/permissions.ts
998
+ /** 독립적인 권한 store 를 생성한다(테스트 격리·다중 컨텍스트에 유리). */
999
+ function createPermissionStore(initial) {
1000
+ let codes = new Set(initial ?? []);
1001
+ const listeners = /* @__PURE__ */ new Set();
1002
+ const emit = () => {
1003
+ for (const l of listeners) l(codes);
1004
+ };
1005
+ return {
1006
+ set(next) {
1007
+ codes = new Set(next);
1008
+ emit();
1009
+ },
1010
+ get() {
1011
+ return codes;
1012
+ },
1013
+ has(code) {
1014
+ return codes.has(code);
1015
+ },
1016
+ hasAny(list) {
1017
+ const arr = [...list];
1018
+ if (arr.length === 0) return true;
1019
+ return arr.some((c) => codes.has(c));
1020
+ },
1021
+ hasAll(list) {
1022
+ return [...list].every((c) => codes.has(c));
1023
+ },
1024
+ clear() {
1025
+ if (codes.size === 0) return;
1026
+ codes = /* @__PURE__ */ new Set();
1027
+ emit();
1028
+ },
1029
+ subscribe(listener) {
1030
+ listeners.add(listener);
1031
+ return () => listeners.delete(listener);
1032
+ }
1033
+ };
1034
+ }
1035
+ /**
1036
+ * 기본 싱글톤 권한 store. 앱 전역에서 `hasPermission(code)` 를 어디서나 호출하는
1037
+ * 흔한 패턴을 위해 free 함수로 바인딩해 노출한다. 격리가 필요하면 `createPermissionStore()` 를 쓴다.
1038
+ */
1039
+ var defaultPermissionStore = createPermissionStore();
1040
+ /** 기본 store 의 권한을 교체한다(부팅 시 `setPermissions(user.Permissions)`). */
1041
+ var setPermissions = (codes) => defaultPermissionStore.set(codes);
1042
+ /** 기본 store 의 현재 권한 집합. */
1043
+ var getPermissions = () => defaultPermissionStore.get();
1044
+ /** 기본 store 기준 단일 권한 보유 여부. */
1045
+ var hasPermission = (code) => defaultPermissionStore.has(code);
1046
+ /** 기본 store 기준 하나라도 보유. */
1047
+ var hasAnyPermission = (codes) => defaultPermissionStore.hasAny(codes);
1048
+ /** 기본 store 기준 모두 보유. */
1049
+ var hasAllPermissions = (codes) => defaultPermissionStore.hasAll(codes);
1050
+ /** 기본 store 권한을 비운다. */
1051
+ var clearPermissions = () => defaultPermissionStore.clear();
1052
+ //#endregion
1053
+ //#region src/auth/AuthClient.ts
1054
+ /**
1055
+ * 쿠키 세션 인증 클라이언트 팩토리.
1056
+ *
1057
+ * 여러 소비앱이 동일하게 재작성하던 `fetchMe`/`login`/`logout` 흐름을 범용 primitive 로 승격.
1058
+ * **원시 `fetch`** 를 쓴다(HttpClient 의 401 인터셉터를 의도적으로 우회) — `fetchMe` 가 401 에
1059
+ * `null` 을 반환하는 것이 곧 "미인증" 신호이고, 이 신호가 로그인 게이트를 구동하기 때문.
1060
+ *
1061
+ * 사용자/자격증명 형태는 앱이 제네릭 타입으로 정의한다(라이브러리는 URL 을 호출만).
1062
+ * 권한 추출기(`getPermissions`)를 주면 `fetchMe`/`login` 성공 시 자동으로 권한 store 를 갱신한다.
1063
+ *
1064
+ * @example
1065
+ * interface User { Id: string; Permissions: string[] }
1066
+ * const auth = createAuthClient<User, { Username: string; Password: string }>({
1067
+ * meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout',
1068
+ * getPermissions: (u) => u.Permissions, // → 성공 시 defaultPermissionStore 자동 set
1069
+ * messages: { invalidCredentials: '사용자명 또는 비밀번호가 올바르지 않습니다.' },
1070
+ * })
1071
+ * const user = await auth.fetchMe() // null → 미인증(로그인 게이트)
1072
+ */
1073
+ var DEFAULT_AUTH_MESSAGES = {
1074
+ invalidCredentials: "Invalid username or password.",
1075
+ loginFailed: "Login failed.",
1076
+ networkError: "A network error occurred."
514
1077
  };
1078
+ function pickServerMessage(body) {
1079
+ if (body && typeof body === "object") {
1080
+ const b = body;
1081
+ const m = b.Message ?? b.message;
1082
+ if (typeof m === "string") return m;
1083
+ }
1084
+ }
1085
+ function createAuthClient(config) {
1086
+ const messages = {
1087
+ ...DEFAULT_AUTH_MESSAGES,
1088
+ ...config.messages
1089
+ };
1090
+ const credentials = config.credentials ?? "same-origin";
1091
+ const baseUrl = config.baseUrl ?? "";
1092
+ const store = config.permissionStore ?? defaultPermissionStore;
1093
+ const url = (u) => baseUrl && u.startsWith("/") ? `${baseUrl}${u}` : u;
1094
+ const syncPermissions = (user) => {
1095
+ if (!config.getPermissions) return;
1096
+ if (user) store.set(config.getPermissions(user));
1097
+ else store.clear();
1098
+ };
1099
+ async function fetchMe() {
1100
+ try {
1101
+ const res = await fetch(url(config.meUrl), { credentials });
1102
+ if (!res.ok) {
1103
+ syncPermissions(null);
1104
+ return null;
1105
+ }
1106
+ const user = await res.json();
1107
+ syncPermissions(user);
1108
+ return user;
1109
+ } catch {
1110
+ syncPermissions(null);
1111
+ return null;
1112
+ }
1113
+ }
1114
+ async function login(cred) {
1115
+ try {
1116
+ const res = await fetch(url(config.loginUrl), {
1117
+ method: "POST",
1118
+ headers: { "Content-Type": "application/json" },
1119
+ credentials,
1120
+ body: JSON.stringify(cred)
1121
+ });
1122
+ if (res.status === 401) return {
1123
+ ok: false,
1124
+ message: messages.invalidCredentials
1125
+ };
1126
+ if (!res.ok) {
1127
+ let msg = messages.loginFailed;
1128
+ try {
1129
+ const body = await res.json();
1130
+ const extracted = (config.extractLoginError ?? pickServerMessage)(body);
1131
+ if (extracted) msg = extracted;
1132
+ } catch {}
1133
+ return {
1134
+ ok: false,
1135
+ message: msg
1136
+ };
1137
+ }
1138
+ const user = await res.json();
1139
+ syncPermissions(user);
1140
+ return {
1141
+ ok: true,
1142
+ user
1143
+ };
1144
+ } catch {
1145
+ return {
1146
+ ok: false,
1147
+ message: messages.networkError
1148
+ };
1149
+ }
1150
+ }
1151
+ async function logout() {
1152
+ try {
1153
+ await fetch(url(config.logoutUrl), {
1154
+ method: "POST",
1155
+ credentials
1156
+ });
1157
+ } catch {}
1158
+ syncPermissions(null);
1159
+ }
1160
+ return {
1161
+ fetchMe,
1162
+ login,
1163
+ logout
1164
+ };
1165
+ }
515
1166
  //#endregion
516
- export { ApiConfig, CurrencyHelper, DateHelper, FormRow, FormSection, ProgressHelper, UrgencyHelper };
1167
+ export { ApiConfig, ApiError, CurrencyHelper, DateHelper, FormRow, FormSection, ProgressHelper, UrgencyHelper, clearPermissions, createAuthClient, createODataService, createPermissionStore, defaultPermissionStore, getPermissions, hasAllPermissions, hasAnyPermission, hasPermission, setPermissions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/enterprise",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Enterprise utilities and components for iyulab framework",
5
5
  "keywords": [
6
6
  "enterprise",
@@ -28,17 +28,21 @@
28
28
  }
29
29
  },
30
30
  "scripts": {
31
- "build": "vite build"
31
+ "build": "vite build",
32
+ "test": "vitest run"
32
33
  },
33
34
  "dependencies": {
34
35
  "@iyulab/components": "^1.1.1",
35
36
  "@iyulab/data-components": "^0.6.1",
36
- "@iyulab/modern-app": "^0.3.7"
37
+ "@iyulab/http-client": "^0.9.0",
38
+ "@iyulab/modern-app": "^0.3.7",
39
+ "odata-query": "^8.0.7"
37
40
  },
38
41
  "devDependencies": {
39
42
  "@types/node": "^25.8.0",
40
43
  "typescript": "^5.9.3",
41
44
  "vite": "^8.0.13",
42
- "vite-plugin-dts": "^5.0.0"
45
+ "vite-plugin-dts": "^5.0.0",
46
+ "vitest": "^3.0.0"
43
47
  }
44
48
  }