@stacksjs/browser 0.75.228 → 0.75.229

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.
@@ -1,8 +1,16 @@
1
- import type { AuthComposable } from '../types/dashboard';
1
+ import type { AuthComposable, SessionExpiredEvent, SessionExpiredListener } from '../types/dashboard';
2
+ export type { SessionExpiredEvent, SessionExpiredListener } from '../types/dashboard';
3
+ /**
4
+ * Run `listener` when the client ends a session on its own: a 401 from the
5
+ * identity endpoint, or a refused refresh. Called once per session however
6
+ * many requests failed together, and not for `logout()`, which the caller
7
+ * already knows about. Returns an unsubscribe function.
8
+ */
9
+ export declare function onSessionExpired(listener: SessionExpiredListener): () => void;
2
10
  /**
3
11
  * Refresh the session, collapsing concurrent callers onto one exchange.
4
12
  *
5
- * Never rejects: an exchange that could not be attempted resolves `false`
13
+ * Never rejects: an exchange that could not be completed resolves `false`
6
14
  * without clearing state, so a caller can tell "signed out" from "could not
7
15
  * reach the server" by checking whether `token.value` survived.
8
16
  */
@@ -14,10 +22,27 @@ export declare function refreshSession(): Promise<boolean>;
14
22
  * The retry is attempted only when a refresh token exists and the first
15
23
  * response was a 401 — a 403 is an authorization decision, not an expiry, and
16
24
  * refreshing would not change it.
25
+ *
26
+ * A same-origin 401 that cannot be renewed is not taken as a sign-out on its
27
+ * own: plenty of 401s are about the request, not the session (a tampered
28
+ * signed URL, a step-up check). It makes the client ask `/api/me` instead -
29
+ * one shared request for the whole burst - and only that answer's 401 ends the
30
+ * session, once.
17
31
  */
18
- export declare function authFetch(input: string, init?: RequestInit): Promise<Response>;
32
+ export declare function authFetch(input: string, init?: RequestInit): Promise<globalThis.Response>;
19
33
  export declare function useAuth(): AuthComposable;
20
- // Strict auth guard middleware
21
- // Usage: call in setup() of page/component, or in router beforeEach
22
- // Pass { guest: true } for guest-only pages
34
+ /**
35
+ * Client navigation guard. Call it in a page's setup, or a router hook.
36
+ * Pass `{ guest: true }` for guest-only pages.
37
+ *
38
+ * A protected page redirects to /login when nothing says the visitor is signed
39
+ * in. When a stored token exists but has not been confirmed yet (a fresh page
40
+ * load), it asks `/api/me` instead of redirecting: the token is kept through a
41
+ * network failure or a 5xx, and only a 401 sends the visitor to sign in. A
42
+ * session that ends later on the same page - many requests failing at once
43
+ * included - redirects exactly once.
44
+ *
45
+ * This only controls client navigation. Guard server pages and actions
46
+ * independently.
47
+ */
23
48
  export declare function authGuard(options?: { guest?: boolean }): void;
