@svadmin/core 0.49.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +32 -0
  2. package/package.json +21 -4
  3. package/src/access-control-contract.ts +250 -0
  4. package/src/adapters/casbin.ts +20 -14
  5. package/src/adapters/casl.ts +22 -19
  6. package/src/admin-tool.ts +7 -7
  7. package/src/audit-contract.ts +105 -0
  8. package/src/audit.ts +88 -128
  9. package/src/auth-hooks.svelte.ts +593 -261
  10. package/src/auth-mutation-contract.ts +143 -0
  11. package/src/auth-query-contract.ts +97 -0
  12. package/src/captured-mutation.svelte.ts +45 -0
  13. package/src/chatProvider.svelte.ts +1 -1
  14. package/src/command-contract.ts +195 -0
  15. package/src/context.svelte.ts +217 -205
  16. package/src/contract-context.svelte.ts +37 -0
  17. package/src/contract-form.svelte.ts +566 -0
  18. package/src/create-contract.ts +56 -0
  19. package/src/create-hooks.svelte.ts +347 -0
  20. package/src/create-many-contract.ts +90 -0
  21. package/src/create-many-hooks.svelte.ts +396 -0
  22. package/src/data-transfer.svelte.ts +1 -215
  23. package/src/defined-options.ts +50 -0
  24. package/src/delete-contract.ts +50 -0
  25. package/src/delete-hooks.svelte.ts +403 -0
  26. package/src/delete-many-contract.ts +60 -0
  27. package/src/delete-many-hooks.svelte.ts +402 -0
  28. package/src/enterprise.ts +4 -4
  29. package/src/export-format.ts +16 -14
  30. package/src/export-hooks.svelte.ts +191 -0
  31. package/src/feature-gate-contract.ts +53 -0
  32. package/src/hook-utils.svelte.ts +53 -105
  33. package/src/hooks.svelte.ts +19 -774
  34. package/src/http.ts +88 -87
  35. package/src/i18n.svelte.ts +56 -2
  36. package/src/import-contract.ts +106 -0
  37. package/src/import-hooks.svelte.ts +310 -0
  38. package/src/index.ts +77 -28
  39. package/src/inferencer-graphql.ts +7 -6
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -286
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +216 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +88 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +193 -149
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +55 -103
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
@@ -1,171 +1,320 @@
1
- // Auth Hooks — reactive wrappers around AuthProvider methods
2
- // Each hook encapsulates the auth call + loading state + error handling + redirect
3
- // Uses module-level $state — no component init-time constraints.
1
+ import { definedOptions } from './defined-options';
2
+ // Component-scoped auth calls with validated results and provider-scoped session revisions.
4
3
 
5
4
  import { captureAdminContext } from './context.svelte';
6
5
  import type { AdminContextAccessor } from './context.svelte';
7
6
  import { notifyWithProvider } from './notification.svelte';
8
- import { t, useTranslation } from './i18n.svelte';
9
- import type { AuthActionResult, CheckResult, Identity, AuthProvider } from './types';
7
+ import { t,useTranslation } from './i18n.svelte';
8
+ import type { AuthActionResult,CheckResult,Identity,AuthProvider } from './types';
10
9
  import { useQueryClient } from '@tanstack/svelte-query';
11
-
12
- let _logoutVersion = $state(0);
13
- let nextAuthMutationInstanceId = 0;
10
+ import { untrack } from 'svelte';
11
+ import { createSubscriber } from 'svelte/reactivity';
12
+ import { parseQueryKey } from './query-keys';
13
+ import { decodePermissionHints, hasPermissionHint, PermissionHintsError, type PermissionHints } from './permission-hints-contract';
14
+ import {
15
+ AuthQueryError, decodeAuthCheck, decodeIdentity, AuthErrorHandlingError, decodeAuthErrorResult,
16
+ type AuthErrorHandlingResult,
17
+ } from './auth-query-contract';
18
+ import {
19
+ AuthMutationError, authMutationFailure, decodeAuthAction, prepareAuthMutation,
20
+ type AuthMutationArgs, type AuthMutationMethod,
21
+ } from './auth-mutation-contract';
22
+ import { navigateWithProvider } from './router';
23
+
24
+ let _logoutVersion=$state(0);
25
+ interface AuthSession {
26
+ readonly cacheId: number;
27
+ readonly observe: () => void;
28
+ readonly changed: () => void;
29
+ version: number;
30
+ logout: number;
31
+ intent: number;
32
+ liveRevision: number;
33
+ liveState: 'ready' | 'changing' | 'signed-out' | 'uncertain';
34
+ inFlight: number;
35
+ }
36
+ let sessions=$state.raw(new WeakMap<AuthProvider, AuthSession>());
37
+ let nextAuthMutationInstanceId=0;
38
+ let nextAuthSessionId=0;
14
39
  export function getLogoutVersion() { return _logoutVersion; }
