@classic-homes/auth 1.42.1 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -337,6 +337,44 @@ token.
337
337
  Call `initCrossTabSync()` once at app start so a refresh or sign-out in another
338
338
  tab rehydrates this tab's Svelte store.
339
339
 
340
+ ## API errors and access debugging
341
+
342
+ A non-2xx response from `apiRequest` / `api.*` throws an `ApiError`. Its
343
+ `message` is the server's message (as before), plus `status`, the CHAPI `code`,
344
+ `details`, `method` and `endpoint`:
345
+
346
+ ```typescript
347
+ import { isApiError } from '@classic-homes/auth/core';
348
+
349
+ try {
350
+ await api.get('/homes', true);
351
+ } catch (e) {
352
+ if (isApiError(e) && e.status === 403) {
353
+ console.log(e.details?.required); // e.g. ['homes:read']
354
+ }
355
+ }
356
+ ```
357
+
358
+ ### Access probe
359
+
360
+ Hooks for troubleshooting tools such as the Access Inspector. They are inert
361
+ until installed and do nothing on the server.
362
+
363
+ - `setAccessProbe(fn)` receives every access decision: core `hasRole*`, store
364
+ `has*`, `checkAuth` (`source: 'route'`), `filterByAccess`/`canAccess`
365
+ (`'nav'`) and API 403s (`'http'`, path only, never the query string).
366
+ Each event carries the requirement, `allowed` and what was `missing`.
367
+ - `setAccessSubject(userId, { roles, permissions })` previews the UI as another
368
+ role set ("view as") for that user only. Checks for other users are
369
+ unaffected, `authStore.user` is never changed, and the preview ends on any
370
+ login, logout or session expiry. Sign-in role gating (`allowedRoles`) always
371
+ uses the real roles.
372
+ - `evaluateAccess(subject, requirement)` is the shared any/all decision
373
+ `checkAuth` uses; `checkAuth` results now include `missing` on denial.
374
+
375
+ These change UI decisions only. The API authorizes every request with the real
376
+ token, so a preview can never grant access.
377
+
340
378
  ## API Reference
341
379
 
342
380
  ### Core Exports
