@pantoia/auth-client 0.1.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 ADDED
@@ -0,0 +1,145 @@
1
+ # @pantoia/auth-client
2
+
3
+ 쿠키로 인증하는 API를 브라우저에서 부르는 클라이언트다.
4
+
5
+ - access 토큰이 만료되면 재발급하고 요청을 다시 보낸다.
6
+ - 내 정보를 1번 불러와 저장하고, 로그인 여부와 권한을 그 값으로 확인한다.
7
+ - API 경로, 오류 코드, 내 정보 형태는 쓰는 쪽이 정한다.
8
+
9
+ ## 설치
10
+
11
+ ```
12
+ npm install @pantoia/auth-client
13
+ ```
14
+
15
+ React 훅을 쓰려면 React 18 이상이 필요하다.
16
+
17
+ ## 만들기
18
+
19
+ ```ts
20
+ import { createAuthClient } from "@pantoia/auth-client";
21
+
22
+ type Me = { nickname: string; permissions: string[] };
23
+
24
+ export const auth = createAuthClient<Me>({
25
+ baseUrl: "https://api.example.com",
26
+ endpoints: { me: "/v1/members/me", refresh: "/v1/auth/refresh", logout: "/v1/auth/logout" },
27
+ codes: { unauthenticated: "UNAUTHENTICATED", refreshConflict: "IDENTITY_REFRESH_TOKEN_CONFLICT" },
28
+ permissionsOf: (me) => me.permissions,
29
+ loginUrl: "https://auth.example.com/login",
30
+ onUnauthenticated: () => window.location.assign(auth.loginUrl()),
31
+ });
32
+ ```
33
+
34
+ | 옵션 | 뜻 |
35
+ |---|---|
36
+ | `baseUrl` | API 주소. 함수면 요청할 때마다 부른다 |
37
+ | `endpoints` | 내 정보, 재발급, 로그아웃 경로 |
38
+ | `codes.unauthenticated` | access 토큰이 없거나 만료됐을 때 오는 401 응답의 `code` |
39
+ | `codes.refreshConflict` | 다른 요청이 먼저 재발급했을 때 오는 409 응답의 `code` |
40
+ | `permissionsOf` | 내 정보에서 권한 목록을 꺼내는 함수 |
41
+ | `loginUrl` | 로그인 화면 주소. 함수면 쓸 때마다 부른다 |
42
+ | `returnToParam` | 로그인 뒤 돌아올 주소를 담는 쿼리 이름. 기본값은 `return_to` |
43
+ | `onUnauthenticated` | 재발급이 401로 실패했을 때 부르는 함수 |
44
+ | `conflictRetryDelayMs` | 409 뒤에 다시 보낸 요청이 또 401일 때 기다리는 시간. 기본값은 300 |
45
+
46
+ ## API 부르기
47
+
48
+ ```ts
49
+ const response = await auth.fetch("/v1/items", { method: "POST", body: { name: "a" } });
50
+ const item = await response.json();
51
+
52
+ await auth.fetch("/v1/auth/login", { method: "POST", body: { email, password }, auth: false });
53
+ ```
54
+
55
+ - 쿠키를 포함해서 보낸다.
56
+ - `body`가 객체면 JSON으로 보내고, `FormData`, `Blob`, `URLSearchParams`, 문자열은 그대로 보낸다.
57
+ - 실패 응답은 `ApiError`로, 서버에 닿지 못하면 `NetworkError`로 던진다. `signal`로 중단하면 `AbortError`를 그대로 던진다.
58
+ - `ApiError`에는 `status`, `code`, `message`(응답의 `detail`), `errors`(필드 오류), `retryAfterSeconds`가 있다.
59
+ - 로그인이 필요 없는 API는 `auth: false`로 부른다. 재발급을 기다리지 않고, 401이어도 재발급하지 않는다.
60
+
61
+ ## 재발급
62
+
63
+ `auth: false`가 아닌 요청이 401이고 응답의 `code`가 `codes.unauthenticated`면 재발급한다.
64
+
65
+ - 한 페이지에서 여러 요청이 동시에 401을 받아도 재발급은 1번만 한다. 나머지 요청과 재발급 중에 시작한 요청은 재발급이 끝난 뒤에 보낸다.
66
+ - 요청을 보낸 뒤 다른 요청이 재발급을 끝냈으면 재발급하지 않고 다시 보낸다.
67
+ - 재발급이 성공하면 원래 요청을 다시 보낸다.
68
+ - 재발급이 409이고 `code`가 `codes.refreshConflict`면 재발급을 다시 하지 않고 원래 요청을 다시 보낸다. 다시 보낸 요청이 또 401이면 `conflictRetryDelayMs` 뒤에 1번 더 보낸다.
69
+ - 재발급이 401이면 기다리던 요청을 모두 실패로 끝내고, 저장한 내 정보를 지우고, `onUnauthenticated`를 1번 부른다.
70
+ - 재발급이 네트워크 오류로 실패하면 요청만 실패로 끝낸다.
71
+
72
+ ## 내 정보와 권한
73
+
74
+ | 함수 | 하는 일 |
75
+ |---|---|
76
+ | `getMe()` | 저장한 내 정보를 돌려준다. 없으면 요청하고, 동시에 불러도 요청은 1번이다. 로그인하지 않았으면 `null` |
77
+ | `reloadMe()` | 내 정보를 다시 요청한다. 내 정보를 바꾼 뒤에 부른다 |
78
+ | `isLoggedIn()` | 저장한 내 정보가 있는지 돌려준다 |
79
+ | `hasPermission(p)`, `hasAnyPermission(ps)`, `hasAllPermissions(ps)` | 저장한 내 정보로 권한을 확인한다. 불러오기 전에는 `false` |
80
+ | `refresh()` | 재발급한다 |
81
+ | `logout()` | 로그아웃 API를 부르고 저장한 내 정보를 지운다 |
82
+ | `loginUrl(returnTo)` | 로그인 화면 주소에 돌아올 주소를 붙인다. 주지 않으면 현재 주소를 붙인다 |
83
+
84
+ `getMe()`는 `onUnauthenticated`를 부르지 않는다. 로그인하지 않은 사람을 어디로 보낼지는 부른 쪽이 정한다.
85
+
86
+ 다른 탭에는 알리지 않는다. 다른 탭에서 로그아웃하면 이 탭은 다음 요청의 재발급이 실패할 때 알게 된다.
87
+
88
+ ## React
89
+
90
+ ```tsx
91
+ import { useHasPermission, useMe } from "@pantoia/auth-client/react";
92
+
93
+ function Header() {
94
+ const me = useMe(auth);
95
+ const admin = useHasPermission(auth, "admin:app:view");
96
+ return <p>{me?.nickname}</p>;
97
+ }
98
+ ```
99
+
100
+ - `useMe(auth)`는 저장한 내 정보를 돌려준다. 아직 불러오지 않았으면 `undefined`를 돌려주고 불러온다. 로그인하지 않았으면 `null`이다.
101
+ - 두 훅 모두 `reloadMe()`, `logout()`, 재발급 실패 뒤에 다시 그린다.
102
+
103
+ ## 로그인 확인 예
104
+
105
+ React Router의 loader에서는 이렇게 쓴다.
106
+
107
+ ```ts
108
+ async function requireLogin({ request }: LoaderFunctionArgs) {
109
+ if (!(await auth.getMe())) {
110
+ throw redirect(auth.loginUrl(request.url));
111
+ }
112
+
113
+ return null;
114
+ }
115
+ ```
116
+
117
+ ## return_to 검사
118
+
119
+ ```ts
120
+ resolveReturnTo(value, rule, fallback)
121
+ isAllowedReturnTo(value, rule)
122
+ ```
123
+
124
+ | 규칙 | 허용하는 주소 |
125
+ |---|---|
126
+ | `{ kind: "domain", domain }` | `https`이고 호스트가 `domain`이나 그 서브도메인인 주소 |
127
+ | `{ kind: "host", hostname }` | `http`나 `https`이고 호스트 이름이 `hostname`인 주소. 포트는 가리지 않는다 |
128
+
129
+ 상대 경로와 사용자 정보가 든 주소(`https://user:pw@...`)는 허용하지 않는다.
130
+
131
+ ## 백엔드에 필요한 것
132
+
133
+ - 인증 쿠키가 API 호스트로 전송되고, CORS가 `credentials`를 허용해야 한다.
134
+ - 오류 응답은 Problem Details(RFC 9457)이고 `code`가 있어야 한다. 필드 오류는 `errors`에 `field`, `code`, `message`로 담는다.
135
+ - 재발급 API는 성공하면 2xx, refresh 토큰이 무효면 401, 다른 요청이 먼저 재발급했으면 409를 줘야 한다.
136
+
137
+ ## 개발
138
+
139
+ Node 20.11 이상이 필요하다.
140
+
141
+ ```
142
+ npm install 처음 한 번
143
+ npm run verify typecheck, lint, test
144
+ npm pack tarball
145
+ ```
@@ -0,0 +1,39 @@
1
+ export type ApiRequest = {
2
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
3
+ body?: unknown;
4
+ headers?: Record<string, string>;
5
+ signal?: AbortSignal;
6
+ auth?: boolean;
7
+ };
8
+ export type AuthClientOptions<Me> = {
9
+ baseUrl: string | (() => string);
10
+ endpoints: {
11
+ me: string;
12
+ refresh: string;
13
+ logout: string;
14
+ };
15
+ codes: {
16
+ unauthenticated: string;
17
+ refreshConflict: string;
18
+ };
19
+ permissionsOf: (me: Me) => readonly string[];
20
+ loginUrl: string | (() => string);
21
+ returnToParam?: string;
22
+ onUnauthenticated?: () => void;
23
+ conflictRetryDelayMs?: number;
24
+ };
25
+ export type AuthClient<Me> = {
26
+ fetch: (path: string, request?: ApiRequest) => Promise<Response>;
27
+ getMe: () => Promise<Me | null>;
28
+ reloadMe: () => Promise<Me | null>;
29
+ isLoggedIn: () => boolean;
30
+ hasPermission: (permission: string) => boolean;
31
+ hasAnyPermission: (permissions: readonly string[]) => boolean;
32
+ hasAllPermissions: (permissions: readonly string[]) => boolean;
33
+ refresh: () => Promise<void>;
34
+ logout: () => Promise<void>;
35
+ loginUrl: (returnTo?: string) => string;
36
+ getSnapshot: () => Me | null | undefined;
37
+ subscribe: (listener: () => void) => () => void;
38
+ };
39
+ export declare function createAuthClient<Me>(options: AuthClientOptions<Me>): AuthClient<Me>;
package/dist/client.js ADDED
@@ -0,0 +1,211 @@
1
+ import { ApiError, NetworkError, readProblem } from "./problem.js";
2
+ function resolve(value) {
3
+ return typeof value === "function" ? value() : value;
4
+ }
5
+ function encodeBody(body) {
6
+ if (body === undefined) {
7
+ return { body: undefined, contentType: undefined };
8
+ }
9
+ if (body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams || typeof body === "string") {
10
+ return { body, contentType: undefined };
11
+ }
12
+ return { body: JSON.stringify(body), contentType: "application/json" };
13
+ }
14
+ function wait(ms) {
15
+ return new Promise((done) => {
16
+ setTimeout(done, ms);
17
+ });
18
+ }
19
+ export function createAuthClient(options) {
20
+ const conflictRetryDelayMs = options.conflictRetryDelayMs ?? 300;
21
+ const returnToParam = options.returnToParam ?? "return_to";
22
+ const listeners = new Set();
23
+ let me;
24
+ let loading = null;
25
+ let refreshing = null;
26
+ let settled = 0;
27
+ let last = { outcome: "refreshed" };
28
+ let notified = false;
29
+ function setMe(value) {
30
+ me = value;
31
+ if (value !== null) {
32
+ notified = false;
33
+ }
34
+ for (const listener of listeners) {
35
+ listener();
36
+ }
37
+ }
38
+ async function send(path, { method = "GET", body, headers, signal }) {
39
+ const encoded = encodeBody(body);
40
+ try {
41
+ return await fetch(`${resolve(options.baseUrl)}${path}`, {
42
+ method,
43
+ credentials: "include",
44
+ headers: encoded.contentType ? { "Content-Type": encoded.contentType, ...headers } : headers,
45
+ body: encoded.body,
46
+ signal,
47
+ });
48
+ }
49
+ catch (error) {
50
+ if (error instanceof DOMException && error.name === "AbortError") {
51
+ throw error;
52
+ }
53
+ throw new NetworkError();
54
+ }
55
+ }
56
+ async function isUnauthenticated(response) {
57
+ if (response.status !== 401) {
58
+ return false;
59
+ }
60
+ try {
61
+ const body = await response.clone().json();
62
+ return typeof body === "object" && body !== null && body.code === options.codes.unauthenticated;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ async function checked(response) {
69
+ if (!response.ok) {
70
+ throw await readProblem(response);
71
+ }
72
+ return response;
73
+ }
74
+ function expire() {
75
+ setMe(null);
76
+ }
77
+ function notifyUnauthenticated() {
78
+ if (notified) {
79
+ return;
80
+ }
81
+ notified = true;
82
+ options.onUnauthenticated?.();
83
+ }
84
+ async function runRefresh() {
85
+ const response = await send(options.endpoints.refresh, { method: "POST" });
86
+ if (response.ok) {
87
+ notified = false;
88
+ return "refreshed";
89
+ }
90
+ const error = await readProblem(response);
91
+ if (error.status === 409 && error.code === options.codes.refreshConflict) {
92
+ return "conflict";
93
+ }
94
+ if (error.status === 401) {
95
+ expire();
96
+ }
97
+ throw error;
98
+ }
99
+ function refreshOnce() {
100
+ if (!refreshing) {
101
+ refreshing = runRefresh().then((outcome) => {
102
+ last = { outcome };
103
+ return outcome;
104
+ }, (error) => {
105
+ last = { error };
106
+ throw error;
107
+ }).finally(() => {
108
+ settled += 1;
109
+ refreshing = null;
110
+ });
111
+ }
112
+ return refreshing;
113
+ }
114
+ function settledResult() {
115
+ return "outcome" in last ? Promise.resolve(last.outcome) : Promise.reject(last.error);
116
+ }
117
+ async function awaitRefresh(promise, notify) {
118
+ try {
119
+ return await promise;
120
+ }
121
+ catch (error) {
122
+ if (notify && error instanceof ApiError && error.status === 401) {
123
+ notifyUnauthenticated();
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ async function request(path, sendOptions = {}) {
129
+ const { auth = true, notify = true } = sendOptions;
130
+ if (!auth) {
131
+ return checked(await send(path, sendOptions));
132
+ }
133
+ if (refreshing) {
134
+ await awaitRefresh(refreshing, notify);
135
+ }
136
+ const sentAt = settled;
137
+ let response = await send(path, sendOptions);
138
+ if (!(await isUnauthenticated(response))) {
139
+ return checked(response);
140
+ }
141
+ const outcome = await awaitRefresh(settled === sentAt ? refreshOnce() : settledResult(), notify);
142
+ response = await send(path, sendOptions);
143
+ if (outcome === "conflict" && (await isUnauthenticated(response))) {
144
+ await wait(conflictRetryDelayMs);
145
+ response = await send(path, sendOptions);
146
+ }
147
+ return checked(response);
148
+ }
149
+ async function load() {
150
+ try {
151
+ const response = await request(options.endpoints.me, { notify: false });
152
+ const value = (await response.json());
153
+ setMe(value);
154
+ return value;
155
+ }
156
+ catch (error) {
157
+ if (error instanceof ApiError && error.status === 401) {
158
+ setMe(null);
159
+ return null;
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+ function startLoad() {
165
+ const promise = load();
166
+ loading = promise;
167
+ const clear = () => {
168
+ if (loading === promise) {
169
+ loading = null;
170
+ }
171
+ };
172
+ promise.then(clear, clear);
173
+ return promise;
174
+ }
175
+ function permissions() {
176
+ return me ? options.permissionsOf(me) : [];
177
+ }
178
+ return {
179
+ fetch: (path, apiRequest) => request(path, apiRequest),
180
+ getMe: () => {
181
+ if (me !== undefined) {
182
+ return Promise.resolve(me);
183
+ }
184
+ return loading ?? startLoad();
185
+ },
186
+ reloadMe: startLoad,
187
+ isLoggedIn: () => me !== undefined && me !== null,
188
+ hasPermission: (permission) => permissions().includes(permission),
189
+ hasAnyPermission: (wanted) => wanted.some((permission) => permissions().includes(permission)),
190
+ hasAllPermissions: (wanted) => wanted.every((permission) => permissions().includes(permission)),
191
+ refresh: async () => {
192
+ await refreshOnce();
193
+ },
194
+ logout: async () => {
195
+ await request(options.endpoints.logout, { method: "POST", auth: false });
196
+ setMe(null);
197
+ },
198
+ loginUrl: (returnTo = window.location.href) => {
199
+ const url = new URL(resolve(options.loginUrl));
200
+ url.searchParams.set(returnToParam, returnTo);
201
+ return url.href;
202
+ },
203
+ getSnapshot: () => me,
204
+ subscribe: (listener) => {
205
+ listeners.add(listener);
206
+ return () => {
207
+ listeners.delete(listener);
208
+ };
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,3 @@
1
+ export { type ApiRequest, type AuthClient, type AuthClientOptions, createAuthClient } from "./client.js";
2
+ export { ApiError, type FieldViolation, NetworkError } from "./problem.js";
3
+ export { isAllowedReturnTo, RETURN_TO_PARAM, resolveReturnTo, type ReturnToRule } from "./return-to.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createAuthClient } from "./client.js";
2
+ export { ApiError, NetworkError } from "./problem.js";
3
+ export { isAllowedReturnTo, RETURN_TO_PARAM, resolveReturnTo } from "./return-to.js";
@@ -0,0 +1,16 @@
1
+ export type FieldViolation = {
2
+ field: string;
3
+ code: string;
4
+ message: string;
5
+ };
6
+ export declare class ApiError extends Error {
7
+ readonly status: number;
8
+ readonly code: string | undefined;
9
+ readonly errors: FieldViolation[];
10
+ readonly retryAfterSeconds: number | undefined;
11
+ constructor(status: number, code: string | undefined, detail: string | undefined, errors: FieldViolation[], retryAfterSeconds: number | undefined);
12
+ }
13
+ export declare class NetworkError extends Error {
14
+ constructor();
15
+ }
16
+ export declare function readProblem(response: Response): Promise<ApiError>;
@@ -0,0 +1,47 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ code;
4
+ errors;
5
+ retryAfterSeconds;
6
+ constructor(status, code, detail, errors, retryAfterSeconds) {
7
+ super(detail ?? "");
8
+ this.name = "ApiError";
9
+ this.status = status;
10
+ this.code = code;
11
+ this.errors = errors;
12
+ this.retryAfterSeconds = retryAfterSeconds;
13
+ }
14
+ }
15
+ export class NetworkError extends Error {
16
+ constructor() {
17
+ super("");
18
+ this.name = "NetworkError";
19
+ }
20
+ }
21
+ function isViolation(value) {
22
+ if (typeof value !== "object" || value === null) {
23
+ return false;
24
+ }
25
+ const item = value;
26
+ return typeof item.field === "string" && typeof item.code === "string" && typeof item.message === "string";
27
+ }
28
+ function readRetryAfter(response) {
29
+ const seconds = Number(response.headers.get("Retry-After"));
30
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
31
+ }
32
+ export async function readProblem(response) {
33
+ let body = {};
34
+ try {
35
+ const parsed = await response.json();
36
+ if (typeof parsed === "object" && parsed !== null) {
37
+ body = parsed;
38
+ }
39
+ }
40
+ catch {
41
+ body = {};
42
+ }
43
+ const code = typeof body.code === "string" ? body.code : undefined;
44
+ const detail = typeof body.detail === "string" ? body.detail : undefined;
45
+ const errors = Array.isArray(body.errors) ? body.errors.filter(isViolation) : [];
46
+ return new ApiError(response.status, code, detail, errors, readRetryAfter(response));
47
+ }
@@ -0,0 +1,3 @@
1
+ import type { AuthClient } from "./client.js";
2
+ export declare function useMe<Me>(auth: AuthClient<Me>): Me | null | undefined;
3
+ export declare function useHasPermission<Me>(auth: AuthClient<Me>, permission: string): boolean;
package/dist/react.js ADDED
@@ -0,0 +1,14 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ export function useMe(auth) {
3
+ const me = useSyncExternalStore(auth.subscribe, auth.getSnapshot, auth.getSnapshot);
4
+ useEffect(() => {
5
+ if (me === undefined) {
6
+ auth.getMe().catch(() => undefined);
7
+ }
8
+ }, [auth, me]);
9
+ return me;
10
+ }
11
+ export function useHasPermission(auth, permission) {
12
+ const read = () => auth.hasPermission(permission);
13
+ return useSyncExternalStore(auth.subscribe, read, read);
14
+ }
@@ -0,0 +1,10 @@
1
+ export type ReturnToRule = {
2
+ kind: "domain";
3
+ domain: string;
4
+ } | {
5
+ kind: "host";
6
+ hostname: string;
7
+ };
8
+ export declare const RETURN_TO_PARAM = "return_to";
9
+ export declare function isAllowedReturnTo(value: string, rule: ReturnToRule): boolean;
10
+ export declare function resolveReturnTo(value: string | null | undefined, rule: ReturnToRule, fallback: string): string;
@@ -0,0 +1,22 @@
1
+ export const RETURN_TO_PARAM = "return_to";
2
+ export function isAllowedReturnTo(value, rule) {
3
+ let url;
4
+ try {
5
+ url = new URL(value);
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ if (url.username || url.password) {
11
+ return false;
12
+ }
13
+ if (rule.kind === "domain") {
14
+ const inDomain = url.hostname === rule.domain || url.hostname.endsWith(`.${rule.domain}`);
15
+ return url.protocol === "https:" && inDomain;
16
+ }
17
+ const web = url.protocol === "http:" || url.protocol === "https:";
18
+ return web && url.hostname === rule.hostname;
19
+ }
20
+ export function resolveReturnTo(value, rule, fallback) {
21
+ return value && isAllowedReturnTo(value, rule) ? new URL(value).href : fallback;
22
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@pantoia/auth-client",
3
+ "version": "0.1.0",
4
+ "description": "쿠키 인증 API 클라이언트. 토큰 재발급, 내 정보, 권한 확인, 로그아웃",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pantoia/pantoia-auth-client.git"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./react": {
20
+ "types": "./dist/react.d.ts",
21
+ "import": "./dist/react.js"
22
+ }
23
+ },
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": ">=20.11"
30
+ },
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
33
+ "prepack": "npm run build",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "oxlint --deny-warnings",
36
+ "test": "vitest run",
37
+ "verify": "npm run typecheck && npm run lint && npm run test"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "react": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@testing-library/dom": "^10.4.2",
49
+ "@testing-library/react": "^16.3.3",
50
+ "@types/react": "^19.2.18",
51
+ "happy-dom": "^20.14.5",
52
+ "oxlint": "^1.81.0",
53
+ "react": "^19.2.8",
54
+ "react-dom": "^19.2.8",
55
+ "typescript": "^7.0.2",
56
+ "vitest": "^4.1.11"
57
+ }
58
+ }