@open-mercato/ui 0.4.9-develop-d989387b7a → 0.4.9-develop-7120508245

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { flash } from "../FlashMessages.js";
3
3
  import { deserializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
4
+ import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
4
5
  import { pushOperation } from "../operations/store.js";
5
6
  import { pushPartialIndexWarning } from "../indexes/store.js";
6
7
  import { createScopedHeaderStack } from "./scopedHeaderStack.js";
@@ -80,6 +81,7 @@ async function apiFetch(input, init) {
80
81
  }
81
82
  const scoped = scopedHeaders.resolveScopedHeaders();
82
83
  const mergedInit = Object.keys(scoped).length ? { ...init ?? {}, headers: mergeHeaders(init?.headers, scoped) } : init;
84
+ const disableForbiddenRedirect = new Headers(mergedInit?.headers).get("x-om-forbidden-redirect") === "0";
83
85
  const res = await baseFetch(input, mergedInit);
84
86
  const pathname = typeof window !== "undefined" ? window.location.pathname : "";
85
87
  const onLoginPage = pathname.startsWith("/login");
@@ -95,15 +97,17 @@ async function apiFetch(input, init) {
95
97
  let roles = null;
96
98
  let features = null;
97
99
  let payload = null;
98
- try {
99
- const clone = res.clone();
100
- const data = await clone.json();
101
- if (Array.isArray(data?.requiredRoles)) roles = data.requiredRoles.map((r) => String(r));
102
- if (Array.isArray(data?.requiredFeatures)) features = data.requiredFeatures.map((f) => String(f));
103
- if (data && typeof data === "object") payload = data;
104
- } catch {
100
+ const aclData = await readJsonSafe(res.clone(), null);
101
+ if (aclData && typeof aclData === "object") {
102
+ if (Array.isArray(aclData.requiredRoles)) {
103
+ roles = aclData.requiredRoles.map((r) => String(r));
104
+ }
105
+ if (Array.isArray(aclData.requiredFeatures)) {
106
+ features = aclData.requiredFeatures.map((f) => String(f));
107
+ }
108
+ payload = aclData;
105
109
  }
106
- if (!onLoginPage && !onPortalRoute) {
110
+ if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {
107
111
  const target = typeof input === "string" ? input : input instanceof URL ? input.toString() : typeof Request !== "undefined" && input instanceof Request ? input.url : "unknown";
108
112
  try {
109
113
  console.warn("[apiFetch] Forbidden response", {
@@ -119,7 +123,16 @@ async function apiFetch(input, init) {
119
123
  if (hasAclHints) {
120
124
  redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features });
121
125
  }
122
- const msg = await res.clone().text().catch(() => "Forbidden");
126
+ const raw = await res.clone().text().catch(() => "Forbidden");
127
+ let msg = raw;
128
+ const parsed = await readJsonSafe(raw, null);
129
+ if (parsed && typeof parsed === "object") {
130
+ if (typeof parsed.error === "string") {
131
+ msg = parsed.error;
132
+ } else if (typeof parsed.message === "string") {
133
+ msg = parsed.message;
134
+ }
135
+ }
123
136
  throw new ForbiddenError(msg);
124
137
  }
125
138
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/utils/api.ts"],
4
- "sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash('Session expired. Redirecting to sign in\u2026', 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n constructor(message = 'Forbidden') {\n super(message)\n this.name = 'ForbiddenError'\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nexport function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n if (window.location.pathname.startsWith('/login')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n const current = window.location.pathname + window.location.search\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const fallbackRoles = DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n const effectiveRoles = roles.length ? roles : fallbackRoles\n const query = features.length\n ? `requireFeature=${encodeURIComponent(features.join(','))}`\n : effectiveRoles.length\n ? `requireRole=${encodeURIComponent(effectiveRoles.map(String).join(','))}`\n : ''\n const url = query\n ? `/login?${query}&redirect=${encodeURIComponent(current)}`\n : `/login?redirect=${encodeURIComponent(current)}`\n flash('Insufficient permissions. Redirecting to login\u2026', 'warning')\n setTimeout(() => { window.location.href = url }, 60)\n } catch {\n // no-op\n }\n}\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const mergedInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init\n const res = await baseFetch(input, mergedInit)\n const pathname = typeof window !== 'undefined' ? window.location.pathname : ''\n const onLoginPage = pathname.startsWith('/login')\n const onPortalRoute = /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n try {\n const clone = res.clone()\n const data = await clone.json()\n if (Array.isArray(data?.requiredRoles)) roles = data.requiredRoles.map((r: any) => String(r))\n if (Array.isArray(data?.requiredFeatures)) features = data.requiredFeatures.map((f: any) => String(f))\n if (data && typeof data === 'object') payload = data\n } catch {}\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n // eslint-disable-next-line no-console\n console.warn('[apiFetch] Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })\n }\n const msg = await res.clone().text().catch(() => 'Forbidden')\n throw new ForbiddenError(msg)\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash('Session expired. Redirecting to sign in\u2026', 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n constructor(message = 'Forbidden') {\n super(message)\n this.name = 'ForbiddenError'\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nexport function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n if (window.location.pathname.startsWith('/login')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n const current = window.location.pathname + window.location.search\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const fallbackRoles = DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n const effectiveRoles = roles.length ? roles : fallbackRoles\n const query = features.length\n ? `requireFeature=${encodeURIComponent(features.join(','))}`\n : effectiveRoles.length\n ? `requireRole=${encodeURIComponent(effectiveRoles.map(String).join(','))}`\n : ''\n const url = query\n ? `/login?${query}&redirect=${encodeURIComponent(current)}`\n : `/login?redirect=${encodeURIComponent(current)}`\n flash('Insufficient permissions. Redirecting to login\u2026', 'warning')\n setTimeout(() => { window.location.href = url }, 60)\n } catch {\n // no-op\n }\n}\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const mergedInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init\n const disableForbiddenRedirect = new Headers(mergedInit?.headers).get('x-om-forbidden-redirect') === '0'\n const res = await baseFetch(input, mergedInit)\n const pathname = typeof window !== 'undefined' ? window.location.pathname : ''\n const onLoginPage = pathname.startsWith('/login')\n const onPortalRoute = /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)\n if (aclData && typeof aclData === 'object') {\n if (Array.isArray(aclData.requiredRoles)) {\n roles = aclData.requiredRoles.map((r) => String(r))\n }\n if (Array.isArray(aclData.requiredFeatures)) {\n features = aclData.requiredFeatures.map((f) => String(f))\n }\n payload = aclData\n }\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n // eslint-disable-next-line no-console\n console.warn('[apiFetch] Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })\n }\n const raw = await res.clone().text().catch(() => 'Forbidden')\n let msg = raw\n const parsed = await readJsonSafe<Record<string, unknown>>(raw, null)\n if (parsed && typeof parsed === 'object') {\n if (typeof parsed.error === 'string') {\n msg = parsed.error\n } else if (typeof parsed.message === 'string') {\n msg = parsed.message\n }\n }\n throw new ForbiddenError(msg)\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.4.9-develop-d989387b7a",
3
+ "version": "0.4.9-develop-7120508245",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -124,12 +124,12 @@
124
124
  "recharts": "^2.15.0"
125
125
  },
126
126
  "peerDependencies": {
127
- "@open-mercato/shared": "0.4.9-develop-d989387b7a",
127
+ "@open-mercato/shared": "0.4.9-develop-7120508245",
128
128
  "react": ">=18.0.0",
129
129
  "react-dom": ">=18.0.0"
130
130
  },
131
131
  "devDependencies": {
132
- "@open-mercato/shared": "0.4.9-develop-d989387b7a",
132
+ "@open-mercato/shared": "0.4.9-develop-7120508245",
133
133
  "@testing-library/dom": "^10.4.1",
134
134
  "@testing-library/jest-dom": "^6.9.1",
135
135
  "@testing-library/react": "^16.3.1",
@@ -3,6 +3,7 @@
3
3
  // Used across UI data utilities to avoid duplication.
4
4
  import { flash } from '../FlashMessages'
5
5
  import { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
6
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
6
7
  import { pushOperation } from '../operations/store'
7
8
  import { pushPartialIndexWarning } from '../indexes/store'
8
9
  import { createScopedHeaderStack } from './scopedHeaderStack'
@@ -107,6 +108,7 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
107
108
  const mergedInit = Object.keys(scoped).length
108
109
  ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
109
110
  : init
111
+ const disableForbiddenRedirect = new Headers(mergedInit?.headers).get('x-om-forbidden-redirect') === '0'
110
112
  const res = await baseFetch(input, mergedInit)
111
113
  const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
112
114
  const onLoginPage = pathname.startsWith('/login')
@@ -126,15 +128,18 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
126
128
  let roles: string[] | null = null
127
129
  let features: string[] | null = null
128
130
  let payload: unknown = null
129
- try {
130
- const clone = res.clone()
131
- const data = await clone.json()
132
- if (Array.isArray(data?.requiredRoles)) roles = data.requiredRoles.map((r: any) => String(r))
133
- if (Array.isArray(data?.requiredFeatures)) features = data.requiredFeatures.map((f: any) => String(f))
134
- if (data && typeof data === 'object') payload = data
135
- } catch {}
131
+ const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)
132
+ if (aclData && typeof aclData === 'object') {
133
+ if (Array.isArray(aclData.requiredRoles)) {
134
+ roles = aclData.requiredRoles.map((r) => String(r))
135
+ }
136
+ if (Array.isArray(aclData.requiredFeatures)) {
137
+ features = aclData.requiredFeatures.map((f) => String(f))
138
+ }
139
+ payload = aclData
140
+ }
136
141
  // Only redirect if not already on login page or a portal route
137
- if (!onLoginPage && !onPortalRoute) {
142
+ if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {
138
143
  const target =
139
144
  typeof input === 'string'
140
145
  ? input
@@ -157,7 +162,16 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
157
162
  if (hasAclHints) {
158
163
  redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })
159
164
  }
160
- const msg = await res.clone().text().catch(() => 'Forbidden')
165
+ const raw = await res.clone().text().catch(() => 'Forbidden')
166
+ let msg = raw
167
+ const parsed = await readJsonSafe<Record<string, unknown>>(raw, null)
168
+ if (parsed && typeof parsed === 'object') {
169
+ if (typeof parsed.error === 'string') {
170
+ msg = parsed.error
171
+ } else if (typeof parsed.message === 'string') {
172
+ msg = parsed.message
173
+ }
174
+ }
161
175
  throw new ForbiddenError(msg)
162
176
  }
163
177
  // If already on login, just return the response for the caller to handle