15
- export function resetLogoutVersion() { _logoutVersion = 0; }
40
+ export function resetLogoutVersion() {
41
+ _logoutVersion=0;
42
+ sessions=new WeakMap<AuthProvider, AuthSession>();
43
+ }
44
+
45
+ function authSession(provider: AuthProvider | null) {
46
+ if(!provider) return { cacheId: 0, observe: () => {}, changed: () => {},
47
+ version: 0, logout: 0, intent: 0, liveRevision: 0, liveState: 'ready' as const, inFlight: 0 };
48
+ const existing=sessions.get(provider);
49
+ if(existing) return existing;
50
+ let update: (() => void) | undefined;
51
+ // A lazily created session may originate inside a derived expression.
52
+ const observe=createSubscriber(notify => { update=notify; return () => { update=undefined; }; });
53
+ const session=$state<AuthSession>({ cacheId: ++nextAuthSessionId, observe, changed: () => update?.(),
54
+ version: 0, logout: 0, intent: 0, liveRevision: 0, liveState: 'ready', inFlight: 0 });
55
+ sessions.set(provider,session);
56
+ return session;
57
+ }
58
+
59
+ function setLiveSession(provider: AuthProvider, state: AuthSession['liveState']) {
60
+ const session=authSession(provider);
61
+ session.liveRevision++;
62
+ session.liveState=state;
63
+ session.changed();
64
+ }
65
+
66
+ function beginLiveSessionChange(provider: AuthProvider, intent: number) {
67
+ const session=authSession(provider);
68
+ session.inFlight++;
69
+ setLiveSession(provider,'changing');
70
+ return () => {
71
+ session.inFlight--;
72
+ session.changed();
73
+ // An older request finishing last leaves the effective remote principal uncertain.
74
+ if(authSession(provider)===session && session.inFlight===0 && session.intent!==intent) {
75
+ setLiveSession(provider,'uncertain');
76
+ }
77
+ };
78
+ }
79
+
80
+ /** Read-only capability snapshot; consumers cannot mutate the underlying auth session. */
81
+ export function captureAuthLiveScope(provider: AuthProvider | null) {
82
+ if(!provider) return Object.freeze({ cacheKey: 'anonymous', available: true, isCurrent: () => true });
83
+ const session=authSession(provider);
84
+ session.observe();
85
+ const revision=session.liveRevision;
86
+ const available=session.liveState==='ready' && session.inFlight===0;
87
+ return Object.freeze({
88
+ cacheKey: `auth:${session.cacheId}:${revision}`,
89
+ available,
90
+ isCurrent: () => available && authSession(provider)===session && session.liveRevision===revision &&
91
+ session.liveState==='ready' && session.inFlight===0,
92
+ });
93
+ }
94
+
95
+ function rejectedLiveState(previous: AuthSession['liveState']): AuthSession['liveState'] {
96
+ return previous==='ready' || previous==='signed-out' ? previous : 'uncertain';
97
+ }
98
+
99
+ function advanceAuthSession(provider: AuthProvider, logout: boolean, liveState: AuthSession['liveState']='ready'): number {
100
+ const session=authSession(provider);
101
+ session.version++;
102
+ setLiveSession(provider,logout ? 'signed-out' : liveState);
103
+ if(logout) {
104
+ session.logout++;
105
+ _logoutVersion++;
106
+ }
107
+ return session.version;
108
+ }
16
109
 
17
110
  // ─── Mutate Factory ─────────────────────────────────────────────
18
111
 
