@grantjs/client 1.18.2 → 1.18.3

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,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/typeof.js
1
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/typeof.js
2
2
  function _typeof(o) {
3
3
  "@babel/helpers - typeof";
4
4
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -7,8 +7,7 @@ function _typeof(o) {
7
7
  return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
8
8
  }, _typeof(o);
9
9
  }
10
- //#endregion
11
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPrimitive.js
10
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/toPrimitive.js
12
11
  function toPrimitive(t, r) {
13
12
  if ("object" != _typeof(t) || !t) return t;
14
13
  var e = t[Symbol.toPrimitive];
@@ -19,14 +18,12 @@ function toPrimitive(t, r) {
19
18
  }
20
19
  return ("string" === r ? String : Number)(t);
21
20
  }
22
- //#endregion
23
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPropertyKey.js
21
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/toPropertyKey.js
24
22
  function toPropertyKey(t) {
25
23
  var i = toPrimitive(t, "string");
26
24
  return "symbol" == _typeof(i) ? i : i + "";
27
25
  }
28
- //#endregion
29
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/defineProperty.js
26
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/defineProperty.js
30
27
  function _defineProperty(e, r, t) {
31
28
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
32
29
  value: t,
@@ -35,8 +32,7 @@ function _defineProperty(e, r, t) {
35
32
  writable: !0
36
33
  }) : e[r] = t, e;
37
34
  }
