@classic-homes/auth 1.42.1 → 1.46.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,51 @@ 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
+ - While a probe is installed, requests to the API origin (never other hosts)
368
+ send `X-Access-Debug: 1`. CHAPI answers admin sessions with
369
+ `X-Access-Redacted: taxId=vendors:read:sensitive, notes=*`, reported as an
370
+ informational `source: 'redacted'` event (`allowed: true`, path only) with
371
+ `fields: { name, permission }[]`; `*` means no permission is configured
372
+ (default-deny). It reflects the real token, never "view as". No header, no
373
+ event. The custom header makes cross-origin API requests preflighted.
374
+ - `setAccessSubject(userId, { roles, permissions })` previews the UI as another
375
+ role set ("view as") for that user only. Checks for other users are
376
+ unaffected, `authStore.user` is never changed, and the preview ends on any
377
+ login, logout or session expiry. Sign-in role gating (`allowedRoles`) always
378
+ uses the real roles.
379
+ - `evaluateAccess(subject, requirement)` is the shared any/all decision
380
+ `checkAuth` uses; `checkAuth` results now include `missing` on denial.
381
+
382
+ These change UI decisions only. The API authorizes every request with the real
383
+ token, so a preview can never grant access.
384
+
340
385
  ## API Reference
341
386
 
342
387
  ### Core Exports
@@ -376,6 +421,16 @@ import {
376
421
  hasAnyRole,
377
422
  hasAllRoles,
378
423
 
424
+ // Errors
425
+ ApiError,
426
+ isApiError,
427
+
428
+ // Access probe (see "API errors and access debugging")
429
+ setAccessProbe,
430
+ setAccessSubject,
431
+ clearAccessSubject,
432
+ evaluateAccess,
433
+
379
434
  // Types
380
435
  type User,
381
436
  type UserRolesResponse,
@@ -0,0 +1,3 @@
1
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-Q4Q3CJVC.js';
2
+ import './chunk-7SFVHDFP.js';
3
+ import './chunk-EFS272S3.js';
@@ -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, ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, parseRedactedHeader, reportAccess, requirementFromDenial, decodeJWT } from './chunk-EFS272S3.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-IWSWWF6U.js');
222
240
  apply(authStore);
223
241
  } catch {
224
242
  }