19
- interface CreateAuthMutationOptions {
20
- method: keyof AuthProvider;
21
- successMessage?: string | null;
22
- errorMessage?: string | false;
23
- onSuccess?: (result: AuthActionResult, adminContext: AdminContextAccessor) => void | Promise<void>;
112
+ interface CreateAuthMutationOptions<M extends AuthMutationMethod> {
113
+ method: M;
114
+ successMessage?: string|null;
115
+ errorMessage?: string|false;
116
+ onSuccess?: (result: AuthActionResult,navigate: (path: string) => Promise<void>) => void|Promise<void>;
24
117
  }
25
118
 
26
119
  export interface AuthNotificationOptions {
27
120
  /** Set to false when the page owns the success state. */
28
- successNotification?: string | false;
29
- errorNotification?: string | false;
121
+ successNotification?: string|false;
122
+ errorNotification?: string|false;
30
123
  }
31
124
 
32
125
  function resolveSuccessMessage(
33
- configured: string | false | undefined,
126
+ configured: string|false|undefined,
34
127
  defaultMessage: string,
35
- ): string | null {
36
- return configured === false ? null : configured ?? defaultMessage;
128
+ ): string|null {
129
+ return configured===false? null:configured??defaultMessage;
37
130
  }
38
131
 
39
- function createAuthMutation(options: CreateAuthMutationOptions) {
40
- const mutationInstanceId = nextAuthMutationInstanceId++;
41
- const adminContext = captureAdminContext();
42
- const i18n = useTranslation();
43
- let isLoading = $state(false);
44
- let mutationEpoch = 0;
45
-
46
- const activeTenantIdentity = () => adminContext.tenantCacheKey?.__svadminTenant;
47
- let observedProvider = adminContext.authProvider;
48
- let observedTenantIdentity = activeTenantIdentity();
49
-
50
- function isActiveMutation(
51
- epoch: number,
52
- provider: AuthProvider,
53
- tenantIdentity: string | number | undefined,
54
- ): boolean {
55
- return epoch === mutationEpoch
56
- && provider === adminContext.authProvider
57
- && tenantIdentity === activeTenantIdentity();
132
+ function createAuthMutation<M extends AuthMutationMethod>(options: CreateAuthMutationOptions<M>) {
133
+ const mutationInstanceId=nextAuthMutationInstanceId++;
134
+ const adminContext=captureAdminContext();
135
+ const i18n=useTranslation();
136
+ interface Pending {
137
+ epoch: number;
138
+ provider: AuthProvider | null;
139
+ tenant: string | number | undefined;
140
+ authVersion: number;
141
+ intent: number;
142
+ previousLive: AuthSession['liveState'];
143
+ changedLive: boolean;
144
+ }
145
+ let pending=$state.raw<Pending | null>(null);
146
+ let mutationEpoch=0;
147
+ let disposed=false;
148
+
149
+ const activeTenantIdentity=() => adminContext.tenantCacheKey?.__svadminTenant;
150
+ function current(value: Pending): boolean {
151
+ return !disposed && value.epoch === mutationEpoch
152
+ && value.provider === adminContext.authProvider
153
+ && value.tenant === activeTenantIdentity()
154
+ && value.intent === authSession(value.provider).intent
155
+ && value.authVersion === authSession(value.provider).version;
58
156
  }
59
157
 
60
- async function mutate(params?: Record<string, unknown>): Promise<AuthActionResult> {
61
- const provider = adminContext.authProvider;
62
- if (!provider) throw new Error('AuthProvider not configured');
63
- const fn = provider[options.method] as ((params?: Record<string, unknown>) => Promise<AuthActionResult>) | undefined;
64
- if (!fn) throw new Error(`AuthProvider.${options.method} not implemented`);
65
-
66
- const epoch = ++mutationEpoch;
67
- const tenantIdentity = activeTenantIdentity();
68
- const notificationProvider = adminContext.notificationProvider;
69
- const sendNotification = (notification: Parameters<typeof notifyWithProvider>[0]) =>
70
- notifyWithProvider(notification, notificationProvider);
71
- isLoading = true;
72
- try {
73
- const result = await fn.call(provider, params);
74
- if (!isActiveMutation(epoch, provider, tenantIdentity)) return result;
75
- const eventKey = `auth:${mutationInstanceId}:${String(options.method)}:${epoch}`;
76
- if (result.success) {
77
- if (options.successMessage) {
158
+ async function mutate(...[params]: AuthMutationArgs<M>): Promise<AuthActionResult> {
159
+ if(disposed) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
160
+ const provider=adminContext.authProvider;
161
+ const epoch=++mutationEpoch;
162
+ const started: Pending={
163
+ epoch, provider, tenant: activeTenantIdentity(),
164
+ authVersion: authSession(provider).version, intent: authSession(provider).intent,
165
+ previousLive: authSession(provider).liveState, changedLive: false,
166
+ };
167
+ pending=started;
168
+ const router=adminContext.routerProvider;
169
+ const notificationProvider=adminContext.notificationProvider;
170
+ const eventKey=`auth:${mutationInstanceId}:${options.method}:${epoch}`;
171
+ const sendNotification=(notification: Parameters<typeof notifyWithProvider>[0]) => {
172
+ try {
173
+ notifyWithProvider(notification,notificationProvider);
174
+ } catch {
175
+ // A presentation failure cannot change an already completed authentication operation.
176
+ }
177
+ };
178
+ const failure=(code: AuthMutationError['code']) => {
179
+ const result=authMutationFailure(code);
180
+ if(current(started)) {
181
+ if(provider && started.changedLive) {
182
+ setLiveSession(provider,code==='AUTH_REJECTED' ? rejectedLiveState(started.previousLive) : 'uncertain');
183
+ }
184
+ pending=null;
185
+ if(options.errorMessage!==false) {
78
186
  sendNotification({
79
- type: 'success',
80
- message: options.successMessage,
81
- key: `${eventKey}:success`,
187
+ type: 'error',
188
+ message: typeof options.errorMessage==='string' ? options.errorMessage : i18n.t('common.operationFailed'),
189
+ key: `${eventKey}:error`,
82
190
  });
83
191
  }
84
- if (options.onSuccess) await options.onSuccess(result, adminContext);
85
- } else {
86
- const msg = result.error?.message ?? (typeof options.errorMessage === 'string' ? options.errorMessage : i18n.t('common.operationFailed'));
87
- if (options.errorMessage !== false) {
88
- sendNotification({ type: 'error', message: msg, key: `${eventKey}:error` });
89
- }
90
192
  }
91
193
  return result;
92
- } catch (err) {
93
- const msg = err instanceof Error ? err.message : (typeof options.errorMessage === 'string' ? options.errorMessage : i18n.t('common.operationFailed'));
94
- if (isActiveMutation(epoch, provider, tenantIdentity) && options.errorMessage !== false) {
194
+ };
195
+ let invoke: () => Promise<unknown>;
196
+ try {
197
+ if(!provider) return failure('AUTH_METHOD_UNAVAILABLE');
198
+ invoke=prepareAuthMutation(provider,options.method,params);
199
+ } catch(err) {
200
+ return failure(err instanceof AuthMutationError ? err.code : 'INVALID_AUTH_INPUT');
201
+ }
202
+ if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
203
+ let releaseLive: (() => void) | undefined;
204
+ if(provider && options.method!=='forgotPassword') {
205
+ started.intent=++authSession(provider).intent;
206
+ started.changedLive=true;
207
+ releaseLive=beginLiveSessionChange(provider,started.intent);
208
+ }
209
+ let response: unknown;
210
+ try {
211
+ response=await invoke();
212
+ } catch {
213
+ return current(started) ? failure('AUTH_REQUEST_FAILED') : authMutationFailure('AUTH_RESULT_SUPERSEDED');
214
+ } finally {
215
+ releaseLive?.();
216
+ }
217
+ if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
218
+ let result: AuthActionResult;
219
+ try {
220
+ result=decodeAuthAction(response);
221
+ } catch {
222
+ return failure('INVALID_AUTH_RESULT');
223
+ }
224
+ if(!current(started)) return authMutationFailure('AUTH_RESULT_SUPERSEDED');
225
+ if(!result.success) return failure('AUTH_REJECTED');
226
+ pending=null;
227
+ if(provider && options.method!=='forgotPassword') {
228
+ started.authVersion=advanceAuthSession(provider,options.method==='logout',
229
+ options.method==='login' ? 'ready' : rejectedLiveState(started.previousLive));
230
+ }
231
+ try {
232
+ if(options.successMessage) {
95
233
  sendNotification({
96
- type: 'error',
97
- message: msg,
98
- key: `auth:${mutationInstanceId}:${String(options.method)}:${epoch}:error`,
234
+ type: 'success', message: options.successMessage, key: `${eventKey}:success`,
99
235
  });
100
236
  }
101
- return { success: false, error: { message: msg } };
102
- } finally {
103
- if (isActiveMutation(epoch, provider, tenantIdentity)) isLoading = false;
237
+ if(current(started) && options.onSuccess) {
238
+ await options.onSuccess(result,async path => {
239
+ await navigateWithProvider(router,path,undefined,
240
+ () => current(started) && router===adminContext.routerProvider);
241
+ });
242
+ }
243
+ } catch {
244
+ // Navigation is a separate side effect; do not report the server write as failed.
104
245
  }
246
+ return current(started) ? result : authMutationFailure('AUTH_RESULT_SUPERSEDED');
105
247
  }
106
248
 
107
- if (typeof window !== 'undefined') {
249
+ if(typeof window!=='undefined') {
108
250
  $effect(() => {
109
- const provider = adminContext.authProvider;
110
- const tenantIdentity = activeTenantIdentity();
111
- if (provider !== observedProvider || tenantIdentity !== observedTenantIdentity) {
112
- observedProvider = provider;
113
- observedTenantIdentity = tenantIdentity;
114
- mutationEpoch++;
115
- isLoading = false;
116
- }
117
- return () => {
118
- mutationEpoch++;
119
- };
251
+ void adminContext.authProvider;
252
+ void activeTenantIdentity();
253
+ return () => { ++mutationEpoch; };
120
254
  });
255
+ $effect(() => () => { disposed=true; ++mutationEpoch; });
121
256
  }
122
257
 
123
258
  return {
124
259
  mutate,
125
- get isLoading() { return isLoading; },
260
+ get isLoading() { return pending !== null && current(pending); },
126
261
  };
127
262
  }
128
263
 
129
264
  // ─── useLogin ─────────────────────────────────────────────────
130
265
 
131
- export function useLogin(opts?: AuthNotificationOptions & { errorMessage?: string | false }) {
266
+ export function useLogin(opts?: AuthNotificationOptions&{ errorMessage?: string|false }) {
132
267
  return createAuthMutation({
133
268
  method: 'login',
134
- successMessage: resolveSuccessMessage(opts?.successNotification, t('common.operationSuccess')),
135
- errorMessage: opts?.errorNotification ?? opts?.errorMessage ?? t('common.loginFailed'),
136
- onSuccess: async (result, adminContext) => { await adminContext.navigate(result.redirectTo ?? '/'); }
269
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
270
+ errorMessage: opts?.errorNotification??opts?.errorMessage??t('common.loginFailed'),
271
+ onSuccess: async (result,navigate) => { await navigate(result.redirectTo??'/'); }
137
272
  });
138
273
  }
139
274
 
140
275
  // ─── useLogout ────────────────────────────────────────────────
141
276
 
142
277
  export function useLogout() {
143
- let queryClient: ReturnType<typeof useQueryClient> | undefined;
278
+ const context=captureAdminContext();
279
+ let queryClient: ReturnType<typeof useQueryClient>|undefined;
144
280
  try {
145
- queryClient = useQueryClient();
281
+ queryClient=useQueryClient();
146
282
  } catch {
147
283
  // Auth hooks remain usable without TanStack Query; cache clearing is best-effort.
148
284
  }
149
285
  return createAuthMutation({
150
286
  method: 'logout',
151
287
  successMessage: null,
152
- onSuccess: async (result, adminContext) => {
153
- _logoutVersion++;
154
- queryClient?.clear();
155
- await adminContext.navigate(result.redirectTo ?? '/login');
288
+ onSuccess: async (result,navigate) => {
289
+ clearAuthQueries(queryClient,context.authProvider);
290
+ await navigate(result.redirectTo??'/login');
156
291
  }
157
292
  });
158
293
  }
159
294
 
295
+ /** @internal Preserve caches with a proven different authentication owner. */
296
+ export function clearAuthQueries(client: ReturnType<typeof useQueryClient> | undefined, provider: AuthProvider | null): void {
297
+ if(!client) return;
298
+ client.getMutationCache().clear();
299
+ const prefix=provider ? `auth:${authSession(provider).cacheId}:` : 'anonymous';
300
+ client.removeQueries({ predicate: query => {
301
+ const params=parseQueryKey(query.queryKey)?.params;
302
+ const tag: unknown=typeof params==='object' && params!==null
303
+ ? Object.getOwnPropertyDescriptor(params,'authSession')?.value : undefined;
304
+ // Untagged caches have no provable session owner and retain conservative logout eviction.
305
+ return typeof tag!=='string' || (provider ? tag.startsWith(prefix) : tag===prefix);
306
+ } });
307
+ }
308
+
160
309
  // ─── useRegister ──────────────────────────────────────────────
161
310
 
162
311
  export function useRegister(opts?: AuthNotificationOptions) {
163
312
  return createAuthMutation({
164
313
  method: 'register',
165
- successMessage: resolveSuccessMessage(opts?.successNotification, t('auth.registerSuccess')),
166
- errorMessage: opts?.errorNotification,
167
- onSuccess: async (result, adminContext) => {
168
- if (result.redirectTo) await adminContext.navigate(result.redirectTo);
314
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('auth.registerSuccess')),
315
+ ...definedOptions({ errorMessage: opts?.errorNotification }),
316
+ onSuccess: async (result,navigate) => {
317
+ if(result.redirectTo) await navigate(result.redirectTo);
169
318
  }
170
319
  });
171
320
  }
@@ -175,8 +324,8 @@ export function useRegister(opts?: AuthNotificationOptions) {
175
324
  export function useForgotPassword(opts?: AuthNotificationOptions) {
176
325
  return createAuthMutation({
177
326
  method: 'forgotPassword',
178
- successMessage: resolveSuccessMessage(opts?.successNotification, t('auth.resetLinkSent')),
179
- errorMessage: opts?.errorNotification,
327
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('auth.resetLinkSent')),
328
+ ...definedOptions({ errorMessage: opts?.errorNotification }),
180
329
  });
181
330
  }
182
331
 
@@ -185,10 +334,10 @@ export function useForgotPassword(opts?: AuthNotificationOptions) {
185
334
  export function useUpdatePassword(opts?: AuthNotificationOptions) {
186
335
  return createAuthMutation({
187
336
  method: 'updatePassword',
188
- successMessage: resolveSuccessMessage(opts?.successNotification, t('common.operationSuccess')),
189
- errorMessage: opts?.errorNotification,
190
- onSuccess: async (result, adminContext) => {
191
- if (result.redirectTo) await adminContext.navigate(result.redirectTo);
337
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
338
+ ...definedOptions({ errorMessage: opts?.errorNotification }),
339
+ onSuccess: async (result,navigate) => {
340
+ if(result.redirectTo) await navigate(result.redirectTo);
192
341
  }
193
342
  });
194
343
  }
@@ -198,10 +347,10 @@ export function useUpdatePassword(opts?: AuthNotificationOptions) {
198
347
  export function useUpdateIdentity(opts?: AuthNotificationOptions) {
199
348
  return createAuthMutation({
200
349
  method: 'updateIdentity',
201
- successMessage: resolveSuccessMessage(opts?.successNotification, t('common.operationSuccess')),
202
- errorMessage: opts?.errorNotification,
203
- onSuccess: async (result, adminContext) => {
204
- if (result.redirectTo) await adminContext.navigate(result.redirectTo);
350
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
351
+ ...definedOptions({ errorMessage: opts?.errorNotification }),
352
+ onSuccess: async (result,navigate) => {
353
+ if(result.redirectTo) await navigate(result.redirectTo);
205
354
  }
206
355
  });
207
356
  }
@@ -209,131 +358,160 @@ export function useUpdateIdentity(opts?: AuthNotificationOptions) {
209
358
  export function useUpdateProfile(opts?: AuthNotificationOptions) {
210
359
  return createAuthMutation({
211
360
  method: 'updateProfile',
212
- successMessage: resolveSuccessMessage(opts?.successNotification, t('common.operationSuccess')),
213
- errorMessage: opts?.errorNotification,
214
- onSuccess: async (result, adminContext) => {
215
- if (result.redirectTo) await adminContext.navigate(result.redirectTo);
361
+ successMessage: resolveSuccessMessage(opts?.successNotification,t('common.operationSuccess')),
362
+ ...definedOptions({ errorMessage: opts?.errorNotification }),
363
+ onSuccess: async (result,navigate) => {
364
+ if(result.redirectTo) await navigate(result.redirectTo);
216
365
  }
217
366
  });
218
367
  }
219
368
 
220
369
  // ─── useGetIdentity ──────────────────────────────────────────
221
370
 
222
- export function useGetIdentity() {
223
- const adminContext = captureAdminContext();
224
- let data = $state<Identity | null>(null);
225
- let isLoading = $state(true);
226
- let error = $state<Error | null>(null);
227
- let requestEpoch = 0;
228
- let observedLogoutVersion = _logoutVersion;
229
-
230
- function requestIdentity(provider: AuthProvider | null): void {
231
- const epoch = ++requestEpoch;
232
- error = null;
233
- if (!provider) {
234
- data = null;
235
- isLoading = false;
236
- return;
237
- }
238
- isLoading = true;
239
- provider.getIdentity().then(identity => {
240
- if (epoch !== requestEpoch) return;
241
- data = identity;
242
- isLoading = false;
243
- }).catch(err => {
244
- if (epoch !== requestEpoch) return;
245
- error = err instanceof Error ? err : new Error(String(err));
246
- isLoading = false;
247
- console.warn('[svadmin] useGetIdentity failed:', err);
248
- });
371
+ function createAuthQuery<T>(options: {
372
+ empty: T;
373
+ withoutProvider: T;
374
+ request: (provider: AuthProvider) => Promise<unknown>;
375
+ decode: (value: unknown) => T;
376
+ invalidCode: 'INVALID_AUTH_IDENTITY' | 'INVALID_AUTH_CHECK';
377
+ onValidated?: (provider: AuthProvider, data: T, liveRevision: number) => void;
378
+ }) {
379
+ const adminContext=captureAdminContext();
380
+ interface Snapshot {
381
+ provider: AuthProvider | null;
382
+ tenant: string | number | undefined;
383
+ logout: number;
384
+ authVersion: number;
385
+ data: T;
386
+ isLoading: boolean;
387
+ error: AuthQueryError | null;
249
388
  }
250
-
251
- function refetchIdentity(): void {
252
- requestIdentity(adminContext.authProvider);
389
+ let snapshot=$state.raw<Snapshot | null>(null);
390
+ let epoch=0;
391
+ let disposed=false;
392
+ let observedProvider=adminContext.authProvider;
393
+ let observedLogoutVersion=authSession(observedProvider).logout;
394
+
395
+ function current(value: Snapshot | null): value is Snapshot {
396
+ return !disposed && value !== null
397
+ && value.provider === adminContext.authProvider
398
+ && value.tenant === adminContext.tenantCacheKey?.__svadminTenant
399
+ && value.authVersion === authSession(value.provider).version
400
+ && value.logout === authSession(value.provider).logout;
253
401
  }
254
402
 
255
- function loadScopedIdentity(provider: AuthProvider | null): void {
256
- data = null;
257
- requestIdentity(provider);
403
+ async function fetch(): Promise<void> {
404
+ if(disposed) return;
405
+ const provider=adminContext.authProvider;
406
+ const liveRevision=authSession(provider).liveRevision;
407
+ const request=++epoch;
408
+ const started: Snapshot={
409
+ provider, tenant: adminContext.tenantCacheKey?.__svadminTenant,
410
+ logout: authSession(provider).logout, authVersion: authSession(provider).version,
411
+ data: provider ? options.empty : options.withoutProvider,
412
+ isLoading: provider !== null && typeof window !== 'undefined', error: null,
413
+ };
414
+ snapshot=started;
415
+ if(!started.isLoading || !provider) return;
416
+ const fail=(code: AuthQueryError['code']) => {
417
+ if(request !== epoch || !current(started)) return;
418
+ snapshot={ ...started, isLoading: false, error: new AuthQueryError(code) };
419
+ };
420
+ let response: unknown;
421
+ try {
422
+ response=await options.request(provider);
423
+ } catch {
424
+ fail('AUTH_QUERY_FAILED');
425
+ return;
426
+ }
427
+ try {
428
+ const data=options.decode(response);
429
+ if(request !== epoch || !current(started)) return;
430
+ options.onValidated?.(provider,data,liveRevision);
431
+ snapshot={ ...started, data, isLoading: false };
432
+ } catch {
433
+ fail(options.invalidCode);
434
+ }
258
435
  }
259
436
 
260
- if (typeof window !== 'undefined') {
437
+ if(typeof window!=='undefined') {
261
438
  $effect(() => {
262
- const provider = adminContext.authProvider;
263
- void adminContext.tenantCacheKey?.__svadminTenant;
264
- const logoutVersion = _logoutVersion;
265
- if (logoutVersion !== observedLogoutVersion) {
266
- observedLogoutVersion = logoutVersion;
267
- requestEpoch++;
268
- data = null;
269
- error = null;
270
- isLoading = false;
439
+ const provider=adminContext.authProvider;
440
+ const tenant=adminContext.tenantCacheKey?.__svadminTenant;
441
+ const logout=authSession(provider).logout;
442
+ const authVersion=authSession(provider).version;
443
+ if(provider===observedProvider && logout!==observedLogoutVersion) {
444
+ observedLogoutVersion=logout;
445
+ ++epoch;
446
+ snapshot={
447
+ provider, tenant, logout, authVersion,
448
+ data: provider ? options.empty : options.withoutProvider, isLoading: false, error: null,
449
+ };
271
450
  return;
272
451
  }
273
-
274
- loadScopedIdentity(provider);
275
- return () => {
276
- requestEpoch++;
277
- };
452
+ observedProvider=provider;
453
+ observedLogoutVersion=logout;
454
+ untrack(() => { void fetch(); });
455
+ return () => { ++epoch; };
278
456
  });
457
+ $effect(() => () => { disposed=true; ++epoch; });
279
458
  }
280
459
 
281
460
  return {
282
- get data() { return data; },
283
- get isLoading() { return isLoading; },
284
- get error() { return error; },
285
- refetch: refetchIdentity,
461
+ get data() {
462
+ if(current(snapshot)) return snapshot.data;
463
+ return !disposed && !adminContext.authProvider ? options.withoutProvider : options.empty;
464
+ },
465
+ get isLoading() {
466
+ return current(snapshot) ? snapshot.isLoading
467
+ : !disposed && adminContext.authProvider !== null && typeof window !== 'undefined';
468
+ },
469
+ get error() { return current(snapshot) ? snapshot.error : null; },
470
+ refetch: fetch,
286
471
  };
287
472
  }
288
473
 
474
+ export function useGetIdentity() {
475
+ return createAuthQuery<Readonly<Identity> | null>({
476
+ empty: null,
477
+ withoutProvider: null,
478
+ request: provider => {
479
+ const method=provider.getIdentity;
480
+ if(typeof method !== 'function') throw new AuthQueryError('AUTH_QUERY_FAILED');
481
+ return method.call(provider);
482
+ },
483
+ decode: decodeIdentity,
484
+ invalidCode: 'INVALID_AUTH_IDENTITY',
485
+ });
486
+ }
487
+
289
488
  // ─── useIsAuthenticated ──────────────────────────────────────
290
489
 
291
490
  export function useIsAuthenticated() {
292
- const adminContext = captureAdminContext();
293
- let isAuthenticated = $state(false);
294
- let isLoading = $state(true);
295
- let checkResult = $state<CheckResult | null>(null);
296
-
297
- function check() {
298
- const provider = adminContext.authProvider;
299
- if (!provider) {
300
- isAuthenticated = true;
301
- isLoading = false;
302
- return;
303
- }
304
- if (typeof window === 'undefined') {
305
- // In SSR we assume false to prevent hydration mismatch before check
306
- isAuthenticated = false;
307
- isLoading = false;
308
- return;
309
- }
310
- isLoading = true;
311
- provider.check().then((result: CheckResult) => {
312
- isAuthenticated = result.authenticated;
313
- checkResult = result;
314
- isLoading = false;
315
- }).catch(() => {
316
- isAuthenticated = false;
317
- checkResult = { authenticated: false };
318
- isLoading = false;
319
- });
320
- }
321
-
322
- // Initial check
323
- check();
324
-
325
- if (typeof window !== 'undefined') {
326
- $effect(() => {
327
- void _logoutVersion;
328
- isAuthenticated = false;
329
- });
330
- }
491
+ const query=createAuthQuery<Readonly<CheckResult> | null>({
492
+ empty: null,
493
+ withoutProvider: Object.freeze({ authenticated: true }),
494
+ request: provider => {
495
+ const method=provider.check;
496
+ if(typeof method !== 'function') throw new AuthQueryError('AUTH_QUERY_FAILED');
497
+ return method.call(provider);
498
+ },
499
+ decode: decodeAuthCheck,
500
+ invalidCode: 'INVALID_AUTH_CHECK',
501
+ onValidated(provider,data,revision) {
502
+ const session=authSession(provider);
503
+ if(data && session.liveRevision===revision && session.liveState!=='changing' && session.inFlight===0) {
504
+ setLiveSession(provider,data.authenticated ? 'ready' : 'signed-out');
505
+ }
506
+ },
507
+ });
331
508
 
332
509
  return {
333
- get isAuthenticated() { return isAuthenticated; },
334
- get isLoading() { return isLoading; },
335
- get data() { return checkResult; },
336
- refetch: check,
510
+ get isAuthenticated() { return query.data?.authenticated === true; },
511
+ get isLoading() { return query.isLoading; },
512
+ get data() { return query.data; },
513
+ get error() { return query.error; },
514
+ refetch: query.refetch,
337
515
  };
338
516
  }
339
517
 
@@ -345,28 +523,130 @@ export function useIsAuthenticated() {
345
523
  * If it returns { redirectTo }, navigates there.
346
524
  */
347
525
  export function useOnError() {
348
- const adminContext = captureAdminContext();
526
+ const adminContext=captureAdminContext();
527
+ let queryClient: ReturnType<typeof useQueryClient> | undefined;
528
+ try {
529
+ queryClient=useQueryClient();
530
+ } catch {
531
+ // Standalone auth hooks may not have a query cache.
532
+ }
533
+ let epoch=0;
534
+ let disposed=false;
535
+ $effect(() => {
536
+ void adminContext.authProvider;
537
+ void adminContext.tenantCacheKey?.__svadminTenant;
538
+ return () => { ++epoch; };
539
+ });
540
+ $effect(() => () => { disposed=true; ++epoch; });
349
541
 
350
- async function mutate(error: unknown) {
351
- const provider = adminContext.authProvider;
352
- if (!provider?.onError) {
353
- console.warn('[svadmin] useOnError: authProvider.onError not implemented');
354
- return;
542
+ return {
543
+ mutate(error: unknown): Promise<AuthErrorHandlingResult> {
544
+ const request=++epoch;
545
+ return handleAuthError(error,adminContext,
546
+ () => !disposed && request===epoch, () => clearAuthQueries(queryClient,adminContext.authProvider));
547
+ },
548
+ };
549
+ }
550
+
551
+ /** Shared validated delegate for auth hooks and data-hook errors. */
552
+ export async function handleAuthError(
553
+ error: unknown,
554
+ adminContext: AdminContextAccessor,
555
+ isActive: () => boolean = () => true,
556
+ onLogout: () => void = () => {},
557
+ origin?: ReturnType<typeof captureAuthLiveScope>,
558
+ isExplicitlyCancelled: () => boolean = () => false,
559
+ ): Promise<AuthErrorHandlingResult> {
560
+ const provider=adminContext.authProvider;
561
+ const tenant=adminContext.tenantCacheKey?.__svadminTenant;
562
+ const router=adminContext.routerProvider;
563
+ let version=authSession(provider).version;
564
+ let intent=authSession(provider).intent;
565
+ let previousLive: AuthSession['liveState'] | undefined;
566
+ let ownsLogout=false;
567
+ // A delegate owns its checked logout after the session begins transitioning.
568
+ const current=() => (isActive() || (ownsLogout && !isExplicitlyCancelled()))
569
+ && (ownsLogout || origin===undefined || origin.isCurrent()) && provider===adminContext.authProvider
570
+ && tenant===adminContext.tenantCacheKey?.__svadminTenant && version===authSession(provider).version
571
+ && intent===authSession(provider).intent
572
+ && router===adminContext.routerProvider;
573
+ const fail=(code: AuthErrorHandlingError['code'], rejected=false): AuthErrorHandlingResult => {
574
+ if(provider && previousLive!==undefined && current()) {
575
+ // A failed data-query logout must not automatically restart the unauthorized read.
576
+ setLiveSession(provider,rejected && origin===undefined ? rejectedLiveState(previousLive) : 'uncertain');
355
577
  }
578
+ return { status: 'failed', error: new AuthErrorHandlingError(code) };
579
+ };
580
+ if(!current()) return { status: 'superseded' };
581
+ let response: unknown;
582
+ let invokeLogout: (() => Promise<unknown>) | undefined;
583
+ try {
584
+ const method=provider?.onError;
585
+ if(method===undefined) {
586
+ const descriptor=typeof error==='object' && error!==null
587
+ ? Object.getOwnPropertyDescriptor(error,'statusCode') : undefined;
588
+ const status: unknown=descriptor && 'value' in descriptor ? descriptor.value : undefined;
589
+ if(status!==401) return { status: 'ignored' };
590
+ await navigateWithProvider(router,'/login',undefined,current);
591
+ return { status: current() ? 'handled' : 'superseded' };
592
+ }
593
+ if(typeof method!=='function' || !provider) return fail('AUTH_ERROR_HANDLER_FAILED');
356
594
  try {
357
- const result = await provider.onError(error);
358
- if (result.logout) {
359
- await provider.logout?.();
360
- await adminContext.navigate(result.redirectTo ?? '/login');
361
- } else if (result.redirectTo) {
362
- await adminContext.navigate(result.redirectTo);
363
- }
364
- } catch (err) {
365
- console.warn('[svadmin] useOnError failed:', err);
595
+ invokeLogout=prepareAuthMutation(provider,'logout',undefined);
596
+ } catch {
597
+ // A missing logout method matters only if the validated handler requests logout.
366
598
  }
599
+ if(!current()) return { status: 'superseded' };
600
+ response=await method.call(provider,error);
601
+ } catch {
602
+ return current() ? fail('AUTH_ERROR_HANDLER_FAILED') : { status: 'superseded' };
367
603
  }
368
-
369
- return { mutate };
604
+ if(!current()) return { status: 'superseded' };
605
+ let result: ReturnType<typeof decodeAuthErrorResult>;
606
+ try {
607
+ result=decodeAuthErrorResult(response);
608
+ } catch {
609
+ return fail('INVALID_AUTH_ERROR_RESULT');
610
+ }
611
+ if(!current()) return { status: 'superseded' };
612
+ if(result.logout) {
613
+ if(!invokeLogout || !provider) return fail('AUTH_LOGOUT_FAILED');
614
+ previousLive=authSession(provider).liveState;
615
+ // The checked origin hands ownership to this delegate's own logout intent.
616
+ ownsLogout=true;
617
+ intent=++authSession(provider).intent;
618
+ const releaseLive=beginLiveSessionChange(provider,intent);
619
+ let logout: unknown;
620
+ try {
621
+ logout=await invokeLogout();
622
+ } catch {
623
+ return current() ? fail('AUTH_LOGOUT_FAILED') : { status: 'superseded' };
624
+ } finally {
625
+ releaseLive();
626
+ }
627
+ if(!current()) return { status: 'superseded' };
628
+ try {
629
+ if(!decodeAuthAction(logout).success) return fail('AUTH_LOGOUT_FAILED',true);
630
+ } catch {
631
+ return fail('AUTH_LOGOUT_FAILED');
632
+ }
633
+ if(!current()) return { status: 'superseded' };
634
+ version=advanceAuthSession(provider,true);
635
+ try {
636
+ onLogout();
637
+ } catch {
638
+ // A cache observer cannot undo a confirmed server logout.
639
+ }
640
+ }
641
+ const redirect=result.redirectTo ?? (result.logout ? '/login' : undefined);
642
+ if(redirect!==undefined) {
643
+ try {
644
+ await navigateWithProvider(router,redirect,undefined,current);
645
+ } catch {
646
+ return current() ? fail('AUTH_ERROR_HANDLER_FAILED') : { status: 'superseded' };
647
+ }
648
+ }
649
+ return { status: current() ? 'handled' : 'superseded' };
370
650
  }
371
651
 
372
652
  // ─── usePermissions ──────────────────────────────────────────
@@ -389,52 +669,104 @@ export function useOnError() {
389
669
  * </script>
390
670
  * ```
391
671
  */
392
- export function usePermissions<T = unknown>() {
393
- const adminContext = captureAdminContext();
394
- let permissions = $state<T | null>(null);
395
- let isLoading = $state(true);
396
- let error = $state<Error | null>(null);
397
- let version = $state(0);
398
-
399
- function fetch() {
400
- const provider = adminContext.authProvider;
401
- if (!provider?.getPermissions) { isLoading = false; return; }
402
- isLoading = true;
403
- error = null;
404
- provider.getPermissions().then(p => {
405
- permissions = p as T;
406
- version++;
407
- isLoading = false;
408
- }).catch(err => {
409
- error = err instanceof Error ? err : new Error(String(err));
410
- isLoading = false;
411
- console.warn('[svadmin] usePermissions failed:', err);
412
- });
672
+ export function usePermissions() {
673
+ const adminContext=captureAdminContext();
674
+ interface Snapshot {
675
+ provider: AuthProvider | null;
676
+ tenant: string | number | undefined;
677
+ logout: number;
678
+ authVersion: number;
679
+ hints: PermissionHints;
680
+ isLoading: boolean;
681
+ error: PermissionHintsError | null;
682
+ }
683
+ let snapshot=$state.raw<Snapshot | null>(null);
684
+ let epoch=0;
685
+ let disposed=false;
686
+ let observedProvider=adminContext.authProvider;
687
+ let observedLogoutVersion=authSession(observedProvider).logout;
688
+ let version=$state(0);
689
+
690
+ function current(value: Snapshot | null): value is Snapshot {
691
+ return !disposed && value !== null
692
+ && value.provider === adminContext.authProvider
693
+ && value.tenant === adminContext.tenantCacheKey?.__svadminTenant
694
+ && value.authVersion === authSession(value.provider).version
695
+ && value.logout === authSession(value.provider).logout;
413
696
  }
414
697
 
415
- if (typeof window !== 'undefined') {
416
- fetch();
698
+ async function fetch(): Promise<void> {
699
+ if(disposed) return;
700
+ const provider=adminContext.authProvider;
701
+ const request=++epoch;
702
+ const started: Snapshot={
703
+ provider, tenant: adminContext.tenantCacheKey?.__svadminTenant,
704
+ logout: authSession(provider).logout, authVersion: authSession(provider).version,
705
+ hints: null, isLoading: true, error: null,
706
+ };
707
+ snapshot=started;
708
+ const fail=(code: PermissionHintsError['code']) => {
709
+ if(request!==epoch || !current(started)) return;
710
+ snapshot={ ...started, isLoading: false, error: new PermissionHintsError(code) };
711
+ };
712
+ let response: unknown;
713
+ try {
714
+ const getPermissions=provider?.getPermissions;
715
+ if(getPermissions===undefined) {
716
+ snapshot={ ...started, isLoading: false };
717
+ return;
718
+ }
719
+ if(typeof getPermissions!=='function') {
720
+ fail('PERMISSION_HINTS_FAILED');
721
+ return;
722
+ }
723
+ response=await getPermissions.call(provider);
724
+ } catch {
725
+ fail('PERMISSION_HINTS_FAILED');
726
+ return;
727
+ }
728
+ try {
729
+ const hints=decodePermissionHints(response);
730
+ if(request!==epoch || !current(started)) return;
731
+ snapshot={ ...started, hints, isLoading: false };
732
+ version++;
733
+ } catch {
734
+ fail('INVALID_PERMISSION_HINTS');
735
+ }
417
736
  }
418
737
 
419
- const hasFn = (perm: string): boolean => {
420
- if (!permissions) return false;
421
- if (Array.isArray(permissions)) return permissions.includes(perm);
422
- if (permissions instanceof Set) return (permissions as Set<string>).has(perm);
423
- if (typeof permissions === 'object') return !!(permissions as Record<string, boolean>)[perm];
424
- return false;
425
- };
738
+ $effect(() => {
739
+ const provider=adminContext.authProvider;
740
+ const tenant=adminContext.tenantCacheKey?.__svadminTenant;
741
+ const logout=authSession(provider).logout;
742
+ const authVersion=authSession(provider).version;
743
+ if(provider===observedProvider && logout!==observedLogoutVersion) {
744
+ observedLogoutVersion=logout;
745
+ ++epoch;
746
+ snapshot={ provider, tenant, logout, authVersion, hints: null, isLoading: false, error: null };
747
+ return;
748
+ }
749
+ observedProvider=provider;
750
+ observedLogoutVersion=logout;
751
+ untrack(() => { void fetch(); });
752
+ return () => { ++epoch; };
753
+ });
754
+ $effect(() => () => { disposed=true; ++epoch; });
755
+
756
+ const raw=(): PermissionHints => current(snapshot) ? snapshot.hints : null;
757
+ const hasFn=(permission: string): boolean => hasPermissionHint(raw(),permission);
426
758
 
427
759
  return {
428
- get isLoading() { return isLoading; },
429
- get error() { return error; },
760
+ get isLoading() { return current(snapshot) ? snapshot.isLoading : !disposed && typeof window!=='undefined'; },
761
+ get error() { return current(snapshot) ? snapshot.error : null; },
430
762
  get version() { return version; },
431
- get raw() { return permissions; },
763
+ get raw() { return raw(); },
432
764
  refetch: fetch,
433
765
 
434
766
  /** Check if a specific permission string exists */
435
767
  has: hasFn,
436
768
 
437
769
  /** Check resource:action style permission */
438
- can: (resource: string, action: string): boolean => hasFn(`${resource}:${action}`),
770
+ can: (resource: string,action: string): boolean => hasFn(`${resource}:${action}`),
439
771
  };
440
772
  }