38
- //#endregion
39
- //#region src/grant-client.ts
35
+ // src/grant-client.ts
40
36
  /**
41
37
  * Module-level shared promise for cookie-only credential refresh so that all 401s
42
38
  * (across all GrantClient instances and in-flight requests) coalesce into one refresh.
@@ -250,7 +246,6 @@ var GrantClient = class {
250
246
  });
251
247
  }
252
248
  };
253
- //#endregion
254
249
  Object.defineProperty(exports, "GrantClient", {
255
250
  enumerable: true,
256
251
  get: function() {
@@ -258,4 +253,4 @@ Object.defineProperty(exports, "GrantClient", {
258
253
  }
259
254
  });
260
255
 
261
- //# sourceMappingURL=grant-client-CtKNEfr0.cjs.map
256
+ //# sourceMappingURL=grant-client-CKB10qTv.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"grant-client-CtKNEfr0.cjs","names":[],"sources":["../src/grant-client.ts"],"sourcesContent":["import type {\n AuthorizationResult,\n GrantClientConfig,\n PermissionQueryOptions,\n Scope,\n SignInWithProjectAppOptions,\n} from './types';\n\n/**\n * Module-level shared promise for cookie-only credential refresh so that all 401s\n * (across all GrantClient instances and in-flight requests) coalesce into one refresh.\n */\nlet sharedCredentialsRefreshPromise: Promise<boolean> | null = null;\n\n/**\n * Module-level shared promise for MFA step-up so that concurrent 403 MFA_REQUIRED\n * responses coalesce into a single step-up dialog.\n */\nlet sharedMfaStepUpPromise: Promise<boolean> | null = null;\n\n/**\n * Grant Client for browser applications\n *\n * Makes HTTP requests to the Grant API to check permissions\n * and retrieve authorization data. Supports both token-based\n * and cookie-based authentication with automatic token refresh.\n */\nexport class GrantClient {\n private config: Required<Pick<GrantClientConfig, 'apiUrl'>> & GrantClientConfig;\n private cache: Map<string, { data: unknown; expires: number }> = new Map();\n private defaultTtl: number;\n\n constructor(config: GrantClientConfig) {\n this.config = config;\n this.defaultTtl = config.cache?.ttl ?? 5 * 60 * 1000; // 5 minutes default\n }\n\n // ============================================================================\n // Public API - Permission Checks\n // ============================================================================\n\n /**\n * Check if the current user has a specific permission\n *\n * @example\n * ```ts\n * const canEdit = await grant.can('document', 'update');\n * if (canEdit) {\n * // Show edit button\n * }\n * ```\n */\n async can(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean> {\n const result = await this.isAuthorized(resource, action, options);\n return result.authorized;\n }\n\n /**\n * Alias for `can` - check if user has permission\n */\n async hasPermission(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<boolean> {\n return this.can(resource, action, options);\n }\n\n // ============================================================================\n // Public API - Project OAuth (sign-in with project app)\n // ============================================================================\n\n /**\n * Start project-app OAuth flow (redirect only).\n * Navigates the current window to the Grant OAuth entry page. Pass a client-generated\n * S256 `codeChallenge`; hosted pages forward it to `GET /api/auth/project/authorize`.\n * After consent the user returns to `redirect_uri?code=&state=`. Exchange the code at\n * `POST /api/auth/project/token` with `grant_type=authorization_code` and the PKCE\n * `code_verifier` you stored (do not expect `#access_token=`).\n *\n * Requires `config.frontendUrl` and `redirectUri`.\n */\n async signInWithProjectApp(options: SignInWithProjectAppOptions): Promise<void> {\n const frontendUrl = this.config.frontendUrl;\n if (!frontendUrl) {\n throw new Error('GrantClient: frontendUrl is required for signInWithProjectApp');\n }\n const locale = options.locale ?? 'en';\n const redirectUri = options.redirectUri;\n if (!redirectUri) {\n throw new Error('redirectUri is required for signInWithProjectApp');\n }\n\n const entryPath = `/${locale}/auth/project`;\n const params = new URLSearchParams({\n client_id: options.clientId,\n redirect_uri: redirectUri,\n state: options.state ?? '',\n });\n if (options.scope) params.set('scope', options.scope);\n if (options.codeChallenge?.trim()) {\n params.set('code_challenge', options.codeChallenge.trim());\n params.set('code_challenge_method', options.codeChallengeMethod ?? 'S256');\n }\n\n const entryUrl = `${frontendUrl.replace(/\\/$/, '')}${entryPath}?${params.toString()}`;\n if (typeof window !== 'undefined') {\n window.location.href = entryUrl;\n }\n }\n\n /**\n * Check authorization with full result details\n *\n * @example\n * ```ts\n * const result = await grant.isAuthorized('document', 'update');\n * if (!result.authorized) {\n * console.log('Denied:', result.reason);\n * }\n * ```\n */\n async isAuthorized(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<AuthorizationResult> {\n const contextResourceKey =\n options?.context?.resource != null ? JSON.stringify(options.context.resource) : undefined;\n const cacheKey = this.getCacheKey('auth', resource, action, options?.scope, contextResourceKey);\n\n // Check cache first (unless explicitly disabled)\n if (options?.useCache !== false) {\n const cached = this.getFromCache<AuthorizationResult>(cacheKey);\n if (cached) return cached;\n }\n\n try {\n // API expects: { permission: { resource, action }, context: { resource?: any }, scope?: { tenant, id } }\n // scope is optional - for session tokens it enables dynamic scope switching\n const scope = options?.scope;\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope;\n // When scope is provided and context.resource is not, derive context.resource from scope.id\n const contextResource =\n options?.context?.resource ??\n (hasValidScope && scope && 'id' in scope && scope.id != null ? { id: scope.id } : null);\n\n const response = await this.fetchWithAuth('/api/auth/is-authorized', {\n method: 'POST',\n body: JSON.stringify({\n permission: {\n resource,\n action,\n },\n context: {\n resource: contextResource,\n },\n // Pass scope for dynamic scope override (only works with session tokens)\n ...(hasValidScope && { scope }),\n }),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n return {\n authorized: false,\n reason: error.message || `API error: ${response.status}`,\n };\n }\n\n const json = await response.json();\n // API returns { success: true, data: { authorized, ... } }\n const result: AuthorizationResult = json.data ?? json;\n this.setCache(cacheKey, result);\n return result;\n } catch (error) {\n return {\n authorized: false,\n reason: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n // ============================================================================\n // Public API - Cache Management\n // ============================================================================\n\n /**\n * Clear all cached data\n */\n clearCache(): void {\n this.cache.clear();\n }\n\n /**\n * Clear cached data for a specific scope\n */\n clearScopeCache(scope?: Scope): void {\n const scopeKey = scope ? JSON.stringify(scope) : 'default';\n for (const key of this.cache.keys()) {\n if (key.includes(scopeKey)) {\n this.cache.delete(key);\n }\n }\n }\n\n // ============================================================================\n // Private - HTTP & Authentication\n // ============================================================================\n\n /**\n * Make an authenticated fetch request with automatic token refresh on 401\n * and MFA step-up on 403 MFA_REQUIRED.\n */\n private async fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {\n const response = await this.doFetch(url, init);\n\n if (response.status === 403 && this.config.onMfaRequired) {\n const cloned = response.clone();\n const body = await cloned.json().catch(() => null);\n if (body && (body.code === 'MFA_REQUIRED' || body.extensions?.reason === 'MFA_REQUIRED')) {\n if (!sharedMfaStepUpPromise) {\n sharedMfaStepUpPromise = this.config.onMfaRequired().finally(() => {\n sharedMfaStepUpPromise = null;\n });\n }\n const verified = await sharedMfaStepUpPromise;\n if (verified) return this.doFetch(url, init);\n }\n }\n\n if (response.status !== 401) return response;\n\n // Cookie-based refresh (HttpOnly refresh cookie). Body-based refresh is not supported.\n // Module-level shared promise so all 401s (any client instance) coalesce into one refresh.\n if (this.config.onRefreshWithCredentials) {\n if (!sharedCredentialsRefreshPromise) {\n sharedCredentialsRefreshPromise = this.config.onRefreshWithCredentials().finally(() => {\n sharedCredentialsRefreshPromise = null;\n });\n }\n const refreshed = await sharedCredentialsRefreshPromise;\n if (refreshed) return this.doFetch(url, init);\n this.config.onUnauthorized?.();\n }\n\n return response;\n }\n\n /**\n * Perform the actual fetch request\n */\n private async doFetch(url: string, init?: RequestInit): Promise<Response> {\n const fetchFn = this.config.fetch ?? globalThis.fetch;\n const fullUrl = url.startsWith('http') ? url : `${this.config.apiUrl}${url}`;\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(init?.headers as Record<string, string>),\n };\n\n // Add authorization header if token is available\n const token = await this.getToken();\n if (token) {\n headers['Authorization'] = `Bearer ${token}`;\n }\n\n return fetchFn(fullUrl, {\n ...init,\n headers,\n // Include cookies for same-origin requests (supports cookie-based auth)\n credentials: this.config.credentials ?? 'include',\n });\n }\n\n /**\n * Get the current access token\n */\n private async getToken(): Promise<string | null> {\n if (this.config.getAccessToken) {\n const token = this.config.getAccessToken();\n return token instanceof Promise ? token : token;\n }\n return null;\n }\n\n // ============================================================================\n // Private - Cache & URL Helpers\n // ============================================================================\n\n private buildUrl(path: string, scope?: Scope): string {\n const url = new URL(path, this.config.apiUrl);\n if (scope) {\n url.searchParams.set('scope', JSON.stringify(scope));\n }\n return url.toString();\n }\n\n private getCacheKey(...parts: (string | Scope | undefined)[]): string {\n const prefix = this.config.cache?.prefix ?? 'grant';\n return `${prefix}:${parts.map((p) => (p ? JSON.stringify(p) : 'default')).join(':')}`;\n }\n\n private getFromCache<T>(key: string): T | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expires) {\n this.cache.delete(key);\n return null;\n }\n\n return entry.data as T;\n }\n\n private setCache(key: string, data: unknown): void {\n this.cache.set(key, {\n data,\n expires: Date.now() + this.defaultTtl,\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"grant-client-CKB10qTv.cjs","names":[],"sources":["../src/grant-client.ts"],"sourcesContent":["import type {\n AuthorizationResult,\n GrantClientConfig,\n PermissionQueryOptions,\n Scope,\n SignInWithProjectAppOptions,\n} from './types';\n\n/**\n * Module-level shared promise for cookie-only credential refresh so that all 401s\n * (across all GrantClient instances and in-flight requests) coalesce into one refresh.\n */\nlet sharedCredentialsRefreshPromise: Promise<boolean> | null = null;\n\n/**\n * Module-level shared promise for MFA step-up so that concurrent 403 MFA_REQUIRED\n * responses coalesce into a single step-up dialog.\n */\nlet sharedMfaStepUpPromise: Promise<boolean> | null = null;\n\n/**\n * Grant Client for browser applications\n *\n * Makes HTTP requests to the Grant API to check permissions\n * and retrieve authorization data. Supports both token-based\n * and cookie-based authentication with automatic token refresh.\n */\nexport class GrantClient {\n private config: Required<Pick<GrantClientConfig, 'apiUrl'>> & GrantClientConfig;\n private cache: Map<string, { data: unknown; expires: number }> = new Map();\n private defaultTtl: number;\n\n constructor(config: GrantClientConfig) {\n this.config = config;\n this.defaultTtl = config.cache?.ttl ?? 5 * 60 * 1000; // 5 minutes default\n }\n\n // ============================================================================\n // Public API - Permission Checks\n // ============================================================================\n\n /**\n * Check if the current user has a specific permission\n *\n * @example\n * ```ts\n * const canEdit = await grant.can('document', 'update');\n * if (canEdit) {\n * // Show edit button\n * }\n * ```\n */\n async can(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean> {\n const result = await this.isAuthorized(resource, action, options);\n return result.authorized;\n }\n\n /**\n * Alias for `can` - check if user has permission\n */\n async hasPermission(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<boolean> {\n return this.can(resource, action, options);\n }\n\n // ============================================================================\n // Public API - Project OAuth (sign-in with project app)\n // ============================================================================\n\n /**\n * Start project-app OAuth flow (redirect only).\n * Navigates the current window to the Grant OAuth entry page. Pass a client-generated\n * S256 `codeChallenge`; hosted pages forward it to `GET /api/auth/project/authorize`.\n * After consent the user returns to `redirect_uri?code=&state=`. Exchange the code at\n * `POST /api/auth/project/token` with `grant_type=authorization_code` and the PKCE\n * `code_verifier` you stored (do not expect `#access_token=`).\n *\n * Requires `config.frontendUrl` and `redirectUri`.\n */\n async signInWithProjectApp(options: SignInWithProjectAppOptions): Promise<void> {\n const frontendUrl = this.config.frontendUrl;\n if (!frontendUrl) {\n throw new Error('GrantClient: frontendUrl is required for signInWithProjectApp');\n }\n const locale = options.locale ?? 'en';\n const redirectUri = options.redirectUri;\n if (!redirectUri) {\n throw new Error('redirectUri is required for signInWithProjectApp');\n }\n\n const entryPath = `/${locale}/auth/project`;\n const params = new URLSearchParams({\n client_id: options.clientId,\n redirect_uri: redirectUri,\n state: options.state ?? '',\n });\n if (options.scope) params.set('scope', options.scope);\n if (options.codeChallenge?.trim()) {\n params.set('code_challenge', options.codeChallenge.trim());\n params.set('code_challenge_method', options.codeChallengeMethod ?? 'S256');\n }\n\n const entryUrl = `${frontendUrl.replace(/\\/$/, '')}${entryPath}?${params.toString()}`;\n if (typeof window !== 'undefined') {\n window.location.href = entryUrl;\n }\n }\n\n /**\n * Check authorization with full result details\n *\n * @example\n * ```ts\n * const result = await grant.isAuthorized('document', 'update');\n * if (!result.authorized) {\n * console.log('Denied:', result.reason);\n * }\n * ```\n */\n async isAuthorized(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<AuthorizationResult> {\n const contextResourceKey =\n options?.context?.resource != null ? JSON.stringify(options.context.resource) : undefined;\n const cacheKey = this.getCacheKey('auth', resource, action, options?.scope, contextResourceKey);\n\n // Check cache first (unless explicitly disabled)\n if (options?.useCache !== false) {\n const cached = this.getFromCache<AuthorizationResult>(cacheKey);\n if (cached) return cached;\n }\n\n try {\n // API expects: { permission: { resource, action }, context: { resource?: any }, scope?: { tenant, id } }\n // scope is optional - for session tokens it enables dynamic scope switching\n const scope = options?.scope;\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope;\n // When scope is provided and context.resource is not, derive context.resource from scope.id\n const contextResource =\n options?.context?.resource ??\n (hasValidScope && scope && 'id' in scope && scope.id != null ? { id: scope.id } : null);\n\n const response = await this.fetchWithAuth('/api/auth/is-authorized', {\n method: 'POST',\n body: JSON.stringify({\n permission: {\n resource,\n action,\n },\n context: {\n resource: contextResource,\n },\n // Pass scope for dynamic scope override (only works with session tokens)\n ...(hasValidScope && { scope }),\n }),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n return {\n authorized: false,\n reason: error.message || `API error: ${response.status}`,\n };\n }\n\n const json = await response.json();\n // API returns { success: true, data: { authorized, ... } }\n const result: AuthorizationResult = json.data ?? json;\n this.setCache(cacheKey, result);\n return result;\n } catch (error) {\n return {\n authorized: false,\n reason: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n // ============================================================================\n // Public API - Cache Management\n // ============================================================================\n\n /**\n * Clear all cached data\n */\n clearCache(): void {\n this.cache.clear();\n }\n\n /**\n * Clear cached data for a specific scope\n */\n clearScopeCache(scope?: Scope): void {\n const scopeKey = scope ? JSON.stringify(scope) : 'default';\n for (const key of this.cache.keys()) {\n if (key.includes(scopeKey)) {\n this.cache.delete(key);\n }\n }\n }\n\n // ============================================================================\n // Private - HTTP & Authentication\n // ============================================================================\n\n /**\n * Make an authenticated fetch request with automatic token refresh on 401\n * and MFA step-up on 403 MFA_REQUIRED.\n */\n private async fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {\n const response = await this.doFetch(url, init);\n\n if (response.status === 403 && this.config.onMfaRequired) {\n const cloned = response.clone();\n const body = await cloned.json().catch(() => null);\n if (body && (body.code === 'MFA_REQUIRED' || body.extensions?.reason === 'MFA_REQUIRED')) {\n if (!sharedMfaStepUpPromise) {\n sharedMfaStepUpPromise = this.config.onMfaRequired().finally(() => {\n sharedMfaStepUpPromise = null;\n });\n }\n const verified = await sharedMfaStepUpPromise;\n if (verified) return this.doFetch(url, init);\n }\n }\n\n if (response.status !== 401) return response;\n\n // Cookie-based refresh (HttpOnly refresh cookie). Body-based refresh is not supported.\n // Module-level shared promise so all 401s (any client instance) coalesce into one refresh.\n if (this.config.onRefreshWithCredentials) {\n if (!sharedCredentialsRefreshPromise) {\n sharedCredentialsRefreshPromise = this.config.onRefreshWithCredentials().finally(() => {\n sharedCredentialsRefreshPromise = null;\n });\n }\n const refreshed = await sharedCredentialsRefreshPromise;\n if (refreshed) return this.doFetch(url, init);\n this.config.onUnauthorized?.();\n }\n\n return response;\n }\n\n /**\n * Perform the actual fetch request\n */\n private async doFetch(url: string, init?: RequestInit): Promise<Response> {\n const fetchFn = this.config.fetch ?? globalThis.fetch;\n const fullUrl = url.startsWith('http') ? url : `${this.config.apiUrl}${url}`;\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(init?.headers as Record<string, string>),\n };\n\n // Add authorization header if token is available\n const token = await this.getToken();\n if (token) {\n headers['Authorization'] = `Bearer ${token}`;\n }\n\n return fetchFn(fullUrl, {\n ...init,\n headers,\n // Include cookies for same-origin requests (supports cookie-based auth)\n credentials: this.config.credentials ?? 'include',\n });\n }\n\n /**\n * Get the current access token\n */\n private async getToken(): Promise<string | null> {\n if (this.config.getAccessToken) {\n const token = this.config.getAccessToken();\n return token instanceof Promise ? token : token;\n }\n return null;\n }\n\n // ============================================================================\n // Private - Cache & URL Helpers\n // ============================================================================\n\n private buildUrl(path: string, scope?: Scope): string {\n const url = new URL(path, this.config.apiUrl);\n if (scope) {\n url.searchParams.set('scope', JSON.stringify(scope));\n }\n return url.toString();\n }\n\n private getCacheKey(...parts: (string | Scope | undefined)[]): string {\n const prefix = this.config.cache?.prefix ?? 'grant';\n return `${prefix}:${parts.map((p) => (p ? JSON.stringify(p) : 'default')).join(':')}`;\n }\n\n private getFromCache<T>(key: string): T | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expires) {\n this.cache.delete(key);\n return null;\n }\n\n return entry.data as T;\n }\n\n private setCache(key: string, data: unknown): void {\n this.cache.set(key, {\n data,\n expires: Date.now() + this.defaultTtl,\n });\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/typeof.js
1
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/typeof.js
2
2
  function _typeof(o) {
3
3
  "@babel/helpers - typeof";
4
4
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -7,8 +7,7 @@ function _typeof(o) {
7
7
  return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
8
8
  }, _typeof(o);
9
9
  }
10
- //#endregion
11
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPrimitive.js
10
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/toPrimitive.js
12
11
  function toPrimitive(t, r) {
13
12
  if ("object" != _typeof(t) || !t) return t;
14
13
  var e = t[Symbol.toPrimitive];
@@ -19,14 +18,12 @@ function toPrimitive(t, r) {
19
18
  }
20
19
  return ("string" === r ? String : Number)(t);
21
20
  }
22
- //#endregion
23
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPropertyKey.js
21
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/toPropertyKey.js
24
22
  function toPropertyKey(t) {
25
23
  var i = toPrimitive(t, "string");
26
24
  return "symbol" == _typeof(i) ? i : i + "";
27
25
  }
28
- //#endregion
29
- //#region \0@oxc-project+runtime@0.153.0/helpers/esm/defineProperty.js
26
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/defineProperty.js
30
27
  function _defineProperty(e, r, t) {
31
28
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
32
29
  value: t,
@@ -35,8 +32,7 @@ function _defineProperty(e, r, t) {
35
32
  writable: !0
36
33
  }) : e[r] = t, e;
37
34
  }
38
- //#endregion
39
- //#region src/grant-client.ts
35
+ // src/grant-client.ts
40
36
  /**
41
37
  * Module-level shared promise for cookie-only credential refresh so that all 401s
42
38
  * (across all GrantClient instances and in-flight requests) coalesce into one refresh.
@@ -250,7 +246,6 @@ var GrantClient = class {
250
246
  });
251
247
  }
252
248
  };
253
- //#endregion
254
249
  export { GrantClient as t };
255
250
 
256
- //# sourceMappingURL=grant-client-TOsIRSIZ.js.map
251
+ //# sourceMappingURL=grant-client-CMH9cZX1.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"grant-client-TOsIRSIZ.js","names":[],"sources":["../src/grant-client.ts"],"sourcesContent":["import type {\n AuthorizationResult,\n GrantClientConfig,\n PermissionQueryOptions,\n Scope,\n SignInWithProjectAppOptions,\n} from './types';\n\n/**\n * Module-level shared promise for cookie-only credential refresh so that all 401s\n * (across all GrantClient instances and in-flight requests) coalesce into one refresh.\n */\nlet sharedCredentialsRefreshPromise: Promise<boolean> | null = null;\n\n/**\n * Module-level shared promise for MFA step-up so that concurrent 403 MFA_REQUIRED\n * responses coalesce into a single step-up dialog.\n */\nlet sharedMfaStepUpPromise: Promise<boolean> | null = null;\n\n/**\n * Grant Client for browser applications\n *\n * Makes HTTP requests to the Grant API to check permissions\n * and retrieve authorization data. Supports both token-based\n * and cookie-based authentication with automatic token refresh.\n */\nexport class GrantClient {\n private config: Required<Pick<GrantClientConfig, 'apiUrl'>> & GrantClientConfig;\n private cache: Map<string, { data: unknown; expires: number }> = new Map();\n private defaultTtl: number;\n\n constructor(config: GrantClientConfig) {\n this.config = config;\n this.defaultTtl = config.cache?.ttl ?? 5 * 60 * 1000; // 5 minutes default\n }\n\n // ============================================================================\n // Public API - Permission Checks\n // ============================================================================\n\n /**\n * Check if the current user has a specific permission\n *\n * @example\n * ```ts\n * const canEdit = await grant.can('document', 'update');\n * if (canEdit) {\n * // Show edit button\n * }\n * ```\n */\n async can(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean> {\n const result = await this.isAuthorized(resource, action, options);\n return result.authorized;\n }\n\n /**\n * Alias for `can` - check if user has permission\n */\n async hasPermission(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<boolean> {\n return this.can(resource, action, options);\n }\n\n // ============================================================================\n // Public API - Project OAuth (sign-in with project app)\n // ============================================================================\n\n /**\n * Start project-app OAuth flow (redirect only).\n * Navigates the current window to the Grant OAuth entry page. Pass a client-generated\n * S256 `codeChallenge`; hosted pages forward it to `GET /api/auth/project/authorize`.\n * After consent the user returns to `redirect_uri?code=&state=`. Exchange the code at\n * `POST /api/auth/project/token` with `grant_type=authorization_code` and the PKCE\n * `code_verifier` you stored (do not expect `#access_token=`).\n *\n * Requires `config.frontendUrl` and `redirectUri`.\n */\n async signInWithProjectApp(options: SignInWithProjectAppOptions): Promise<void> {\n const frontendUrl = this.config.frontendUrl;\n if (!frontendUrl) {\n throw new Error('GrantClient: frontendUrl is required for signInWithProjectApp');\n }\n const locale = options.locale ?? 'en';\n const redirectUri = options.redirectUri;\n if (!redirectUri) {\n throw new Error('redirectUri is required for signInWithProjectApp');\n }\n\n const entryPath = `/${locale}/auth/project`;\n const params = new URLSearchParams({\n client_id: options.clientId,\n redirect_uri: redirectUri,\n state: options.state ?? '',\n });\n if (options.scope) params.set('scope', options.scope);\n if (options.codeChallenge?.trim()) {\n params.set('code_challenge', options.codeChallenge.trim());\n params.set('code_challenge_method', options.codeChallengeMethod ?? 'S256');\n }\n\n const entryUrl = `${frontendUrl.replace(/\\/$/, '')}${entryPath}?${params.toString()}`;\n if (typeof window !== 'undefined') {\n window.location.href = entryUrl;\n }\n }\n\n /**\n * Check authorization with full result details\n *\n * @example\n * ```ts\n * const result = await grant.isAuthorized('document', 'update');\n * if (!result.authorized) {\n * console.log('Denied:', result.reason);\n * }\n * ```\n */\n async isAuthorized(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<AuthorizationResult> {\n const contextResourceKey =\n options?.context?.resource != null ? JSON.stringify(options.context.resource) : undefined;\n const cacheKey = this.getCacheKey('auth', resource, action, options?.scope, contextResourceKey);\n\n // Check cache first (unless explicitly disabled)\n if (options?.useCache !== false) {\n const cached = this.getFromCache<AuthorizationResult>(cacheKey);\n if (cached) return cached;\n }\n\n try {\n // API expects: { permission: { resource, action }, context: { resource?: any }, scope?: { tenant, id } }\n // scope is optional - for session tokens it enables dynamic scope switching\n const scope = options?.scope;\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope;\n // When scope is provided and context.resource is not, derive context.resource from scope.id\n const contextResource =\n options?.context?.resource ??\n (hasValidScope && scope && 'id' in scope && scope.id != null ? { id: scope.id } : null);\n\n const response = await this.fetchWithAuth('/api/auth/is-authorized', {\n method: 'POST',\n body: JSON.stringify({\n permission: {\n resource,\n action,\n },\n context: {\n resource: contextResource,\n },\n // Pass scope for dynamic scope override (only works with session tokens)\n ...(hasValidScope && { scope }),\n }),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n return {\n authorized: false,\n reason: error.message || `API error: ${response.status}`,\n };\n }\n\n const json = await response.json();\n // API returns { success: true, data: { authorized, ... } }\n const result: AuthorizationResult = json.data ?? json;\n this.setCache(cacheKey, result);\n return result;\n } catch (error) {\n return {\n authorized: false,\n reason: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n // ============================================================================\n // Public API - Cache Management\n // ============================================================================\n\n /**\n * Clear all cached data\n */\n clearCache(): void {\n this.cache.clear();\n }\n\n /**\n * Clear cached data for a specific scope\n */\n clearScopeCache(scope?: Scope): void {\n const scopeKey = scope ? JSON.stringify(scope) : 'default';\n for (const key of this.cache.keys()) {\n if (key.includes(scopeKey)) {\n this.cache.delete(key);\n }\n }\n }\n\n // ============================================================================\n // Private - HTTP & Authentication\n // ============================================================================\n\n /**\n * Make an authenticated fetch request with automatic token refresh on 401\n * and MFA step-up on 403 MFA_REQUIRED.\n */\n private async fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {\n const response = await this.doFetch(url, init);\n\n if (response.status === 403 && this.config.onMfaRequired) {\n const cloned = response.clone();\n const body = await cloned.json().catch(() => null);\n if (body && (body.code === 'MFA_REQUIRED' || body.extensions?.reason === 'MFA_REQUIRED')) {\n if (!sharedMfaStepUpPromise) {\n sharedMfaStepUpPromise = this.config.onMfaRequired().finally(() => {\n sharedMfaStepUpPromise = null;\n });\n }\n const verified = await sharedMfaStepUpPromise;\n if (verified) return this.doFetch(url, init);\n }\n }\n\n if (response.status !== 401) return response;\n\n // Cookie-based refresh (HttpOnly refresh cookie). Body-based refresh is not supported.\n // Module-level shared promise so all 401s (any client instance) coalesce into one refresh.\n if (this.config.onRefreshWithCredentials) {\n if (!sharedCredentialsRefreshPromise) {\n sharedCredentialsRefreshPromise = this.config.onRefreshWithCredentials().finally(() => {\n sharedCredentialsRefreshPromise = null;\n });\n }\n const refreshed = await sharedCredentialsRefreshPromise;\n if (refreshed) return this.doFetch(url, init);\n this.config.onUnauthorized?.();\n }\n\n return response;\n }\n\n /**\n * Perform the actual fetch request\n */\n private async doFetch(url: string, init?: RequestInit): Promise<Response> {\n const fetchFn = this.config.fetch ?? globalThis.fetch;\n const fullUrl = url.startsWith('http') ? url : `${this.config.apiUrl}${url}`;\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(init?.headers as Record<string, string>),\n };\n\n // Add authorization header if token is available\n const token = await this.getToken();\n if (token) {\n headers['Authorization'] = `Bearer ${token}`;\n }\n\n return fetchFn(fullUrl, {\n ...init,\n headers,\n // Include cookies for same-origin requests (supports cookie-based auth)\n credentials: this.config.credentials ?? 'include',\n });\n }\n\n /**\n * Get the current access token\n */\n private async getToken(): Promise<string | null> {\n if (this.config.getAccessToken) {\n const token = this.config.getAccessToken();\n return token instanceof Promise ? token : token;\n }\n return null;\n }\n\n // ============================================================================\n // Private - Cache & URL Helpers\n // ============================================================================\n\n private buildUrl(path: string, scope?: Scope): string {\n const url = new URL(path, this.config.apiUrl);\n if (scope) {\n url.searchParams.set('scope', JSON.stringify(scope));\n }\n return url.toString();\n }\n\n private getCacheKey(...parts: (string | Scope | undefined)[]): string {\n const prefix = this.config.cache?.prefix ?? 'grant';\n return `${prefix}:${parts.map((p) => (p ? JSON.stringify(p) : 'default')).join(':')}`;\n }\n\n private getFromCache<T>(key: string): T | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expires) {\n this.cache.delete(key);\n return null;\n }\n\n return entry.data as T;\n }\n\n private setCache(key: string, data: unknown): void {\n this.cache.set(key, {\n data,\n expires: Date.now() + this.defaultTtl,\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"grant-client-CMH9cZX1.js","names":[],"sources":["../src/grant-client.ts"],"sourcesContent":["import type {\n AuthorizationResult,\n GrantClientConfig,\n PermissionQueryOptions,\n Scope,\n SignInWithProjectAppOptions,\n} from './types';\n\n/**\n * Module-level shared promise for cookie-only credential refresh so that all 401s\n * (across all GrantClient instances and in-flight requests) coalesce into one refresh.\n */\nlet sharedCredentialsRefreshPromise: Promise<boolean> | null = null;\n\n/**\n * Module-level shared promise for MFA step-up so that concurrent 403 MFA_REQUIRED\n * responses coalesce into a single step-up dialog.\n */\nlet sharedMfaStepUpPromise: Promise<boolean> | null = null;\n\n/**\n * Grant Client for browser applications\n *\n * Makes HTTP requests to the Grant API to check permissions\n * and retrieve authorization data. Supports both token-based\n * and cookie-based authentication with automatic token refresh.\n */\nexport class GrantClient {\n private config: Required<Pick<GrantClientConfig, 'apiUrl'>> & GrantClientConfig;\n private cache: Map<string, { data: unknown; expires: number }> = new Map();\n private defaultTtl: number;\n\n constructor(config: GrantClientConfig) {\n this.config = config;\n this.defaultTtl = config.cache?.ttl ?? 5 * 60 * 1000; // 5 minutes default\n }\n\n // ============================================================================\n // Public API - Permission Checks\n // ============================================================================\n\n /**\n * Check if the current user has a specific permission\n *\n * @example\n * ```ts\n * const canEdit = await grant.can('document', 'update');\n * if (canEdit) {\n * // Show edit button\n * }\n * ```\n */\n async can(resource: string, action: string, options?: PermissionQueryOptions): Promise<boolean> {\n const result = await this.isAuthorized(resource, action, options);\n return result.authorized;\n }\n\n /**\n * Alias for `can` - check if user has permission\n */\n async hasPermission(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<boolean> {\n return this.can(resource, action, options);\n }\n\n // ============================================================================\n // Public API - Project OAuth (sign-in with project app)\n // ============================================================================\n\n /**\n * Start project-app OAuth flow (redirect only).\n * Navigates the current window to the Grant OAuth entry page. Pass a client-generated\n * S256 `codeChallenge`; hosted pages forward it to `GET /api/auth/project/authorize`.\n * After consent the user returns to `redirect_uri?code=&state=`. Exchange the code at\n * `POST /api/auth/project/token` with `grant_type=authorization_code` and the PKCE\n * `code_verifier` you stored (do not expect `#access_token=`).\n *\n * Requires `config.frontendUrl` and `redirectUri`.\n */\n async signInWithProjectApp(options: SignInWithProjectAppOptions): Promise<void> {\n const frontendUrl = this.config.frontendUrl;\n if (!frontendUrl) {\n throw new Error('GrantClient: frontendUrl is required for signInWithProjectApp');\n }\n const locale = options.locale ?? 'en';\n const redirectUri = options.redirectUri;\n if (!redirectUri) {\n throw new Error('redirectUri is required for signInWithProjectApp');\n }\n\n const entryPath = `/${locale}/auth/project`;\n const params = new URLSearchParams({\n client_id: options.clientId,\n redirect_uri: redirectUri,\n state: options.state ?? '',\n });\n if (options.scope) params.set('scope', options.scope);\n if (options.codeChallenge?.trim()) {\n params.set('code_challenge', options.codeChallenge.trim());\n params.set('code_challenge_method', options.codeChallengeMethod ?? 'S256');\n }\n\n const entryUrl = `${frontendUrl.replace(/\\/$/, '')}${entryPath}?${params.toString()}`;\n if (typeof window !== 'undefined') {\n window.location.href = entryUrl;\n }\n }\n\n /**\n * Check authorization with full result details\n *\n * @example\n * ```ts\n * const result = await grant.isAuthorized('document', 'update');\n * if (!result.authorized) {\n * console.log('Denied:', result.reason);\n * }\n * ```\n */\n async isAuthorized(\n resource: string,\n action: string,\n options?: PermissionQueryOptions\n ): Promise<AuthorizationResult> {\n const contextResourceKey =\n options?.context?.resource != null ? JSON.stringify(options.context.resource) : undefined;\n const cacheKey = this.getCacheKey('auth', resource, action, options?.scope, contextResourceKey);\n\n // Check cache first (unless explicitly disabled)\n if (options?.useCache !== false) {\n const cached = this.getFromCache<AuthorizationResult>(cacheKey);\n if (cached) return cached;\n }\n\n try {\n // API expects: { permission: { resource, action }, context: { resource?: any }, scope?: { tenant, id } }\n // scope is optional - for session tokens it enables dynamic scope switching\n const scope = options?.scope;\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope;\n // When scope is provided and context.resource is not, derive context.resource from scope.id\n const contextResource =\n options?.context?.resource ??\n (hasValidScope && scope && 'id' in scope && scope.id != null ? { id: scope.id } : null);\n\n const response = await this.fetchWithAuth('/api/auth/is-authorized', {\n method: 'POST',\n body: JSON.stringify({\n permission: {\n resource,\n action,\n },\n context: {\n resource: contextResource,\n },\n // Pass scope for dynamic scope override (only works with session tokens)\n ...(hasValidScope && { scope }),\n }),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n return {\n authorized: false,\n reason: error.message || `API error: ${response.status}`,\n };\n }\n\n const json = await response.json();\n // API returns { success: true, data: { authorized, ... } }\n const result: AuthorizationResult = json.data ?? json;\n this.setCache(cacheKey, result);\n return result;\n } catch (error) {\n return {\n authorized: false,\n reason: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n // ============================================================================\n // Public API - Cache Management\n // ============================================================================\n\n /**\n * Clear all cached data\n */\n clearCache(): void {\n this.cache.clear();\n }\n\n /**\n * Clear cached data for a specific scope\n */\n clearScopeCache(scope?: Scope): void {\n const scopeKey = scope ? JSON.stringify(scope) : 'default';\n for (const key of this.cache.keys()) {\n if (key.includes(scopeKey)) {\n this.cache.delete(key);\n }\n }\n }\n\n // ============================================================================\n // Private - HTTP & Authentication\n // ============================================================================\n\n /**\n * Make an authenticated fetch request with automatic token refresh on 401\n * and MFA step-up on 403 MFA_REQUIRED.\n */\n private async fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {\n const response = await this.doFetch(url, init);\n\n if (response.status === 403 && this.config.onMfaRequired) {\n const cloned = response.clone();\n const body = await cloned.json().catch(() => null);\n if (body && (body.code === 'MFA_REQUIRED' || body.extensions?.reason === 'MFA_REQUIRED')) {\n if (!sharedMfaStepUpPromise) {\n sharedMfaStepUpPromise = this.config.onMfaRequired().finally(() => {\n sharedMfaStepUpPromise = null;\n });\n }\n const verified = await sharedMfaStepUpPromise;\n if (verified) return this.doFetch(url, init);\n }\n }\n\n if (response.status !== 401) return response;\n\n // Cookie-based refresh (HttpOnly refresh cookie). Body-based refresh is not supported.\n // Module-level shared promise so all 401s (any client instance) coalesce into one refresh.\n if (this.config.onRefreshWithCredentials) {\n if (!sharedCredentialsRefreshPromise) {\n sharedCredentialsRefreshPromise = this.config.onRefreshWithCredentials().finally(() => {\n sharedCredentialsRefreshPromise = null;\n });\n }\n const refreshed = await sharedCredentialsRefreshPromise;\n if (refreshed) return this.doFetch(url, init);\n this.config.onUnauthorized?.();\n }\n\n return response;\n }\n\n /**\n * Perform the actual fetch request\n */\n private async doFetch(url: string, init?: RequestInit): Promise<Response> {\n const fetchFn = this.config.fetch ?? globalThis.fetch;\n const fullUrl = url.startsWith('http') ? url : `${this.config.apiUrl}${url}`;\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(init?.headers as Record<string, string>),\n };\n\n // Add authorization header if token is available\n const token = await this.getToken();\n if (token) {\n headers['Authorization'] = `Bearer ${token}`;\n }\n\n return fetchFn(fullUrl, {\n ...init,\n headers,\n // Include cookies for same-origin requests (supports cookie-based auth)\n credentials: this.config.credentials ?? 'include',\n });\n }\n\n /**\n * Get the current access token\n */\n private async getToken(): Promise<string | null> {\n if (this.config.getAccessToken) {\n const token = this.config.getAccessToken();\n return token instanceof Promise ? token : token;\n }\n return null;\n }\n\n // ============================================================================\n // Private - Cache & URL Helpers\n // ============================================================================\n\n private buildUrl(path: string, scope?: Scope): string {\n const url = new URL(path, this.config.apiUrl);\n if (scope) {\n url.searchParams.set('scope', JSON.stringify(scope));\n }\n return url.toString();\n }\n\n private getCacheKey(...parts: (string | Scope | undefined)[]): string {\n const prefix = this.config.cache?.prefix ?? 'grant';\n return `${prefix}:${parts.map((p) => (p ? JSON.stringify(p) : 'default')).join(':')}`;\n }\n\n private getFromCache<T>(key: string): T | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n\n if (Date.now() > entry.expires) {\n this.cache.delete(key);\n return null;\n }\n\n return entry.data as T;\n }\n\n private setCache(key: string, data: unknown): void {\n this.cache.set(key, {\n data,\n expires: Date.now() + this.defaultTtl,\n });\n }\n}\n"],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -1,3 +1,3 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_grant_client = require("./grant-client-CtKNEfr0.cjs");
2
+ const require_grant_client = require("./grant-client-CKB10qTv.cjs");
3
3
  exports.GrantClient = require_grant_client.GrantClient;
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { t as GrantClient } from "./grant-client-TOsIRSIZ.js";
1
+ export { t as GrantClient } from "./grant-client-CMH9cZX1.js";
package/dist/react.cjs CHANGED
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_grant_client = require("./grant-client-CtKNEfr0.cjs");
2
+ const require_grant_client = require("./grant-client-CKB10qTv.cjs");
3
3
  let react = require("react");
4
4
  let react_jsx_runtime = require("react/jsx-runtime");
5
- //#region src/react/context.tsx
5
+ // src/react/context.tsx
6
6
  /**
7
7
  * Context for the Grant client
8
8
  */
@@ -73,8 +73,7 @@ function useGrantClient() {
73
73
  function useGrantClientOptional() {
74
74
  return (0, react.useContext)(GrantContext);
75
75
  }
76
- //#endregion
77
- //#region src/react/hooks/useGrant.ts
76
+ // src/react/hooks/useGrant.ts
78
77
  /**
79
78
  * Serialize scope for stable dependency comparison
80
79
  * This prevents re-fetching when scope object reference changes but values are the same
@@ -186,8 +185,7 @@ function useGrant(resource, action, options = {}) {
186
185
  };
187
186
  return isGranted;
188
187
  }
189
- //#endregion
190
- //#region src/react/components/GrantGate.tsx
188
+ // src/react/components/GrantGate.tsx
191
189
  /**
192
190
  * Component that conditionally renders children based on permissions
193
191
  *
@@ -240,7 +238,6 @@ function GrantGate({ resource, action, scope, enabled, useCache, children, fallb
240
238
  if (isGranted) return children;
241
239
  return fallback;
242
240
  }
243
- //#endregion
244
241
  exports.GrantClient = require_grant_client.GrantClient;
245
242
  exports.GrantGate = GrantGate;
246
243
  exports.GrantProvider = GrantProvider;
@@ -1 +1 @@
1
- {"version":3,"file":"react.cjs","names":["createContext","useMemo","GrantClient","useContext","useRef","useMemo","useState","useCallback"],"sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, type ReactNode, useContext, useMemo } from 'react';\n\nimport { GrantClient } from '../grant-client';\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { AuthorizationResult, Scope } from '../../types';\nimport { useGrantClient } from '../context';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"react.cjs","names":["createContext","useMemo","GrantClient","useContext","useRef","useMemo","useState","useCallback"],"sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, type ReactNode, useContext, useMemo } from 'react';\n\nimport { GrantClient } from '../grant-client';\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { AuthorizationResult, Scope } from '../../types';\nimport { useGrantClient } from '../context';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"mappings":"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"}
package/dist/react.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { t as GrantClient } from "./grant-client-TOsIRSIZ.js";
1
+ import { t as GrantClient } from "./grant-client-CMH9cZX1.js";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
- //#region src/react/context.tsx
4
+ // src/react/context.tsx
5
5
  /**
6
6
  * Context for the Grant client
7
7
  */
@@ -72,8 +72,7 @@ function useGrantClient() {
72
72
  function useGrantClientOptional() {
73
73
  return useContext(GrantContext);
74
74
  }
75
- //#endregion
76
- //#region src/react/hooks/useGrant.ts
75
+ // src/react/hooks/useGrant.ts
77
76
  /**
78
77
  * Serialize scope for stable dependency comparison
79
78
  * This prevents re-fetching when scope object reference changes but values are the same
@@ -185,8 +184,7 @@ function useGrant(resource, action, options = {}) {
185
184
  };
186
185
  return isGranted;
187
186
  }
188
- //#endregion
189
- //#region src/react/components/GrantGate.tsx
187
+ // src/react/components/GrantGate.tsx
190
188
  /**
191
189
  * Component that conditionally renders children based on permissions
192
190
  *
@@ -239,7 +237,6 @@ function GrantGate({ resource, action, scope, enabled, useCache, children, fallb
239
237
  if (isGranted) return children;
240
238
  return fallback;
241
239
  }
242
- //#endregion
243
240
  export { GrantClient, GrantGate, GrantProvider, useGrant, useGrantClient, useGrantClientOptional };
244
241
 
245
242
  //# sourceMappingURL=react.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, type ReactNode, useContext, useMemo } from 'react';\n\nimport { GrantClient } from '../grant-client';\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { AuthorizationResult, Scope } from '../../types';\nimport { useGrantClient } from '../context';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/context.tsx","../src/react/hooks/useGrant.ts","../src/react/components/GrantGate.tsx"],"sourcesContent":["'use client';\n\nimport { createContext, type ReactNode, useContext, useMemo } from 'react';\n\nimport { GrantClient } from '../grant-client';\nimport type { GrantClientConfig } from '../types';\n\n/**\n * Context for the Grant client\n */\nconst GrantContext = createContext<GrantClient | null>(null);\n\n/**\n * Props for the GrantProvider component\n */\nexport interface GrantProviderProps {\n /**\n * Grant client configuration\n */\n config: GrantClientConfig;\n\n /**\n * Pre-configured GrantClient instance (alternative to config)\n * If provided, config is ignored\n */\n client?: GrantClient;\n\n /**\n * Child components\n */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the Grant client available to child components\n *\n * @example\n * ```tsx\n * // Option 1: Pass config (cookie-based refresh)\n * <GrantProvider\n * config={{\n * apiUrl: 'https://api.grant.com',\n * getAccessToken: () => localStorage.getItem('accessToken'),\n * onRefreshWithCredentials: async () => {\n * const res = await fetch('https://api.grant.com/api/auth/refresh', { method: 'POST', credentials: 'include' });\n * if (!res.ok) return false;\n * const { data } = await res.json();\n * if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); return true; }\n * return false;\n * },\n * onTokenRefresh: (tokens) => { localStorage.setItem('accessToken', tokens.accessToken); },\n * onUnauthorized: () => { window.location.href = '/login'; },\n * }}\n * >\n * <App />\n * </GrantProvider>\n *\n * // Option 2: Pass pre-configured client\n * const grant = new GrantClient({ ... });\n * <GrantProvider client={grant}>\n * <App />\n * </GrantProvider>\n * ```\n */\nexport function GrantProvider({ config, client, children }: GrantProviderProps) {\n const grantClient = useMemo(() => {\n if (client) return client;\n return new GrantClient(config);\n }, [client, config]);\n\n return <GrantContext.Provider value={grantClient}>{children}</GrantContext.Provider>;\n}\n\n/**\n * Hook to access the Grant client from context\n *\n * @throws Error if used outside of GrantProvider\n *\n * @example\n * ```tsx\n * const grant = useGrantClient();\n * const hasPermission = await grant.can('resource', 'action');\n * ```\n */\nexport function useGrantClient(): GrantClient {\n const client = useContext(GrantContext);\n\n if (!client) {\n throw new Error(\n 'useGrantClient must be used within a GrantProvider. ' +\n 'Wrap your app with <GrantProvider config={...}> to fix this error.'\n );\n }\n\n return client;\n}\n\n/**\n * Hook to optionally access the Grant client\n * Returns null if not in a GrantProvider context\n *\n * Use this when you want to gracefully handle missing provider\n */\nexport function useGrantClientOptional(): GrantClient | null {\n return useContext(GrantContext);\n}\n","'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport type { AuthorizationResult, Scope } from '../../types';\nimport { useGrantClient } from '../context';\n\n/**\n * Options for the useGrant hook\n */\nexport interface UseGrantOptions {\n /** Scope to check the permission in. If provided but null/undefined, hook waits for it to become valid. */\n scope?: Scope | null;\n /** Whether to skip the permission check */\n enabled?: boolean;\n /** Whether to use cached results (default: true) */\n useCache?: boolean;\n /** Whether to return loading state (default: false) */\n returnLoading?: boolean;\n /** Context to check permissions for */\n context?: {\n resource?: Record<string, unknown> | null;\n };\n}\n\n/**\n * Result when returnLoading is true\n */\nexport interface UseGrantResult {\n /** Whether the user is granted permission */\n isGranted: boolean;\n /** Whether the permission check is loading */\n isLoading: boolean;\n}\n\n/**\n * Serialize scope for stable dependency comparison\n * This prevents re-fetching when scope object reference changes but values are the same\n */\nfunction serializeScope(scope?: Scope | null): string {\n if (!scope) return '';\n return `${scope.tenant}:${scope.id}`;\n}\n\n/**\n * Hook to check if a user is granted permission for a specific resource and action\n *\n * By default, returns a simple boolean, defaulting to false while loading.\n * Set `returnLoading: true` to get an object with `isGranted` and `isLoading`.\n *\n * @param resource - The resource slug to check\n * @param action - The action to check\n * @param options - Additional options\n *\n * @example\n * ```tsx\n * // Simple boolean (default)\n * const canEdit = useGrant('document', 'update');\n *\n * return (\n * <div>\n * {canEdit && <EditButton />}\n * </div>\n * );\n *\n * // With loading state\n * const { isGranted, isLoading } = useGrant('document', 'update', {\n * returnLoading: true,\n * });\n *\n * if (isLoading) return <Spinner />;\n * if (!isGranted) return null;\n *\n * return <EditButton />;\n * ```\n */\nexport function useGrant(\n resource: string,\n action: string,\n options: UseGrantOptions = {}\n): boolean | UseGrantResult {\n const { scope, enabled = true, useCache = true, returnLoading = false, context } = options;\n const client = useGrantClient();\n\n // Track if scope was explicitly provided (even if null/undefined)\n // This allows us to distinguish between \"scope not provided\" (optional) vs \"scope provided but falsy\" (wait for it)\n // Check this once at the start - if scope key exists in options, it was provided\n // Note: { scope: undefined } has the key, { } does not have the key\n const scopeWasProvidedRef = useRef('scope' in options);\n\n // Determine if we should wait for scope to become valid\n // If scope was provided but is falsy or invalid, wait for it to become truthy\n // Recalculate when scope changes\n const isEffectivelyEnabled = useMemo(() => {\n const hasValidScope =\n scope && typeof scope === 'object' && 'tenant' in scope && 'id' in scope && scope.id;\n const shouldWaitForScope = scopeWasProvidedRef.current && !hasValidScope;\n return enabled && !shouldWaitForScope;\n }, [scope, enabled]);\n\n const [data, setData] = useState<AuthorizationResult | null>(null);\n const [isLoading, setIsLoading] = useState(isEffectivelyEnabled);\n\n // Synchronously correct isLoading when isEffectivelyEnabled transitions.\n // useState only uses its initializer on first render, so subsequent transitions\n // leave isLoading stale for one render cycle (the effect hasn't run yet).\n // This uses React's \"storing information from previous renders\" pattern to\n // immediately set isLoading before the render completes.\n // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders\n const [prevEffectivelyEnabled, setPrevEffectivelyEnabled] = useState(isEffectivelyEnabled);\n if (isEffectivelyEnabled !== prevEffectivelyEnabled) {\n setPrevEffectivelyEnabled(isEffectivelyEnabled);\n if (isEffectivelyEnabled) {\n setIsLoading(true);\n } else {\n setIsLoading(false);\n setData(null);\n }\n }\n\n // Track mounted state to prevent state updates after unmount\n const isMounted = useRef(true);\n\n // Store scope in a ref so we always have the latest value without triggering re-renders\n const scopeRef = useRef(scope);\n scopeRef.current = scope;\n\n // Store context in a ref so the callback always sends the latest context\n const contextRef = useRef(context);\n contextRef.current = context;\n\n // Serialize scope to get a stable string for dependency comparison\n const scopeKey = serializeScope(scope);\n\n // Serialize context so we re-create the callback when context meaningfully changes\n const contextKey = useMemo(\n () => (context?.resource != null ? JSON.stringify(context.resource) : ''),\n [context?.resource]\n );\n\n const fetchPermission = useCallback(async () => {\n if (!isEffectivelyEnabled) {\n setIsLoading(false);\n return;\n }\n\n setIsLoading(true);\n\n try {\n // Use scopeRef.current and contextRef.current to get the latest values\n // Convert null to undefined for the client (which expects Scope | undefined)\n const result = await client.isAuthorized(resource, action, {\n scope: scopeRef.current ?? undefined,\n useCache,\n context: contextRef.current,\n });\n if (isMounted.current) {\n setData(result);\n }\n } catch {\n // On error, set data to null (will return false)\n if (isMounted.current) {\n setData(null);\n }\n } finally {\n if (isMounted.current) {\n setIsLoading(false);\n }\n }\n // contextKey ensures we re-run when context (e.g. resource) changes so the request gets the latest context\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client, resource, action, scopeKey, isEffectivelyEnabled, useCache, contextKey]);\n\n useEffect(() => {\n isMounted.current = true;\n\n // Clear data when scope becomes invalid (waiting for valid scope)\n if (!isEffectivelyEnabled && scopeWasProvidedRef.current) {\n setData(null);\n setIsLoading(false);\n } else {\n fetchPermission();\n }\n\n return () => {\n isMounted.current = false;\n };\n }, [fetchPermission, isEffectivelyEnabled]);\n\n const isGranted = data?.authorized ?? false;\n\n // Return object with loading state if requested, otherwise just boolean\n if (returnLoading) {\n return { isGranted, isLoading };\n }\n\n return isGranted;\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\n\nimport { useGrant, type UseGrantOptions } from '../hooks/useGrant';\n\n/**\n * Props for the GrantGate component\n */\nexport interface GrantGateProps extends UseGrantOptions {\n /** The resource slug to check permission for */\n resource: string;\n /** The action to check */\n action: string;\n /** Content to render if permission is granted */\n children: ReactNode;\n /** Content to render if permission is denied (optional) */\n fallback?: ReactNode;\n /** Content to render while loading (optional) */\n loading?: ReactNode;\n}\n\n/**\n * Component that conditionally renders children based on permissions\n *\n * @example\n * ```tsx\n * // Basic usage - hide element if no permission\n * <GrantGate resource=\"document\" action=\"update\">\n * <EditButton />\n * </GrantGate>\n *\n * // With fallback for denied access\n * <GrantGate\n * resource=\"admin\"\n * action=\"access\"\n * fallback={<p>You don't have admin access</p>}\n * >\n * <AdminPanel />\n * </GrantGate>\n *\n * // With loading state\n * <GrantGate\n * resource=\"report\"\n * action=\"view\"\n * loading={<Spinner />}\n * fallback={<AccessDenied />}\n * >\n * <ReportViewer />\n * </GrantGate>\n *\n * // With scope for multi-tenant\n * <GrantGate\n * resource=\"project\"\n * action=\"delete\"\n * scope={{ tenant: 'project', id: projectId }}\n * >\n * <DeleteProjectButton />\n * </GrantGate>\n * ```\n */\nexport function GrantGate({\n resource,\n action,\n scope,\n enabled,\n useCache,\n children,\n fallback = null,\n loading = null,\n}: GrantGateProps): ReactNode {\n // Build options object conditionally\n // Only include scope in options if it's not undefined (null is valid and means \"wait for it\")\n // This allows the hook to distinguish between \"scope not provided\" (undefined) vs \"scope provided but null\"\n const options: Parameters<typeof useGrant>[2] = {\n enabled,\n useCache,\n returnLoading: loading !== null,\n };\n\n // Only add scope to options if it's explicitly null or a valid object\n // If scope is undefined, don't include it so hook treats it as optional\n if (scope !== undefined) {\n options.scope = scope;\n }\n\n // Use loading state if loading prop is provided\n const result = useGrant(resource, action, options);\n\n const isGranted = typeof result === 'boolean' ? result : result.isGranted;\n const isLoading = typeof result === 'boolean' ? false : result.isLoading;\n\n if (isLoading && loading !== null) {\n return loading;\n }\n\n if (isGranted) {\n return children;\n }\n\n return fallback;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grantjs/client",
3
- "version": "1.18.2",
3
+ "version": "1.18.3",
4
4
  "description": "Browser SDK for Grant authorization - React hooks and components for permission-based UI rendering",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -55,7 +55,7 @@
55
55
  "registry": "https://registry.npmjs.org/"
56
56
  },
57
57
  "dependencies": {
58
- "@grantjs/schema": "1.18.2"
58
+ "@grantjs/schema": "1.18.3"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@testing-library/jest-dom": "^7.0.1",
@@ -69,7 +69,7 @@
69
69
  "react": "^19.3.1",
70
70
  "@typescript/native": "npm:typescript@^7.0.2",
71
71
  "typescript": "npm:@typescript/typescript6@^6.0.2",
72
- "vite": "^8.3.4",
72
+ "vite": "^8.3.5",
73
73
  "vite-plugin-dts": "^5.1.2",
74
74
  "vitest": "^5.0.5"
75
75
  },