@@ -1 +1 @@
1
- import{readSessionHandoff,stripSessionHandoff,useStorage}from"@stacksjs/composables";import{ref}from"@stacksjs/stx";import{CSRF_COOKIE_NAME,readCsrfToken,withCsrfHeader}from"./csrf";const token=useStorage("token",""),refreshToken=useStorage("refresh_token",""),stacksConfig=globalThis.__STACKS_CONFIG__||{},baseUrl=stacksConfig.API_URL||(typeof window<"u"?window.location.origin:""),ME_PATH=stacksConfig.AUTH_ME_PATH||"/api/me",REFRESH_PATH=stacksConfig.AUTH_REFRESH_PATH||"/auth/refresh",user=ref(null),isAuthenticated=ref(!1);let inFlightRefresh=null;function applySessionHandoff(pack){const resolved=pack??(typeof window<"u"?readSessionHandoff(window.location.hash):null);if(!resolved)return!1;token.value=resolved.token;refreshToken.value=resolved.refreshToken??"";if(resolved.user!==void 0&&resolved.user!==null)user.value=resolved.user;isAuthenticated.value=!0;if(typeof window<"u"&&!pack){const rest=stripSessionHandoff(window.location.hash);window.history.replaceState(null,"",`${window.location.pathname}${window.location.search}${rest}`)}return!0}function clearSession(){token.value="";refreshToken.value="";user.value=null;isAuthenticated.value=!1}async function performRefresh(){if(!refreshToken.value)return!1;const response=await fetch(`${baseUrl}${REFRESH_PATH}`,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json",Accept:"application/json"}),body:JSON.stringify({refresh_token:refreshToken.value})});if(!response.ok){clearSession();return!1}const data=await response.json(),nextAccess=data.access_token??data.token;if(!nextAccess){clearSession();return!1}token.value=nextAccess;if(data.refresh_token)refreshToken.value=data.refresh_token;return!0}export async function refreshSession(){if(inFlightRefresh)return inFlightRefresh;inFlightRefresh=performRefresh().catch(()=>{return!1}).finally(()=>{inFlightRefresh=null});return inFlightRefresh}export async function authFetch(input,init={}){const send=()=>{const target=input.startsWith("http")?input:`${baseUrl}${input}`,headers=new Headers(init.headers);if(token.value)headers.set("Authorization",`Bearer ${token.value}`);headers.set("Accept","application/json");const method=(init.method||"GET").toUpperCase(),pageOrigin=typeof window<"u"?window.location?.origin:void 0,sameOrigin=!!pageOrigin&&new URL(target,pageOrigin).origin===pageOrigin;if(!["GET","HEAD","OPTIONS"].includes(method)&&sameOrigin&&!headers.has(CSRF_COOKIE_NAME)){const csrfToken=readCsrfToken();if(csrfToken)headers.set(CSRF_COOKIE_NAME,csrfToken)}return fetch(target,{credentials:"same-origin",...init,headers})},response=await send();if(response.status!==401||!refreshToken.value)return response;if(!await refreshSession())return response;return await send()}export function useAuth(){function isTwoFactorChallenge(data){if(!data||typeof data!=="object")return!1;const candidate=data;return candidate.requires_two_factor===!0&&typeof candidate.challenge_token==="string"&&candidate.challenge_token.length>0}function isLoginResponse(data){if(!data||typeof data!=="object")return!1;const candidate=data;return typeof candidate.token==="string"&&candidate.token.length>0&&typeof candidate.user==="object"&&candidate.user!==null&&(candidate.refresh_token===void 0||typeof candidate.refresh_token==="string"&&candidate.refresh_token.length>0)}function storeTokenPack(data){token.value=data.token;refreshToken.value=data.refresh_token??""}async function fetchAuthUser(){try{const response=await authFetch(ME_PATH,{cache:"no-store"});if(!response.ok){clearSession();return null}const data=await response.json();user.value=data;isAuthenticated.value=!0;return data}catch(error){console.error("Error fetching user:",error);isAuthenticated.value=!1;user.value=null;return null}}async function checkAuthentication(){try{return await fetchAuthUser()!==null}catch(error){console.error("Error checking authentication:",error);return!1}}async function register(user){const url=`${baseUrl}/register`,data=await(await fetch(url,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify(user)})).json();if(isRegisterError(data))return data;if(isRegisterResponse(data)){token.value=data.token;const refreshed=data.refresh_token;refreshToken.value=refreshed??"";return data}return data}function isRegisterError(data){return"errors"in data}function isRegisterResponse(data){return"token"in data&&"user"in data}async function login(user){try{const url=`${baseUrl}/login`,response=await fetch(url,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify(user)});if(!response.ok){const errorData=await response.json();throw Error(errorData.message||`HTTP error! status: ${response.status}`)}const data=await response.json();if(isTwoFactorChallenge(data))return data;if(!isLoginResponse(data))throw Error("The login response did not contain a valid session.");storeTokenPack(data);await fetchAuthUser();return data}catch(error){return error}}async function verifyTwoFactorLogin(challengeToken,code){try{const response=await fetch(`${baseUrl}/verify-two-factor-login`,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify({challenge_token:challengeToken,code})});if(!response.ok){const errorData=await response.json();throw Error(errorData.message||`HTTP error! status: ${response.status}`)}const data=await response.json();if(!isLoginResponse(data))throw Error("The two-factor response did not contain a valid session.");storeTokenPack(data);await fetchAuthUser();return data}catch(error){return error}}async function logout(){const currentToken=token.value,response=await fetch(`${baseUrl}/logout`,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({...currentToken?{Authorization:`Bearer ${currentToken}`}:{},Accept:"application/json"})});if(!response.ok)throw Error(`Logout failed with status ${response.status}`);clearSession()}async function completeSocialLogin(pack){const applied=applySessionHandoff(pack),confirmed=await fetchAuthUser();if(confirmed||applied)return confirmed;return null}return{user,isAuthenticated,token,getToken:()=>token.value,register,login,verifyTwoFactorLogin,logout,fetchAuthUser,completeSocialLogin,checkAuthentication,refreshToken,getRefreshToken:()=>refreshToken.value,refreshSession,authFetch}}export function authGuard(options={}){const guest=options.guest??!1,{isAuthenticated}=useAuth();if(guest){if(isAuthenticated.value)window.location.replace("/");return}if(!isAuthenticated.value)window.location.replace("/login")}
1
+ import{readSessionHandoff,readSessionHandoffCode,stripSessionHandoff,useStorage}from"@stacksjs/composables";import{ref}from"@stacksjs/stx";import{CSRF_COOKIE_NAME,readCsrfToken,withCsrfHeader}from"./csrf";const token=useStorage("token",""),refreshToken=useStorage("refresh_token",""),stacksConfig=globalThis.__STACKS_CONFIG__||{},baseUrl=stacksConfig.API_URL||(typeof window<"u"?window.location.origin:""),ME_PATH=stacksConfig.AUTH_ME_PATH||"/api/me",REFRESH_PATH=stacksConfig.AUTH_REFRESH_PATH||"/auth/refresh",HANDOFF_PATH=stacksConfig.AUTH_HANDOFF_PATH||"/auth/handoff",user=ref(null),isAuthenticated=ref(!1);let inFlightRefresh=null,sessionEpoch=0,inFlightMe=null;const sessionExpiredListeners=new Set;let guardedPath=null,guardRedirectInstalled=!1;export function onSessionExpired(listener){sessionExpiredListeners.add(listener);return()=>{sessionExpiredListeners.delete(listener)}}function applySessionHandoff(pack){const resolved=pack??(typeof window<"u"?readSessionHandoff(window.location.hash):null);if(!resolved)return!1;sessionEpoch++;token.value=resolved.token;refreshToken.value=resolved.refreshToken??"";if(resolved.user!==void 0&&resolved.user!==null)applyUser(resolved.user);isAuthenticated.value=!0;if(typeof window<"u"&&!pack)stripHandoffFragment();return!0}function stripHandoffFragment(){const rest=stripSessionHandoff(window.location.hash);window.history.replaceState(null,"",`${window.location.pathname}${window.location.search}${rest}`)}async function redeemHandoffCode(code){try{const response=await fetch(`${baseUrl}${HANDOFF_PATH}`,{method:"POST",credentials:"include",headers:withCsrfHeader({"Content-Type":"application/json",Accept:"application/json"}),body:JSON.stringify({code})});if(response.status!==200)return null;const data=await response.json();if(!data||typeof data!=="object")return null;const accessToken=data.access_token??data.token,refresh=data.refresh_token;if(typeof accessToken!=="string"||accessToken.length===0)return null;if(typeof data.user!=="object"||data.user===null)return null;if(refresh!==void 0&&(typeof refresh!=="string"||refresh.length===0))return null;return{token:accessToken,refreshToken:refresh,user:data.user,expiresIn:typeof data.expires_in==="number"?data.expires_in:void 0}}catch(error){console.error("Error redeeming sign-in code:",error);return null}}function clearSession(){sessionEpoch++;token.value="";refreshToken.value="";user.value=null;isAuthenticated.value=!1}function holdsSession(){return!!token.value||!!refreshToken.value||user.value!==null||isAuthenticated.value}function expireSession(epoch,event){if(epoch!==sessionEpoch)return;const hadSession=holdsSession();clearSession();if(!hadSession)return;for(const listener of[...sessionExpiredListeners])try{listener(event)}catch(error){console.error("Error in session expiry listener:",error)}}function sameJson(a,b){if(a===b)return!0;if(typeof a!=="object"||typeof b!=="object"||a===null||b===null)return!1;if(Array.isArray(a)!==Array.isArray(b))return!1;const left=a,right=b,keys=Object.keys(left);if(keys.length!==Object.keys(right).length)return!1;return keys.every((key)=>Object.prototype.hasOwnProperty.call(right,key)&&sameJson(left[key],right[key]))}function applyUser(next){if(!sameJson(user.value,next))user.value=next;if(!isAuthenticated.value)isAuthenticated.value=!0}function isTransientStatus(status){return status>=500||status===408||status===429}async function performRefresh(){if(!refreshToken.value)return!1;const epoch=sessionEpoch,url=`${baseUrl}${REFRESH_PATH}`,response=await fetch(url,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json",Accept:"application/json"}),body:JSON.stringify({refresh_token:refreshToken.value})});if(isTransientStatus(response.status))throw Error(`Refresh unavailable (status ${response.status})`);if(!response.ok){expireSession(epoch,{reason:"refresh-refused",status:response.status,url});return!1}const data=await response.json(),nextAccess=data.access_token??data.token;if(!nextAccess){expireSession(epoch,{reason:"refresh-refused",status:response.status,url});return!1}if(epoch!==sessionEpoch)return!1;token.value=nextAccess;if(data.refresh_token)refreshToken.value=data.refresh_token;return!0}export async function refreshSession(){if(inFlightRefresh)return inFlightRefresh;inFlightRefresh=performRefresh().catch(()=>{return!1}).finally(()=>{inFlightRefresh=null});return inFlightRefresh}function resolveTarget(input){return input.startsWith("http")?input:`${baseUrl}${input}`}function speaksForSession(target){const pageOrigin=typeof window<"u"?window.location?.origin:void 0;try{const origin=new URL(target,pageOrigin).origin;return origin===pageOrigin||!!baseUrl&&origin===new URL(baseUrl,pageOrigin).origin}catch{return!1}}async function sendWithSession(input,init){const epoch=sessionEpoch,target=resolveTarget(input),send=()=>{const headers=new Headers(init.headers);if(token.value)headers.set("Authorization",`Bearer ${token.value}`);headers.set("Accept","application/json");const method=(init.method||"GET").toUpperCase(),pageOrigin=typeof window<"u"?window.location?.origin:void 0,sameOrigin=!!pageOrigin&&new URL(target,pageOrigin).origin===pageOrigin;if(!["GET","HEAD","OPTIONS"].includes(method)&&sameOrigin&&!headers.has(CSRF_COOKIE_NAME)){const csrfToken=readCsrfToken();if(csrfToken)headers.set(CSRF_COOKIE_NAME,csrfToken)}return fetch(target,{credentials:"same-origin",...init,headers})},sentWith=token.value,response=await send();if(response.status!==401||!speaksForSession(target)||epoch!==sessionEpoch)return{response,epoch,unauthenticated:!1};if(!refreshToken.value)return{response,epoch,unauthenticated:!0};if(!(token.value&&token.value!==sentWith?!0:await refreshSession())||epoch!==sessionEpoch)return{response,epoch,unauthenticated:!1};const retried=await send();return{response:retried,epoch,unauthenticated:retried.status===401&&epoch===sessionEpoch}}export async function authFetch(input,init={}){const result=await sendWithSession(input,init);if(result.unauthenticated&&result.epoch===sessionEpoch&&holdsSession())try{await loadAuthUser()}catch{}return result.response}function loadAuthUser(){if(inFlightMe&&inFlightMe.epoch===sessionEpoch)return inFlightMe.promise;const epoch=sessionEpoch,entry={epoch,promise:requestAuthUser(epoch).finally(()=>{if(inFlightMe===entry)inFlightMe=null})};inFlightMe=entry;return entry.promise}async function requestAuthUser(epoch){let result;try{result=await sendWithSession(ME_PATH,{cache:"no-store"})}catch(error){console.error("Error fetching user:",error);return user.value}const{response}=result;if(response.status===401){if(result.unauthenticated)expireSession(epoch,{reason:"unauthorized",status:401,url:resolveTarget(ME_PATH)});return epoch===sessionEpoch?user.value:null}if(!response.ok)return user.value;let data;try{data=await response.json()}catch(error){console.error("Error reading user:",error);return user.value}if(epoch!==sessionEpoch)return user.value;applyUser(data);return user.value}function installGuardRedirect(){if(typeof window>"u")return;guardedPath=window.location?.pathname??null;if(guardRedirectInstalled)return;guardRedirectInstalled=!0;onSessionExpired(()=>{if(typeof window>"u"||guardedPath===null)return;if(window.location?.pathname===guardedPath)window.location.replace("/login")})}export function useAuth(){function isTwoFactorChallenge(data){if(!data||typeof data!=="object")return!1;const candidate=data;return candidate.requires_two_factor===!0&&typeof candidate.challenge_token==="string"&&candidate.challenge_token.length>0}function isLoginResponse(data){if(!data||typeof data!=="object")return!1;const candidate=data;return typeof candidate.token==="string"&&candidate.token.length>0&&typeof candidate.user==="object"&&candidate.user!==null&&(candidate.refresh_token===void 0||typeof candidate.refresh_token==="string"&&candidate.refresh_token.length>0)}function storeTokenPack(data){sessionEpoch++;token.value=data.token;refreshToken.value=data.refresh_token??""}async function fetchAuthUser(){return await loadAuthUser()}async function checkAuthentication(){try{return await fetchAuthUser()!==null}catch(error){console.error("Error checking authentication:",error);return!1}}async function register(user){const url=`${baseUrl}/register`,data=await(await fetch(url,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify(user)})).json();if(isRegisterError(data))return data;if(isRegisterResponse(data)){sessionEpoch++;token.value=data.token;const refreshed=data.refresh_token;refreshToken.value=refreshed??"";return data}return data}function isRegisterError(data){return"errors"in data}function isRegisterResponse(data){return"token"in data&&"user"in data}async function login(user){try{const url=`${baseUrl}/login`,response=await fetch(url,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify(user)});if(!response.ok){const errorData=await response.json();throw Error(errorData.message||`HTTP error! status: ${response.status}`)}const data=await response.json();if(isTwoFactorChallenge(data))return data;if(!isLoginResponse(data))throw Error("The login response did not contain a valid session.");storeTokenPack(data);await fetchAuthUser();return data}catch(error){return error}}async function verifyTwoFactorLogin(challengeToken,code){try{const response=await fetch(`${baseUrl}/verify-two-factor-login`,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({"Content-Type":"application/json"}),body:JSON.stringify({challenge_token:challengeToken,code})});if(!response.ok){const errorData=await response.json();throw Error(errorData.message||`HTTP error! status: ${response.status}`)}const data=await response.json();if(!isLoginResponse(data))throw Error("The two-factor response did not contain a valid session.");storeTokenPack(data);await fetchAuthUser();return data}catch(error){return error}}async function logout(){const currentToken=token.value,response=await fetch(`${baseUrl}/logout`,{method:"POST",credentials:"same-origin",headers:withCsrfHeader({...currentToken?{Authorization:`Bearer ${currentToken}`}:{},Accept:"application/json"})});if(!response.ok)throw Error(`Logout failed with status ${response.status}`);clearSession()}async function completeSocialLogin(pack){let applied=!1;const code=!pack&&typeof window<"u"?readSessionHandoffCode(window.location.hash):null;if(code){stripHandoffFragment();const redeemed=await redeemHandoffCode(code);if(!redeemed)return null;applied=applySessionHandoff(redeemed)}if(!applied)applied=applySessionHandoff(pack);const confirmed=await fetchAuthUser();if(confirmed||applied)return confirmed;return null}return{user,isAuthenticated,token,getToken:()=>token.value,register,login,verifyTwoFactorLogin,logout,fetchAuthUser,onSessionExpired,completeSocialLogin,checkAuthentication,refreshToken,getRefreshToken:()=>refreshToken.value,refreshSession,authFetch}}export function authGuard(options={}){const guest=options.guest??!1,{isAuthenticated,fetchAuthUser}=useAuth();if(guest){if(isAuthenticated.value)window.location.replace("/");return}if(isAuthenticated.value){installGuardRedirect();return}if(token.value){installGuardRedirect();fetchAuthUser();return}window.location.replace("/login")}
@@ -59,6 +59,19 @@ export declare interface UserData {
59
59
  email: string
60
60
  name: string
61
61
  }
62
+ /**
63
+ * Why `useAuth` ended a session on its own, as passed to `onSessionExpired`
64
+ * listeners.
65
+ *
66
+ * - `unauthorized`: the identity endpoint answered 401 for the current
67
+ * credential, either directly or when a 401 elsewhere made the client check.
68
+ * - `refresh-refused`: the server refused to exchange the refresh token.
69
+ */
70
+ export declare interface SessionExpiredEvent {
71
+ reason: 'unauthorized' | 'refresh-refused'
72
+ status: number
73
+ url: string
74
+ }
62
75
  export declare interface AuthComposable {
63
76
  isAuthenticated: Ref<boolean>
64
77
  user: Ref<UserData | null>
@@ -66,6 +79,7 @@ export declare interface AuthComposable {
66
79
  verifyTwoFactorLogin: (challengeToken: string, code: string) => Promise<LoginResponse | LoginError>
67
80
  register: (user: AuthUser) => Promise<RegisterResponse | RegisterError>
68
81
  fetchAuthUser: () => Promise<UserData | null>
82
+ onSessionExpired: (listener: SessionExpiredListener) => () => void
69
83
  completeSocialLogin: (pack?: SessionHandoffPack | null) => Promise<UserData | null>
70
84
  checkAuthentication: () => Promise<boolean>
71
85
  logout: () => Promise<void>
@@ -82,3 +96,4 @@ export type ResponseError = {
82
96
  export type LoginError = RegisterError;
83
97
  export type LoginResult = LoginResponse | TwoFactorLoginChallenge;
84
98
  export type MeResponse = UserData;
99
+ export type SessionExpiredListener = (event: SessionExpiredEvent) => void;
@@ -15,6 +15,7 @@ export declare interface StacksBrowserConfig {
15
15
  API_URL?: string
16
16
  AUTH_ME_PATH?: string
17
17
  AUTH_REFRESH_PATH?: string
18
+ AUTH_HANDOFF_PATH?: string
18
19
  FRONTEND_STRIPE_PUBLIC_KEY?: string
19
20
  [key: string]: unknown
20
21
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser",
3
3
  "type": "module",
4
- "version": "0.75.228",
4
+ "version": "0.75.229",
5
5
  "description": "Stacks core frontend/browser functionalities.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -74,9 +74,9 @@
74
74
  "prepublishOnly": "bun run build"
75
75
  },
76
76
  "dependencies": {
77
- "@stacksjs/composables": "0.75.228",
78
- "@stacksjs/datetime": "0.75.228",
79
- "@stacksjs/strings": "0.75.228",
77
+ "@stacksjs/composables": "0.75.229",
78
+ "@stacksjs/datetime": "0.75.229",
79
+ "@stacksjs/strings": "0.75.229",
80
80
  "@stacksjs/stx": "^0.2.567",
81
81
  "bun-query-builder": "^0.3.8"
82
82
  },
@@ -89,7 +89,7 @@
89
89
  }
90
90
  },
91
91
  "devDependencies": {
92
- "@stacksjs/utils": "0.75.228",
92
+ "@stacksjs/utils": "0.75.229",
93
93
  "@stripe/stripe-js": "^10.0.0",
94
94
  "better-dx": "^0.2.24"
95
95
  }