@@ -376,6 +414,16 @@ import {
376
414
  hasAnyRole,
377
415
  hasAllRoles,
378
416
 
417
+ // Errors
418
+ ApiError,
419
+ isApiError,
420
+
421
+ // Access probe (see "API errors and access debugging")
422
+ setAccessProbe,
423
+ setAccessSubject,
424
+ clearAccessSubject,
425
+ evaluateAccess,
426
+
379
427
  // Types
380
428
  type User,
381
429
  type UserRolesResponse,
@@ -0,0 +1,3 @@
1
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-W2ASN4NE.js';
2
+ import './chunk-ADCPJOPQ.js';
3
+ import './chunk-5X6E5MZG.js';
@@ -1,5 +1,5 @@
1
- import { authApi, InvalidAuthResponseError, RoleDeniedError } from './chunk-QV3DUSP5.js';
2
- import { hasAnyRole, getUserRoles } from './chunk-K6TV3ZVS.js';
1
+ import { authApi, InvalidAuthResponseError, RoleDeniedError } from './chunk-ADCPJOPQ.js';
2
+ import { getUserRoles } from './chunk-5X6E5MZG.js';
3
3
 
4
4
  // src/core/guards.ts
5
5
  function isMfaChallengeResponse(response) {
@@ -19,7 +19,7 @@ function getAvailableMethods(response) {
19
19
  async function setStoreAuth(response) {
20
20
  let store;
21
21
  try {
22
- store = (await import('./auth.svelte-FUCU4DYP.js')).authStore;
22
+ store = (await import('./auth.svelte-AN7ZNZ2W.js')).authStore;
23
23
  } catch {
24
24
  return;
25
25
  }
@@ -44,7 +44,7 @@ var AuthService = class {
44
44
  throw new InvalidAuthResponseError();
45
45
  }
46
46
  if (options?.allowedRoles?.length) {
47
- if (!hasAnyRole(response.user, options.allowedRoles)) {
47
+ if (!holdsAnyRole(response.user, options.allowedRoles)) {
48
48
  throw new RoleDeniedError(
49
49
  response.user,
50
50
  options.allowedRoles,
@@ -224,7 +224,7 @@ var AuthService = class {
224
224
  throw new InvalidAuthResponseError();
225
225
  }
226
226
  if (options?.allowedRoles?.length) {
227
- if (!hasAnyRole(response.user, options.allowedRoles)) {
227
+ if (!holdsAnyRole(response.user, options.allowedRoles)) {
228
228
  throw new RoleDeniedError(
229
229
  response.user,
230
230
  options.allowedRoles,
@@ -332,6 +332,10 @@ var AuthService = class {
332
332
  }
333
333
  };
334
334
  var authService = new AuthService();
335
+ function holdsAnyRole(user, allowed) {
336
+ const held = getUserRoles(user);
337
+ return allowed.some((role) => held.includes(role));
338
+ }
335
339
 
336
340
  // src/core/user-utils.ts
337
341
  function getDisplayName(user) {
@@ -193,6 +193,101 @@ function extractClaims(token, claims) {
193
193
  return result;
194
194
  }
195
195
 
196
+ // src/core/access-probe.ts
197
+ var isBrowser = typeof window !== "undefined";
198
+ var probe = null;
199
+ var override = null;
200
+ var overrideListeners = /* @__PURE__ */ new Set();
201
+ function setAccessProbe(next) {
202
+ const previous = probe;
203
+ if (isBrowser || next === null) probe = next;
204
+ return previous;
205
+ }
206
+ function isAccessProbeActive() {
207
+ return probe !== null;
208
+ }
209
+ function reportAccess(event) {
210
+ if (!probe) return;
211
+ try {
212
+ probe(event);
213
+ } catch {
214
+ }
215
+ }
216
+ function setAccessSubject(userId, subject) {
217
+ if (!isBrowser) return;
218
+ override = {
219
+ userId,
220
+ subject: { roles: [...subject.roles], permissions: [...subject.permissions] }
221
+ };
222
+ notifyOverride();
223
+ }
224
+ function clearAccessSubject() {
225
+ if (!override) return;
226
+ override = null;
227
+ notifyOverride();
228
+ }
229
+ function getAccessSubjectOverride() {
230
+ return override;
231
+ }
232
+ function onAccessSubjectChange(listener) {
233
+ overrideListeners.add(listener);
234
+ return () => overrideListeners.delete(listener);
235
+ }
236
+ function notifyOverride() {
237
+ for (const listener of overrideListeners) {
238
+ try {
239
+ listener();
240
+ } catch {
241
+ }
242
+ }
243
+ }
244
+ function effectiveSubject(user) {
245
+ if (override && user?.id !== void 0 && user.id === override.userId) {
246
+ return { ...override.subject, simulated: true };
247
+ }
248
+ return { roles: user?.roles ?? [], permissions: user?.permissions ?? [], simulated: false };
249
+ }
250
+ function evaluateList(required, held, requireAll) {
251
+ if (!required || required.length === 0) return { ok: true, missing: [] };
252
+ const absent = required.filter((item) => !held.includes(item));
253
+ const ok = requireAll ? absent.length === 0 : absent.length < required.length;
254
+ return { ok, missing: ok ? [] : requireAll ? absent : [...required] };
255
+ }
256
+ function evaluateAccess(subject, requirement) {
257
+ const roles = evaluateList(requirement.roles, subject.roles, requirement.requireAllRoles);
258
+ const permissions = evaluateList(
259
+ requirement.permissions,
260
+ subject.permissions,
261
+ requirement.requireAllPermissions
262
+ );
263
+ return {
264
+ allowed: roles.ok && permissions.ok,
265
+ missing: { roles: roles.missing, permissions: permissions.missing }
266
+ };
267
+ }
268
+ function reportDecision(source, user, requirement, allowed, label) {
269
+ if (!probe) return;
270
+ const subject = effectiveSubject(user);
271
+ reportAccess({
272
+ source,
273
+ requirement,
274
+ allowed,
275
+ missing: allowed ? { roles: [], permissions: [] } : evaluateAccess(subject, requirement).missing,
276
+ subjectId: user?.id ?? null,
277
+ simulated: subject.simulated,
278
+ label
279
+ });
280
+ }
281
+ function requirementFromDenial(code, details) {
282
+ if (!details || typeof details !== "object") return null;
283
+ const { required, mode } = details;
284
+ const list = typeof required === "string" ? [required] : Array.isArray(required) ? required.filter((item) => typeof item === "string") : [];
285
+ if (list.length === 0) return null;
286
+ const isPermission = code === "INSUFFICIENT_PERMISSIONS" || list.some((p) => p.includes(":"));
287
+ const requireAll = mode === "any" ? false : mode === "all" ? true : isPermission;
288
+ return isPermission ? { permissions: list, requireAllPermissions: requireAll } : { roles: list, requireAllRoles: requireAll };
289
+ }
290
+
196
291
  // src/core/roles.ts
197
292
  var ROLE_NAMES = [
198
293
  "admin",
@@ -275,15 +370,21 @@ function getEffectiveLevel(roles) {
275
370
  return max;
276
371
  }
277
372
  function hasRole(user, role) {
278
- return getUserRoles(user).includes(role);
373
+ const allowed = effectiveSubject(user).roles.includes(role);
374
+ reportDecision("check", user, { roles: [role] }, allowed);
375
+ return allowed;
279
376
  }
280
377
  function hasAnyRole(user, roles) {
281
- const held = getUserRoles(user);
282
- return roles.some((role) => held.includes(role));
378
+ const held = effectiveSubject(user).roles;
379
+ const allowed = roles.some((role) => held.includes(role));
380
+ if (roles.length > 0) reportDecision("check", user, { roles }, allowed);
381
+ return allowed;
283
382
  }
284
383
  function hasAllRoles(user, roles) {
285
- const held = getUserRoles(user);
286
- return roles.every((role) => held.includes(role));
384
+ const held = effectiveSubject(user).roles;
385
+ const allowed = roles.every((role) => held.includes(role));
386
+ if (roles.length > 0) reportDecision("check", user, { roles, requireAllRoles: true }, allowed);
387
+ return allowed;
287
388
  }
288
389
 
289
- export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, isValidRedirectUrl, isValidSSOLogoutUrl, resetConfig };
390
+ export { ROLE_HIERARCHY, ROLE_NAMES, clearAccessSubject, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, effectiveSubject, evaluateAccess, extractClaims, getAccessSubjectOverride, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isAccessProbeActive, isInitialized, isTokenExpired, isValidRedirectUrl, isValidSSOLogoutUrl, onAccessSubjectChange, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject };
@@ -1,4 +1,4 @@
1
- import { isInitialized, getConfig, getUserRoles, getStorage, isTokenExpired, getFetch, isValidRedirectUrl, decodeJWT } from './chunk-K6TV3ZVS.js';
1
+ import { isInitialized, getConfig, getUserRoles, clearAccessSubject, getStorage, isTokenExpired, getFetch, isAccessProbeActive, isValidRedirectUrl, requirementFromDenial, reportAccess, decodeJWT } from './chunk-5X6E5MZG.js';
2
2
 
3
3
  // src/core/sso-state.ts
4
4
  var SSO_CLIENT_STATE_PARAM = "client_state";
@@ -119,11 +119,29 @@ var MFAChallengeEmailError = class _MFAChallengeEmailError extends Error {
119
119
  }
120
120
  }
121
121
  };
122
+ var ApiError = class _ApiError extends Error {
123
+ constructor(message, init) {
124
+ super(message);
125
+ this.name = "ApiError";
126
+ this.status = init.status;
127
+ this.code = init.code;
128
+ this.details = init.details;
129
+ this.method = init.method;
130
+ this.endpoint = init.endpoint;
131
+ if (Error.captureStackTrace) {
132
+ Error.captureStackTrace(this, _ApiError);
133
+ }
134
+ }
135
+ };
136
+ function isApiError(error) {
137
+ return error instanceof ApiError || error?.name === "ApiError";
138
+ }
122
139
 
123
140
  // src/core/session.ts
124
141
  var generation = 0;
125
142
  function bumpSessionGeneration() {
126
143
  generation += 1;
144
+ clearAccessSubject();
127
145
  }
128
146
  function readIdentity() {
129
147
  let parsed = {};
@@ -218,7 +236,7 @@ function writeStoredTokens(accessToken, refreshToken) {
218
236
  }
219
237
  async function syncSvelteStore(apply) {
220
238
  try {
221
- const { authStore } = await import('./auth.svelte-FUCU4DYP.js');
239
+ const { authStore } = await import('./auth.svelte-AN7ZNZ2W.js');
222
240
  apply(authStore);
223
241
  } catch {
224
242
  }
@@ -371,6 +389,31 @@ function isApiOrigin(url, baseUrl) {
371
389
  return false;
372
390
  }
373
391
  }
392
+ function isRecord(value) {
393
+ return typeof value === "object" && value !== null && !Array.isArray(value);
394
+ }
395
+ function reportForbidden(method, endpoint, code, details) {
396
+ let path = endpoint.split(/[?#]/, 1)[0];
397
+ try {
398
+ if (/^https?:\/\//i.test(path)) path = new URL(path).pathname;
399
+ } catch {
400
+ }
401
+ const requirement = requirementFromDenial(code, details) ?? {};
402
+ reportAccess({
403
+ source: "http",
404
+ requirement,
405
+ allowed: false,
406
+ missing: {
407
+ roles: [...requirement.roles ?? []],
408
+ permissions: [...requirement.permissions ?? []]
409
+ },
410
+ // The server decided against the real token; whose id is the caller's business.
411
+ subjectId: null,
412
+ simulated: false,
413
+ label: `${method} ${path}`,
414
+ http: { method, endpoint: path, status: 403, code }
415
+ });
416
+ }
374
417
  function apiRequest(endpoint, options = {}) {
375
418
  return apiRequestInternal(endpoint, options, false);
376
419
  }
@@ -496,7 +539,19 @@ async function apiRequestInternal(endpoint, options, isRetry, startedIn) {
496
539
  if (!response.ok) {
497
540
  const errorBody = data ?? {};
498
541
  const errorMessage = errorBody.error?.message || (typeof errorBody.message === "string" ? errorBody.message : void 0) || errorBody.meta?.message || (typeof errorBody.detail === "string" ? errorBody.detail : void 0) || `Request failed (${response.status})`;
499
- const error = new Error(errorMessage);
542
+ const code = typeof errorBody.error?.code === "string" ? errorBody.error.code : void 0;
543
+ const details = isRecord(errorBody.error?.details) ? errorBody.error.details : void 0;
544
+ const method = (fetchOptions.method ?? "GET").toUpperCase();
545
+ const error = new ApiError(errorMessage, {
546
+ status: response.status,
547
+ code,
548
+ details,
549
+ method,
550
+ endpoint
551
+ });
552
+ if (response.status === 403 && isAccessProbeActive()) {
553
+ reportForbidden(method, endpoint, code, details);
554
+ }
500
555
  config.onAuthError?.(error);
501
556
  throw error;
502
557
  }
@@ -1100,4 +1155,4 @@ var authApi = {
1100
1155
  }
1101
1156
  };
1102
1157
 
1103
- export { InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, bumpSessionGeneration, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState };
1158
+ export { ApiError, InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, bumpSessionGeneration, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isApiError, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState };
@@ -1,6 +1,6 @@
1
- import { authService } from './chunk-A6JJQCBU.js';
2
- import { authStore, currentUser, isAuthenticated, authActions } from './chunk-IR6DVUUF.js';
3
- import { isInitialized, initAuth, getUserRoles } from './chunk-K6TV3ZVS.js';
1
+ import { authService } from './chunk-42EO3FSU.js';
2
+ import { authStore, currentUser, isAuthenticated, authActions } from './chunk-W2ASN4NE.js';
3
+ import { evaluateAccess, effectiveSubject, isAccessProbeActive, reportDecision, isInitialized, initAuth } from './chunk-5X6E5MZG.js';
4
4
 
5
5
  // src/svelte/guards/auth-guard.ts
6
6
  function checkAuth(options = {}) {
@@ -12,28 +12,26 @@ function checkAuth(options = {}) {
12
12
  redirectTo: "/login"
13
13
  };
14
14
  }
15
- if (roles && roles.length > 0) {
16
- const hasRoles = requireAllRoles ? authStore.hasAllRoles(roles) : authStore.hasAnyRole(roles);
17
- if (!hasRoles) {
18
- return {
19
- allowed: false,
20
- reason: "missing_role",
21
- redirectTo: "/unauthorized"
22
- };
23
- }
15
+ const requirement = { roles, permissions, requireAllRoles, requireAllPermissions };
16
+ const user = authStore.user;
17
+ const { allowed, missing } = evaluateAccess(effectiveSubject(user), requirement);
18
+ if (isAccessProbeActive() && ((roles?.length ?? 0) > 0 || (permissions?.length ?? 0) > 0)) {
19
+ reportDecision("route", user, requirement, allowed, routeLabel());
24
20
  }
25
- if (permissions && permissions.length > 0) {
26
- const hasPermissions = requireAllPermissions ? authStore.hasAllPermissions(permissions) : authStore.hasAnyPermission(permissions);
27
- if (!hasPermissions) {
28
- return {
29
- allowed: false,
30
- reason: "missing_permission",
31
- redirectTo: "/unauthorized"
32
- };
33
- }
21
+ if (!allowed) {
22
+ return {
23
+ allowed: false,
24
+ // Roles are checked first: a role miss is reported even if permissions also fail.
25
+ reason: missing.roles.length > 0 ? "missing_role" : "missing_permission",
26
+ redirectTo: "/unauthorized",
27
+ missing
28
+ };
34
29
  }
35
30
  return { allowed: true };
36
31
  }
32
+ function routeLabel() {
33
+ return typeof window !== "undefined" ? window.location?.pathname : void 0;
34
+ }
37
35
  function createAuthGuard(options = {}) {
38
36
  return (onDenied) => {
39
37
  const result = checkAuth(options);
@@ -92,8 +90,9 @@ function filterByAccess(items, user, options = {}) {
92
90
  childrenKey,
93
91
  pruneEmptyParents = true
94
92
  } = options;
95
- const userRoles = getUserRoles(user);
96
- const userPerms = user?.permissions || [];
93
+ const subject = effectiveSubject(user);
94
+ const userRoles = subject.roles;
95
+ const userPerms = subject.permissions;
97
96
  function isVisible(item) {
98
97
  const hasRoleRestriction = item.roles && item.roles.length > 0;
99
98
  const hasPermRestriction = item.permissions && item.permissions.length > 0;
@@ -106,6 +105,24 @@ function filterByAccess(items, user, options = {}) {
106
105
  if (!user) {
107
106
  return false;
108
107
  }
108
+ const allowed = isAllowed(item);
109
+ reportDecision(
110
+ "nav",
111
+ user,
112
+ {
113
+ roles: item.roles,
114
+ permissions: item.permissions,
115
+ requireAllRoles,
116
+ requireAllPermissions
117
+ },
118
+ allowed,
119
+ item.id ?? item.href
120
+ );
121
+ return allowed;
122
+ }
123
+ function isAllowed(item) {
124
+ const hasRoleRestriction = item.roles && item.roles.length > 0;
125
+ const hasPermRestriction = item.permissions && item.permissions.length > 0;
109
126
  if (hasRoleRestriction) {
110
127
  const hasRoles = requireAllRoles ? item.roles.every((r) => userRoles.includes(r)) : item.roles.some((r) => userRoles.includes(r));
111
128
  if (!hasRoles) {
@@ -1,5 +1,5 @@
1
- import { InvalidAuthResponseError, bumpSessionGeneration, authApi } from './chunk-QV3DUSP5.js';
2
- import { isInitialized, getConfig, isValidSSOLogoutUrl, hasRole, hasAnyRole, hasAllRoles, decodeJWT, getStorage, getDefaultStorage } from './chunk-K6TV3ZVS.js';
1
+ import { InvalidAuthResponseError, bumpSessionGeneration, authApi } from './chunk-ADCPJOPQ.js';
2
+ import { onAccessSubjectChange, isInitialized, getConfig, isValidSSOLogoutUrl, hasRole, hasAnyRole, hasAllRoles, effectiveSubject, reportDecision, decodeJWT, getStorage, getDefaultStorage } from './chunk-5X6E5MZG.js';
3
3
 
4
4
  // src/svelte/stores/auth.svelte.ts
5
5
  var STORAGE_VERSION = 2;
@@ -72,6 +72,7 @@ var AuthStore = class {
72
72
  constructor() {
73
73
  this.subscribers = /* @__PURE__ */ new Set();
74
74
  this.state = loadAuthFromStorage();
75
+ onAccessSubjectChange(() => this.notify());
75
76
  }
76
77
  /**
77
78
  * Subscribe to state changes (Svelte store contract).
@@ -249,7 +250,7 @@ var AuthStore = class {
249
250
  * Check if user has a specific permission.
250
251
  */
251
252
  hasPermission(permission) {
252
- return this.state.user?.permissions?.includes(permission) ?? false;
253
+ return this.checkPermissions([permission], false);
253
254
  }
254
255
  /**
255
256
  * Check if the user holds a specific role.
@@ -275,13 +276,27 @@ var AuthStore = class {
275
276
  * Check if user has any of the specified permissions.
276
277
  */
277
278
  hasAnyPermission(permissions) {
278
- return permissions.some((perm) => this.state.user?.permissions?.includes(perm)) ?? false;
279
+ return this.checkPermissions(permissions, false);
279
280
  }
280
281
  /**
281
282
  * Check if user has all of the specified permissions.
282
283
  */
283
284
  hasAllPermissions(permissions) {
284
- return permissions.every((perm) => this.state.user?.permissions?.includes(perm)) ?? false;
285
+ return this.checkPermissions(permissions, true);
286
+ }
287
+ /**
288
+ * Permission checks share one path so they honor the access probe's
289
+ * "view as" override and report to it. A guest holds no permissions.
290
+ */
291
+ checkPermissions(permissions, requireAll) {
292
+ const user = this.state.user;
293
+ const held = effectiveSubject(user).permissions;
294
+ const allowed = requireAll ? permissions.every((perm) => held.includes(perm)) : permissions.some((perm) => held.includes(perm));
295
+ if (permissions.length > 0) {
296
+ const requirement = { permissions, requireAllPermissions: requireAll };
297
+ reportDecision("check", user, requirement, allowed);
298
+ }
299
+ return allowed;
285
300
  }
286
301
  /**
287
302
  * Re-hydrate state from storage (useful after config changes).
@@ -1,6 +1,6 @@
1
1
  import { e as LoginCredentials, f as LoginResponse, g as LogoutResponse, R as RegisterData, l as RegisterResponse, q as User, k as ProfileUpdateData, C as ChangePasswordData, o as Session, A as ApiKey, c as CreateApiKeyRequest, d as CreateApiKeyResponse, U as UpdateApiKeyRequest, j as MFAStatus, i as MFASetupResponse, M as MFAChallengeData, h as MFAChallengeEmailResponse, D as Device, r as UserPreferences, L as LinkedAccount, n as SecurityEvent, P as Pagination, s as UserRolesResponse } from '../types-DF5Ny2IA.js';
2
2
  export { a as AuthConfig, b as AuthState, m as ResetPasswordData, S as SSOConfig, p as StorageAdapter, t as createMemoryStorage, u as createSessionStorage, v as getConfig, w as getDefaultStorage, x as getFetch, y as getStorage, z as initAuth, B as isInitialized, E as resetConfig } from '../types-DF5Ny2IA.js';
3
- export { A as AuthService, I as InvalidAuthResponseError, L as LoginOptions, M as MFAChallengeEmailError, a as MFAVerifyOptions, R as RoleDeniedError, S as SessionChangedError, b as authService, f as formatUserRoles, g as getAvatarFallback, c as getDisplayName, d as getGreeting, e as getUserEmail, h as getUserInitials, i as isRoleDeniedError, j as isSessionChangedError } from '../user-utils-Bo3lMsuT.js';
3
+ export { A as ApiError, a as AuthService, I as InvalidAuthResponseError, L as LoginOptions, M as MFAChallengeEmailError, b as MFAVerifyOptions, R as RoleDeniedError, S as SessionChangedError, c as authService, f as formatUserRoles, g as getAvatarFallback, d as getDisplayName, e as getGreeting, h as getUserEmail, i as getUserInitials, j as isApiError, k as isRoleDeniedError, l as isSessionChangedError } from '../user-utils-DYyljbJR.js';
4
4
 
5
5
  /**
6
6
  * SSO client state
@@ -684,23 +684,156 @@ declare function computePrimaryRole(roles: readonly string[]): RoleName;
684
684
  * ```
685
685
  */
686
686
  declare function getEffectiveLevel(roles: readonly string[]): number;
687
+ /** A user as the role checks accept it; `id` lets "view as" target the session user. */
688
+ type RoleSubject = (Pick<User, 'roles'> & {
689
+ id?: string;
690
+ permissions?: string[];
691
+ }) | null | undefined;
687
692
  /**
688
693
  * Whether a user holds a specific role.
689
694
  *
690
695
  * Exact membership — holding `admin` does NOT imply `agent`. This matches the
691
696
  * API's `requireRole`, so the UI can't offer something the server will then
692
697
  * reject with a 403.
698
+ *
699
+ * Honors the access probe's "view as" override for the user it targets.
693
700
  */
694
- declare function hasRole(user: Pick<User, 'roles'> | null | undefined, role: string): boolean;
701
+ declare function hasRole(user: RoleSubject, role: string): boolean;
695
702
  /**
696
703
  * Whether a user holds at least one of the given roles.
697
704
  * An empty `roles` list matches nothing.
698
705
  */
699
- declare function hasAnyRole(user: Pick<User, 'roles'> | null | undefined, roles: readonly string[]): boolean;
706
+ declare function hasAnyRole(user: RoleSubject, roles: readonly string[]): boolean;
700
707
  /**
701
708
  * Whether a user holds every one of the given roles.
702
709
  * An empty `roles` list matches vacuously (nothing was required).
703
710
  */
704
- declare function hasAllRoles(user: Pick<User, 'roles'> | null | undefined, roles: readonly string[]): boolean;
711
+ declare function hasAllRoles(user: RoleSubject, roles: readonly string[]): boolean;
712
+
713
+ /**
714
+ * Access probe
715
+ *
716
+ * Hooks that let a troubleshooting tool (the Access Inspector) see every
717
+ * role/permission decision the UI makes, and preview the UI as another
718
+ * role set ("view as").
719
+ *
720
+ * Both hooks are inert by default: with no probe installed a check costs one
721
+ * null test, and with no subject override every check reads the real user.
722
+ * They are browser-only: on the server the module state would be shared by
723
+ * every request, so the setters are no-ops there.
724
+ *
725
+ * SECURITY: this changes UI decisions only. The API still authorizes every
726
+ * request with the real token, so "view as" can never grant access.
727
+ */
728
+ /** What a piece of UI or a request requires. Empty or absent lists are no restriction. */
729
+ interface AccessRequirement {
730
+ roles?: readonly string[];
731
+ permissions?: readonly string[];
732
+ /** Require every listed role (default: any one of them). */
733
+ requireAllRoles?: boolean;
734
+ /** Require every listed permission (default: any one of them). */
735
+ requireAllPermissions?: boolean;
736
+ }
737
+ /** The roles and permissions a decision was made against. */
738
+ interface AccessSubject {
739
+ roles: readonly string[];
740
+ permissions: readonly string[];
741
+ }
742
+ /** Outcome of {@link evaluateAccess}. */
743
+ interface AccessEvaluation {
744
+ allowed: boolean;
745
+ /**
746
+ * What the subject lacks. In "any" mode a failed list is reported whole
747
+ * (holding any one of them would do); in "all" mode only the absent items.
748
+ */
749
+ missing: {
750
+ roles: string[];
751
+ permissions: string[];
752
+ };
753
+ }
754
+ /** Where a reported decision came from. */
755
+ type AccessCheckSource =
756
+ /** `hasRole` / `hasPermission` and friends (core helpers and the store). */
757
+ 'check'
758
+ /** `checkAuth` and the route guards built on it. */
759
+ | 'route'
760
+ /** `filterByAccess` / `canAccess` (navigation). */
761
+ | 'nav'
762
+ /** A 403 from the API. */
763
+ | 'http'
764
+ /** A UI element (the inspector's own `RequireAccess` / `requires`). */
765
+ | 'element';
766
+ interface AccessCheckEvent extends AccessEvaluation {
767
+ source: AccessCheckSource;
768
+ requirement: AccessRequirement;
769
+ /** Id of the user checked, or null for a guest. Checks also run for other users (admin lists). */
770
+ subjectId: string | null;
771
+ /** True when the decision used the "view as" override instead of the real user. */
772
+ simulated: boolean;
773
+ /** Human-readable context: a nav item id/href, or `METHOD /path` for HTTP. */
774
+ label?: string;
775
+ /** Present for `http` events. */
776
+ http?: {
777
+ method: string;
778
+ endpoint: string;
779
+ status: number;
780
+ code?: string;
781
+ };
782
+ }
783
+ type AccessProbe = (event: AccessCheckEvent) => void;
784
+ type SubjectUser = {
785
+ id?: string;
786
+ roles?: readonly string[];
787
+ permissions?: readonly string[];
788
+ };
789
+ /**
790
+ * Install (or with `null`, remove) the function that receives every access
791
+ * decision. Only one probe at a time; returns the previous one. No-op on the server.
792
+ */
793
+ declare function setAccessProbe(next: AccessProbe | null): AccessProbe | null;
794
+ /** Whether a probe is installed — check before building an event. */
795
+ declare function isAccessProbeActive(): boolean;
796
+ /** Hand an event to the probe, if any. A throwing probe never breaks the check. */
797
+ declare function reportAccess(event: AccessCheckEvent): void;
798
+ /**
799
+ * Make access checks for `userId` use `subject` instead of that user's real
800
+ * roles and permissions. Checks for any other user are unaffected, so admin
801
+ * screens listing other people keep working. No-op on the server.
802
+ */
803
+ declare function setAccessSubject(userId: string, subject: AccessSubject): void;
804
+ /** Remove the "view as" override. */
805
+ declare function clearAccessSubject(): void;
806
+ /** The active override, if any. */
807
+ declare function getAccessSubjectOverride(): {
808
+ userId: string;
809
+ subject: AccessSubject;
810
+ } | null;
811
+ /** Called whenever the override changes, so stores can re-run their checks. */
812
+ declare function onAccessSubjectChange(listener: () => void): () => void;
813
+ /**
814
+ * The roles and permissions to decide access with for `user`: the override
815
+ * when it targets this user, otherwise the user's own.
816
+ */
817
+ declare function effectiveSubject(user: SubjectUser | null | undefined): AccessSubject & {
818
+ simulated: boolean;
819
+ };
820
+ /**
821
+ * Decide a requirement against a subject. Roles and permissions must both
822
+ * pass when both are given; within each list `requireAll*` picks all vs any.
823
+ * Exact membership, like the API (`admin` does not imply `agent`).
824
+ */
825
+ declare function evaluateAccess(subject: AccessSubject, requirement: AccessRequirement): AccessEvaluation;
826
+ /**
827
+ * Report a decision the caller already made. Recomputes `missing` against
828
+ * the same subject so every source reports it the same way.
829
+ */
830
+ declare function reportDecision(source: AccessCheckSource, user: SubjectUser | null | undefined, requirement: AccessRequirement, allowed: boolean, label?: string): void;
831
+ /**
832
+ * Read what a CHAPI 403 says was required. `requirePermissions` sends
833
+ * `{ required: string[] }` with code `INSUFFICIENT_PERMISSIONS`;
834
+ * `requireRole` sends `{ required: string }` with code `FORBIDDEN`. An
835
+ * optional `mode: 'any' | 'all'` overrides the defaults (permissions: all).
836
+ */
837
+ declare function requirementFromDenial(code: string | undefined, details: unknown): AccessRequirement | null;
705
838
 
706
- export { ApiKey, type ApiRequestOptions, type ApiResponse, ChangePasswordData, CreateApiKeyRequest, CreateApiKeyResponse, Device, type JWTPayload, LinkedAccount, LoginCredentials, LoginResponse, LogoutResponse, MFAChallengeData, MFAChallengeEmailResponse, MFASetupResponse, MFAStatus, Pagination, ProfileUpdateData, ROLE_HIERARCHY, ROLE_NAMES, type RefreshOutcome, type RefreshSessionOptions, RegisterData, RegisterResponse, type RoleName, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SecurityEvent, Session, type SsoClientStateResult, type SsoClientStateVerification, UpdateApiKeyRequest, User, UserPreferences, UserRolesResponse, api, apiRequest, authApi, clearStoredAuth, computePrimaryRole, consumeSsoClientState, createSsoClientState, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initCrossTabSync, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, refreshSession, updateStoredTokens, verifySsoClientState };
839
+ export { type AccessCheckEvent, type AccessCheckSource, type AccessEvaluation, type AccessProbe, type AccessRequirement, type AccessSubject, ApiKey, type ApiRequestOptions, type ApiResponse, ChangePasswordData, CreateApiKeyRequest, CreateApiKeyResponse, Device, type JWTPayload, LinkedAccount, LoginCredentials, LoginResponse, LogoutResponse, MFAChallengeData, MFAChallengeEmailResponse, MFASetupResponse, MFAStatus, Pagination, ProfileUpdateData, ROLE_HIERARCHY, ROLE_NAMES, type RefreshOutcome, type RefreshSessionOptions, RegisterData, RegisterResponse, type RoleName, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SecurityEvent, Session, type SsoClientStateResult, type SsoClientStateVerification, UpdateApiKeyRequest, User, UserPreferences, UserRolesResponse, api, apiRequest, authApi, clearAccessSubject, clearStoredAuth, computePrimaryRole, consumeSsoClientState, createSsoClientState, decodeJWT, effectiveSubject, evaluateAccess, extractClaims, extractData, getAccessSubjectOverride, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initCrossTabSync, isAccessProbeActive, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, onAccessSubjectChange, refreshSession, reportAccess, reportDecision, requirementFromDenial, setAccessProbe, setAccessSubject, updateStoredTokens, verifySsoClientState };
@@ -1,3 +1,3 @@
1
- export { AuthService, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse } from '../chunk-A6JJQCBU.js';
2
- export { InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState } from '../chunk-QV3DUSP5.js';
3
- export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, resetConfig } from '../chunk-K6TV3ZVS.js';
1
+ export { AuthService, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse } from '../chunk-42EO3FSU.js';
2
+ export { ApiError, InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isApiError, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState } from '../chunk-ADCPJOPQ.js';
3
+ export { ROLE_HIERARCHY, ROLE_NAMES, clearAccessSubject, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, effectiveSubject, evaluateAccess, extractClaims, getAccessSubjectOverride, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isAccessProbeActive, isInitialized, isTokenExpired, onAccessSubjectChange, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject } from '../chunk-5X6E5MZG.js';
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { A as ApiKey, a as AuthConfig, b as AuthState, C as ChangePasswordData, c as CreateApiKeyRequest, d as CreateApiKeyResponse, D as Device, L as LinkedAccount, e as LoginCredentials, f as LoginResponse, g as LogoutResponse, M as MFAChallengeData, h as MFAChallengeEmailResponse, i as MFASetupResponse, j as MFAStatus, P as Pagination, k as ProfileUpdateData, R as RegisterData, l as RegisterResponse, m as ResetPasswordData, S as SSOConfig, n as SecurityEvent, o as Session, p as StorageAdapter, U as UpdateApiKeyRequest, q as User, r as UserPreferences, s as UserRolesResponse, t as createMemoryStorage, u as createSessionStorage, v as getConfig, w as getDefaultStorage, x as getFetch, y as getStorage, z as initAuth, B as isInitialized, E as resetConfig } from './types-DF5Ny2IA.js';
2
- export { ApiRequestOptions, ApiResponse, JWTPayload, ROLE_HIERARCHY, ROLE_NAMES, RefreshOutcome, RefreshSessionOptions, RoleName, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SsoClientStateResult, SsoClientStateVerification, api, apiRequest, authApi, clearStoredAuth, computePrimaryRole, consumeSsoClientState, createSsoClientState, decodeJWT, extractClaims, extractData, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initCrossTabSync, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, refreshSession, updateStoredTokens, verifySsoClientState } from './core/index.js';
3
- export { A as AuthService, I as InvalidAuthResponseError, L as LoginOptions, M as MFAChallengeEmailError, a as MFAVerifyOptions, R as RoleDeniedError, S as SessionChangedError, b as authService, f as formatUserRoles, g as getAvatarFallback, c as getDisplayName, d as getGreeting, e as getUserEmail, h as getUserInitials, i as isRoleDeniedError, j as isSessionChangedError } from './user-utils-Bo3lMsuT.js';
2
+ export { AccessCheckEvent, AccessCheckSource, AccessEvaluation, AccessProbe, AccessRequirement, AccessSubject, ApiRequestOptions, ApiResponse, JWTPayload, ROLE_HIERARCHY, ROLE_NAMES, RefreshOutcome, RefreshSessionOptions, RoleName, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SsoClientStateResult, SsoClientStateVerification, api, apiRequest, authApi, clearAccessSubject, clearStoredAuth, computePrimaryRole, consumeSsoClientState, createSsoClientState, decodeJWT, effectiveSubject, evaluateAccess, extractClaims, extractData, getAccessSubjectOverride, getAccessToken, getAvailableMethods, getEffectiveLevel, getMfaToken, getRefreshToken, getSessionToken, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initCrossTabSync, isAccessProbeActive, isLoginSuccessResponse, isMfaChallengeResponse, isTokenExpired, onAccessSubjectChange, refreshSession, reportAccess, reportDecision, requirementFromDenial, setAccessProbe, setAccessSubject, updateStoredTokens, verifySsoClientState } from './core/index.js';
3
+ export { A as ApiError, a as AuthService, I as InvalidAuthResponseError, L as LoginOptions, M as MFAChallengeEmailError, b as MFAVerifyOptions, R as RoleDeniedError, S as SessionChangedError, c as authService, f as formatUserRoles, g as getAvatarFallback, d as getDisplayName, e as getGreeting, h as getUserEmail, i as getUserInitials, j as isApiError, k as isRoleDeniedError, l as isSessionChangedError } from './user-utils-DYyljbJR.js';
4
4
  export { AuthClient, AuthGuardOptions, AuthGuardResult, AuthHookOptions, CreateAuthClientOptions, NavFilterOptions, RoleRestrictedItem, authActions, authStore, canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, currentUser, filterByAccess, filterNavSections, isAuthenticated, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './svelte/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './chunk-G6UH4HBI.js';
2
- export { AuthService, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse } from './chunk-A6JJQCBU.js';
3
- export { authActions, authStore, currentUser, isAuthenticated } from './chunk-IR6DVUUF.js';
4
- export { InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState } from './chunk-QV3DUSP5.js';
5
- export { ROLE_HIERARCHY, ROLE_NAMES, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, extractClaims, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isInitialized, isTokenExpired, resetConfig } from './chunk-K6TV3ZVS.js';
1
+ export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from './chunk-J2WAFXA6.js';
2
+ export { AuthService, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse } from './chunk-42EO3FSU.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-W2ASN4NE.js';
4
+ export { ApiError, InvalidAuthResponseError, MFAChallengeEmailError, RoleDeniedError, SSO_CLIENT_STATE_PARAM, SSO_CLIENT_STATE_TTL_MS, SessionChangedError, api, apiRequest, authApi, clearStoredAuth, consumeSsoClientState, createSsoClientState, extractData, getAccessToken, getRefreshToken, getSessionToken, initCrossTabSync, isApiError, isRoleDeniedError, isSessionChangedError, refreshSession, updateStoredTokens, verifySsoClientState } from './chunk-ADCPJOPQ.js';
5
+ export { ROLE_HIERARCHY, ROLE_NAMES, clearAccessSubject, computePrimaryRole, createMemoryStorage, createSessionStorage, decodeJWT, effectiveSubject, evaluateAccess, extractClaims, getAccessSubjectOverride, getConfig, getDefaultStorage, getEffectiveLevel, getFetch, getStorage, getTokenExpiration, getTokenRemainingTime, getUserRoles, hasAllRoles, hasAnyRole, hasRole, initAuth, isAccessProbeActive, isInitialized, isTokenExpired, onAccessSubjectChange, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject } from './chunk-5X6E5MZG.js';
@@ -1,7 +1,7 @@
1
1
  import { b as AuthState, q as User, a as AuthConfig } from '../types-DF5Ny2IA.js';
2
2
  export { z as initAuth, B as isInitialized } from '../types-DF5Ny2IA.js';
3
- import { b as authService } from '../user-utils-Bo3lMsuT.js';
4
- export { I as InvalidAuthResponseError, R as RoleDeniedError, S as SessionChangedError, f as formatUserRoles, g as getAvatarFallback, c as getDisplayName, d as getGreeting, e as getUserEmail, h as getUserInitials, i as isRoleDeniedError, j as isSessionChangedError } from '../user-utils-Bo3lMsuT.js';
3
+ import { c as authService } from '../user-utils-DYyljbJR.js';
4
+ export { I as InvalidAuthResponseError, R as RoleDeniedError, S as SessionChangedError, f as formatUserRoles, g as getAvatarFallback, d as getDisplayName, e as getGreeting, h as getUserEmail, i as getUserInitials, k as isRoleDeniedError, l as isSessionChangedError } from '../user-utils-DYyljbJR.js';
5
5
 
6
6
  /**
7
7
  * Auth Store
@@ -90,6 +90,11 @@ declare class AuthStore {
90
90
  * Check if user has all of the specified permissions.
91
91
  */
92
92
  hasAllPermissions(permissions: string[]): boolean;
93
+ /**
94
+ * Permission checks share one path so they honor the access probe's
95
+ * "view as" override and report to it. A guest holds no permissions.
96
+ */
97
+ private checkPermissions;
93
98
  /**
94
99
  * Re-hydrate state from storage (useful after config changes).
95
100
  */
@@ -174,6 +179,14 @@ interface AuthGuardResult {
174
179
  reason?: 'not_authenticated' | 'missing_role' | 'missing_permission';
175
180
  /** Redirect URL suggestion */
176
181
  redirectTo?: string;
182
+ /**
183
+ * What the user lacks when denied for a role or permission: the whole
184
+ * list in "any" mode, only the absent items in "all" mode.
185
+ */
186
+ missing?: {
187
+ roles: string[];
188
+ permissions: string[];
189
+ };
177
190
  }
178
191
  /**
179
192
  * Check if the current user passes the auth guard.
@@ -1,5 +1,5 @@
1
- export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from '../chunk-G6UH4HBI.js';
2
- export { formatUserRoles, getAvatarFallback, getDisplayName, getGreeting, getUserEmail, getUserInitials } from '../chunk-A6JJQCBU.js';
3
- export { authActions, authStore, currentUser, isAuthenticated } from '../chunk-IR6DVUUF.js';
4
- export { InvalidAuthResponseError, RoleDeniedError, SessionChangedError, isRoleDeniedError, isSessionChangedError } from '../chunk-QV3DUSP5.js';
5
- export { initAuth, isInitialized } from '../chunk-K6TV3ZVS.js';
1
+ export { canAccess, checkAuth, createAuthClient, createAuthGuard, createAuthHook, createNavFilter, filterByAccess, filterNavSections, matchesRoute, protectedLoad, requireAuth, requirePermission, requireRole, routePatterns } from '../chunk-J2WAFXA6.js';
2
+ export { formatUserRoles, getAvatarFallback, getDisplayName, getGreeting, getUserEmail, getUserInitials } from '../chunk-42EO3FSU.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from '../chunk-W2ASN4NE.js';
4
+ export { InvalidAuthResponseError, RoleDeniedError, SessionChangedError, isRoleDeniedError, isSessionChangedError } from '../chunk-ADCPJOPQ.js';
5
+ export { initAuth, isInitialized } from '../chunk-5X6E5MZG.js';
@@ -1,4 +1,4 @@
1
- import { hasRole, hasAnyRole, hasAllRoles, initAuth, resetConfig, getUserRoles, isTokenExpired, decodeJWT } from '../chunk-K6TV3ZVS.js';
1
+ import { hasRole, hasAnyRole, hasAllRoles, initAuth, resetConfig, getUserRoles, isTokenExpired, decodeJWT } from '../chunk-5X6E5MZG.js';
2
2
 
3
3
  // src/testing/fixtures/users.ts
4
4
  var mockUser = {
@@ -356,6 +356,38 @@ declare class MFAChallengeEmailError extends Error {
356
356
  readonly retryAfterSeconds?: number;
357
357
  constructor(message: string, status: number, retryAfterSeconds?: number);
358
358
  }
359
+ /**
360
+ * Error thrown by `apiRequest` (and the `api.*` helpers) when the API answers
361
+ * with a non-2xx status. `message` is the server's message, exactly as the
362
+ * plain `Error` used to carry, so existing `catch (e) { e.message }` code is
363
+ * unaffected; the status, CHAPI error code and `details` are kept alongside.
364
+ *
365
+ * For a 403, `details.required` lists what CHAPI said was missing.
366
+ */
367
+ declare class ApiError extends Error {
368
+ readonly name = "ApiError";
369
+ /** HTTP status. */
370
+ readonly status: number;
371
+ /** CHAPI error code (e.g. `INSUFFICIENT_PERMISSIONS`), when sent. */
372
+ readonly code?: string;
373
+ /** CHAPI `error.details`, when sent. */
374
+ readonly details?: Record<string, unknown>;
375
+ /** Request method. */
376
+ readonly method: string;
377
+ /** The endpoint as passed to `apiRequest`. */
378
+ readonly endpoint: string;
379
+ constructor(message: string, init: {
380
+ status: number;
381
+ code?: string;
382
+ details?: Record<string, unknown>;
383
+ method: string;
384
+ endpoint: string;
385
+ });
386
+ }
387
+ /**
388
+ * Type guard to check if an error is an ApiError.
389
+ */
390
+ declare function isApiError(error: unknown): error is ApiError;
359
391
 
360
392
  /**
361
393
  * User Display Utilities
@@ -460,4 +492,4 @@ declare function formatUserRoles(user: User | null | undefined, options?: {
460
492
  capitalize?: boolean;
461
493
  }): string;
462
494
 
463
- export { AuthService as A, InvalidAuthResponseError as I, type LoginOptions as L, MFAChallengeEmailError as M, RoleDeniedError as R, SessionChangedError as S, type MFAVerifyOptions as a, authService as b, getDisplayName as c, getGreeting as d, getUserEmail as e, formatUserRoles as f, getAvatarFallback as g, getUserInitials as h, isRoleDeniedError as i, isSessionChangedError as j };
495
+ export { ApiError as A, InvalidAuthResponseError as I, type LoginOptions as L, MFAChallengeEmailError as M, RoleDeniedError as R, SessionChangedError as S, AuthService as a, type MFAVerifyOptions as b, authService as c, getDisplayName as d, getGreeting as e, formatUserRoles as f, getAvatarFallback as g, getUserEmail as h, getUserInitials as i, isApiError as j, isRoleDeniedError as k, isSessionChangedError as l };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/auth",
3
- "version": "1.42.1",
3
+ "version": "1.45.0",
4
4
  "description": "Authentication services and Svelte bindings for Classic Theme apps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,3 +0,0 @@
1
- export { authActions, authStore, currentUser, isAuthenticated } from './chunk-IR6DVUUF.js';
2
- import './chunk-QV3DUSP5.js';
3
- import './chunk-K6TV3ZVS.js';