@@ -371,6 +389,65 @@ 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 probePath(endpoint) {
396
+ let path = endpoint.split(/[?#]/, 1)[0];
397
+ try {
398
+ if (/^https?:\/\//i.test(path)) path = new URL(path).pathname;
399
+ } catch {
400
+ }
401
+ return path;
402
+ }
403
+ function reportRedacted(method, endpoint, response) {
404
+ let header;
405
+ let truncated;
406
+ try {
407
+ header = response.headers.get(ACCESS_REDACTED_HEADER);
408
+ truncated = response.headers.get(ACCESS_REDACTED_TRUNCATED_HEADER);
409
+ } catch {
410
+ return;
411
+ }
412
+ const { fields, omitted } = parseRedactedHeader(header);
413
+ if (fields.length === 0) return;
414
+ const serverOmitted = Number.parseInt(truncated ?? "", 10);
415
+ const fieldsOmitted = omitted + (Number.isFinite(serverOmitted) && serverOmitted > 0 ? Math.min(serverOmitted, 1e6) : 0);
416
+ const permissions = [...new Set(fields.map((f) => f.permission).filter((p) => p !== "*"))];
417
+ const path = probePath(endpoint);
418
+ reportAccess({
419
+ source: "redacted",
420
+ requirement: permissions.length ? { permissions } : {},
421
+ // Informational: the request succeeded, the fields say what was withheld.
422
+ allowed: true,
423
+ missing: { roles: [], permissions: [] },
424
+ // The server redacted for the real token, never the "view as" preview.
425
+ subjectId: null,
426
+ simulated: false,
427
+ label: `${method} ${path}`,
428
+ http: { method, endpoint: path, status: response.status },
429
+ fields,
430
+ ...fieldsOmitted > 0 && { fieldsOmitted }
431
+ });
432
+ }
433
+ function reportForbidden(method, endpoint, code, details) {
434
+ const path = probePath(endpoint);
435
+ const requirement = requirementFromDenial(code, details) ?? {};
436
+ reportAccess({
437
+ source: "http",
438
+ requirement,
439
+ allowed: false,
440
+ missing: {
441
+ roles: [...requirement.roles ?? []],
442
+ permissions: [...requirement.permissions ?? []]
443
+ },
444
+ // The server decided against the real token; whose id is the caller's business.
445
+ subjectId: null,
446
+ simulated: false,
447
+ label: `${method} ${path}`,
448
+ http: { method, endpoint: path, status: 403, code }
449
+ });
450
+ }
374
451
  function apiRequest(endpoint, options = {}) {
375
452
  return apiRequestInternal(endpoint, options, false);
376
453
  }
@@ -422,6 +499,8 @@ async function apiRequestInternal(endpoint, options, isRetry, startedIn) {
422
499
  "Content-Type": "application/json",
423
500
  ...fetchOptions.headers
424
501
  };
502
+ const accessDebug = isAccessProbeActive() && (!isAbsolute || isApiOrigin(url, config.baseUrl));
503
+ if (accessDebug) headers[ACCESS_DEBUG_HEADER] = "1";
425
504
  if (authenticated) {
426
505
  const accessToken = getAccessToken();
427
506
  const sessionToken = getSessionToken();
@@ -455,6 +534,9 @@ async function apiRequestInternal(endpoint, options, isRetry, startedIn) {
455
534
  }
456
535
  throw new Error("Authentication service unavailable. Please try again.");
457
536
  }
537
+ if (accessDebug && isAccessProbeActive()) {
538
+ reportRedacted((fetchOptions.method ?? "GET").toUpperCase(), endpoint, response);
539
+ }
458
540
  let data;
459
541
  const contentType = response.headers.get("content-type");
460
542
  if (contentType?.includes("application/json")) {
@@ -496,7 +578,19 @@ async function apiRequestInternal(endpoint, options, isRetry, startedIn) {
496
578
  if (!response.ok) {
497
579
  const errorBody = data ?? {};
498
580
  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);
581
+ const code = typeof errorBody.error?.code === "string" ? errorBody.error.code : void 0;
582
+ const details = isRecord(errorBody.error?.details) ? errorBody.error.details : void 0;
583
+ const method = (fetchOptions.method ?? "GET").toUpperCase();
584
+ const error = new ApiError(errorMessage, {
585
+ status: response.status,
586
+ code,
587
+ details,
588
+ method,
589
+ endpoint
590
+ });
591
+ if (response.status === 403 && isAccessProbeActive()) {
592
+ reportForbidden(method, endpoint, code, details);
593
+ }
500
594
  config.onAuthError?.(error);
501
595
  throw error;
502
596
  }
@@ -1100,4 +1194,4 @@ var authApi = {
1100
1194
  }
1101
1195
  };
1102
1196
 
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 };
1197
+ 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,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-7SFVHDFP.js';
2
+ import { getUserRoles } from './chunk-EFS272S3.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-IWSWWF6U.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,121 @@ 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
+ var ACCESS_DEBUG_HEADER = "X-Access-Debug";
291
+ var ACCESS_REDACTED_HEADER = "X-Access-Redacted";
292
+ var ACCESS_REDACTED_TRUNCATED_HEADER = "X-Access-Redacted-Truncated";
293
+ var MAX_REDACTED_FIELDS = 100;
294
+ var MAX_REDACTED_HEADER = 16384;
295
+ var REDACTED_ENTRY = /^([A-Za-z0-9_.:*-]{1,128})=([A-Za-z0-9_.:*-]{1,128})$/;
296
+ function parseRedactedHeader(value) {
297
+ const fields = [];
298
+ let omitted = 0;
299
+ if (!value) return { fields, omitted };
300
+ const seen = /* @__PURE__ */ new Set();
301
+ for (const raw of value.slice(0, MAX_REDACTED_HEADER).split(",")) {
302
+ const match = REDACTED_ENTRY.exec(raw.trim());
303
+ if (!match || seen.has(match[1])) continue;
304
+ seen.add(match[1]);
305
+ if (fields.length >= MAX_REDACTED_FIELDS) omitted += 1;
306
+ else fields.push({ name: match[1], permission: match[2] });
307
+ }
308
+ return { fields, omitted };
309
+ }
310
+
196
311
  // src/core/roles.ts
197
312
  var ROLE_NAMES = [
198
313
  "admin",
@@ -275,15 +390,21 @@ function getEffectiveLevel(roles) {
275
390
  return max;
276
391
  }
277
392
  function hasRole(user, role) {
278
- return getUserRoles(user).includes(role);
393
+ const allowed = effectiveSubject(user).roles.includes(role);
394
+ reportDecision("check", user, { roles: [role] }, allowed);
395
+ return allowed;
279
396
  }
280
397
  function hasAnyRole(user, roles) {
281
- const held = getUserRoles(user);
282
- return roles.some((role) => held.includes(role));
398
+ const held = effectiveSubject(user).roles;
399
+ const allowed = roles.some((role) => held.includes(role));
400
+ if (roles.length > 0) reportDecision("check", user, { roles }, allowed);
401
+ return allowed;
283
402
  }
284
403
  function hasAllRoles(user, roles) {
285
- const held = getUserRoles(user);
286
- return roles.every((role) => held.includes(role));
404
+ const held = effectiveSubject(user).roles;
405
+ const allowed = roles.every((role) => held.includes(role));
406
+ if (roles.length > 0) reportDecision("check", user, { roles, requireAllRoles: true }, allowed);
407
+ return allowed;
287
408
  }
288
409
 
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 };
410
+ export { ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, 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, parseRedactedHeader, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject };
@@ -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-7SFVHDFP.js';
2
+ import { onAccessSubjectChange, isInitialized, getConfig, isValidSSOLogoutUrl, hasRole, hasAnyRole, hasAllRoles, effectiveSubject, reportDecision, decodeJWT, getStorage, getDefaultStorage } from './chunk-EFS272S3.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
- 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-BGQVBSXN.js';
2
+ import { authStore, currentUser, isAuthenticated, authActions } from './chunk-Q4Q3CJVC.js';
3
+ import { evaluateAccess, effectiveSubject, isAccessProbeActive, reportDecision, isInitialized, initAuth } from './chunk-EFS272S3.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,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,187 @@ 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
+ /**
767
+ * Fields the API left out of a response (`X-Access-Redacted`). Informational,
768
+ * not a denial: `allowed` is true and `missing` is empty; see `fields`.
769
+ */
770
+ | 'redacted';
771
+ /** A top-level response field the API withheld, and the permission that would reveal it. */
772
+ interface RedactedField {
773
+ name: string;
774
+ /** `*` = the field has no permission configured and is dropped by default-deny. */
775
+ permission: string;
776
+ }
777
+ interface AccessCheckEvent extends AccessEvaluation {
778
+ source: AccessCheckSource;
779
+ requirement: AccessRequirement;
780
+ /** Id of the user checked, or null for a guest. Checks also run for other users (admin lists). */
781
+ subjectId: string | null;
782
+ /** True when the decision used the "view as" override instead of the real user. */
783
+ simulated: boolean;
784
+ /** Human-readable context: a nav item id/href, or `METHOD /path` for HTTP. */
785
+ label?: string;
786
+ /** Present for `http` and `redacted` events. */
787
+ http?: {
788
+ method: string;
789
+ endpoint: string;
790
+ status: number;
791
+ code?: string;
792
+ };
793
+ /** Present for `redacted` events: the withheld fields, in the order the server listed them. */
794
+ fields?: RedactedField[];
795
+ /** `redacted` events: entries the server (or the parser's cap) left out of `fields`. */
796
+ fieldsOmitted?: number;
797
+ }
798
+ type AccessProbe = (event: AccessCheckEvent) => void;
799
+ type SubjectUser = {
800
+ id?: string;
801
+ roles?: readonly string[];
802
+ permissions?: readonly string[];
803
+ };
804
+ /**
805
+ * Install (or with `null`, remove) the function that receives every access
806
+ * decision. Only one probe at a time; returns the previous one. No-op on the server.
807
+ */
808
+ declare function setAccessProbe(next: AccessProbe | null): AccessProbe | null;
809
+ /** Whether a probe is installed — check before building an event. */
810
+ declare function isAccessProbeActive(): boolean;
811
+ /** Hand an event to the probe, if any. A throwing probe never breaks the check. */
812
+ declare function reportAccess(event: AccessCheckEvent): void;
813
+ /**
814
+ * Make access checks for `userId` use `subject` instead of that user's real
815
+ * roles and permissions. Checks for any other user are unaffected, so admin
816
+ * screens listing other people keep working. No-op on the server.
817
+ */
818
+ declare function setAccessSubject(userId: string, subject: AccessSubject): void;
819
+ /** Remove the "view as" override. */
820
+ declare function clearAccessSubject(): void;
821
+ /** The active override, if any. */
822
+ declare function getAccessSubjectOverride(): {
823
+ userId: string;
824
+ subject: AccessSubject;
825
+ } | null;
826
+ /** Called whenever the override changes, so stores can re-run their checks. */
827
+ declare function onAccessSubjectChange(listener: () => void): () => void;
828
+ /**
829
+ * The roles and permissions to decide access with for `user`: the override
830
+ * when it targets this user, otherwise the user's own.
831
+ */
832
+ declare function effectiveSubject(user: SubjectUser | null | undefined): AccessSubject & {
833
+ simulated: boolean;
834
+ };
835
+ /**
836
+ * Decide a requirement against a subject. Roles and permissions must both
837
+ * pass when both are given; within each list `requireAll*` picks all vs any.
838
+ * Exact membership, like the API (`admin` does not imply `agent`).
839
+ */
840
+ declare function evaluateAccess(subject: AccessSubject, requirement: AccessRequirement): AccessEvaluation;
841
+ /**
842
+ * Report a decision the caller already made. Recomputes `missing` against
843
+ * the same subject so every source reports it the same way.
844
+ */
845
+ declare function reportDecision(source: AccessCheckSource, user: SubjectUser | null | undefined, requirement: AccessRequirement, allowed: boolean, label?: string): void;
846
+ /**
847
+ * Read what a CHAPI 403 says was required. `requirePermissions` sends
848
+ * `{ required: string[] }` with code `INSUFFICIENT_PERMISSIONS`;
849
+ * `requireRole` sends `{ required: string }` with code `FORBIDDEN`. An
850
+ * optional `mode: 'any' | 'all'` overrides the defaults (permissions: all).
851
+ */
852
+ declare function requirementFromDenial(code: string | undefined, details: unknown): AccessRequirement | null;
853
+ /** Request header asking CHAPI to say which fields it redacted (admin sessions only). */
854
+ declare const ACCESS_DEBUG_HEADER = "X-Access-Debug";
855
+ /** Response header: `name=permission` entries joined by `", "`. */
856
+ declare const ACCESS_REDACTED_HEADER = "X-Access-Redacted";
857
+ /** Response header: how many entries the server left out of {@link ACCESS_REDACTED_HEADER}. */
858
+ declare const ACCESS_REDACTED_TRUNCATED_HEADER = "X-Access-Redacted-Truncated";
859
+ /**
860
+ * Parse an `X-Access-Redacted` value (`taxId=vendors:read:sensitive, notes=*`).
861
+ * Malformed entries are skipped, a repeated name keeps its first permission,
862
+ * and at most 100 fields are kept; `omitted` counts the valid ones dropped by
863
+ * that cap. A missing or empty header yields no fields.
864
+ */
865
+ declare function parseRedactedHeader(value: string | null | undefined): {
866
+ fields: RedactedField[];
867
+ omitted: number;
868
+ };
705
869
 
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 };
870
+ export { ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, 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 RedactedField, 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, parseRedactedHeader, 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-BGQVBSXN.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-7SFVHDFP.js';
3
+ export { ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, 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, parseRedactedHeader, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject } from '../chunk-EFS272S3.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 { ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, AccessCheckEvent, AccessCheckSource, AccessEvaluation, AccessProbe, AccessRequirement, AccessSubject, ApiRequestOptions, ApiResponse, JWTPayload, ROLE_HIERARCHY, ROLE_NAMES, RedactedField, 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, parseRedactedHeader, 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-TJRFP24M.js';
2
+ export { AuthService, authService, formatUserRoles, getAvailableMethods, getAvatarFallback, getDisplayName, getGreeting, getMfaToken, getUserEmail, getUserInitials, isLoginSuccessResponse, isMfaChallengeResponse } from './chunk-BGQVBSXN.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from './chunk-Q4Q3CJVC.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-7SFVHDFP.js';
5
+ export { ACCESS_DEBUG_HEADER, ACCESS_REDACTED_HEADER, ACCESS_REDACTED_TRUNCATED_HEADER, 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, parseRedactedHeader, reportAccess, reportDecision, requirementFromDenial, resetConfig, setAccessProbe, setAccessSubject } from './chunk-EFS272S3.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-TJRFP24M.js';
2
+ export { formatUserRoles, getAvatarFallback, getDisplayName, getGreeting, getUserEmail, getUserInitials } from '../chunk-BGQVBSXN.js';
3
+ export { authActions, authStore, currentUser, isAuthenticated } from '../chunk-Q4Q3CJVC.js';
4
+ export { InvalidAuthResponseError, RoleDeniedError, SessionChangedError, isRoleDeniedError, isSessionChangedError } from '../chunk-7SFVHDFP.js';
5
+ export { initAuth, isInitialized } from '../chunk-EFS272S3.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-EFS272S3.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.46.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';