Enable authenticated browser bootstrap for remote H5 access

Browser-mode startup now preserves the desktop localhost path while prompting for H5 credentials when a non-loopback backend requires verification. The API client and websocket layer share a central token source so REST and session sockets authenticate consistently without leaking the token into diagnostics.

Constraint: Preserve the Tauri startup path and default localhost browser behavior
Rejected: Reuse the generic startup error view for H5 auth failures | it blocks the credential-entry recovery path
Directive: Non-loopback browser startup is the only path that should require saved H5 verification without re-checking the desktop bootstrap contract
Confidence: high
Scope-risk: moderate
Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/components/layout/AppShell.test.tsx
Tested: cd desktop && bun run lint
Tested: git diff --check -- desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/components/layout/AppShell.test.tsx
Not-tested: Live remote H5 browser session against a running server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 00:26:01 +08:00
parent 4865b4f206
commit ba1807541a
8 changed files with 520 additions and 30 deletions

View File

@ -1,11 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api, rawRecordDiagnosticEvent } from './client'
import {
api,
getDefaultBaseUrl,
rawRecordDiagnosticEvent,
setAuthToken,
setBaseUrl,
} from './client'
describe('api diagnostics reporting', () => {
afterEach(() => {
setAuthToken(null)
setBaseUrl(getDefaultBaseUrl())
vi.restoreAllMocks()
})
it('does not send Authorization for default local requests', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.get('/api/status')
const [, init] = fetchMock.mock.calls[0]!
expect((init as RequestInit).headers).toMatchObject({
'Content-Type': 'application/json',
})
expect((init as RequestInit & { headers?: Record<string, string> }).headers?.Authorization).toBeUndefined()
})
it('adds Authorization when an H5 token is configured', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
setAuthToken('h5_x')
await api.get('/api/status')
const [, init] = fetchMock.mock.calls[0]!
expect((init as RequestInit & { headers?: Record<string, string> }).headers).toMatchObject({
Authorization: 'Bearer h5_x',
})
})
it('reports non-diagnostics API failures without request bodies', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock
@ -31,6 +71,27 @@ describe('api diagnostics reporting', () => {
expect(JSON.stringify(body)).not.toContain('sk-should-not-report')
})
it('does not leak the H5 token in diagnostics payloads', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
setAuthToken('h5_super_secret')
await expect(api.get('/api/status')).rejects.toThrow('Unauthorized')
const [, diagnosticInit] = fetchMock.mock.calls[1]!
const body = JSON.parse(String((diagnosticInit as RequestInit).body))
expect(JSON.stringify(body)).not.toContain('h5_super_secret')
})
it('does not recursively report diagnostics endpoint failures', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), {

View File

@ -8,6 +8,7 @@ const ENV_BASE_URL =
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
let authToken: string | null = null
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
function getErrorMessage(status: number, body: unknown) {
@ -30,6 +31,15 @@ export function getBaseUrl() {
return baseUrl
}
export function setAuthToken(token: string | null) {
const trimmed = token?.trim() ?? ''
authToken = trimmed.length > 0 ? trimmed : null
}
export function getAuthToken() {
return authToken
}
export function getDefaultBaseUrl() {
return DEFAULT_BASE_URL
}
@ -46,9 +56,7 @@ export class ApiError extends Error {
async function request<T>(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise<T> {
const url = `${baseUrl}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const headers = buildHeaders()
const controller = new AbortController()
const timeoutMs = options?.timeout ?? 30_000
@ -88,12 +96,12 @@ function reportApiFailure(method: string, path: string, error: unknown) {
method,
path,
errorName: error instanceof Error ? error.name : typeof error,
message: error instanceof Error ? error.message : String(error),
message: sanitizeDiagnosticValue(error instanceof Error ? error.message : String(error)),
}
if (error instanceof ApiError) {
details.status = error.status
details.response = error.body
details.response = sanitizeDiagnosticValue(error.body)
}
void rawRecordDiagnosticEvent({
@ -113,11 +121,43 @@ export function rawRecordDiagnosticEvent(event: {
}) {
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: buildHeaders(),
body: JSON.stringify(event),
}).catch(() => undefined)
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
return headers
}
function sanitizeDiagnosticValue(value: unknown): unknown {
if (!authToken) return value
if (typeof value === 'string') {
return value.split(authToken).join('[redacted]')
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeDiagnosticValue(entry))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, sanitizeDiagnosticValue(entry)]),
)
}
return value
}
export const api = {
get: <T>(path: string, options?: { timeout?: number }) => request<T>('GET', path, undefined, options),
post: <T>(path: string, body?: unknown, options?: { timeout?: number }) => request<T>('POST', path, body, options),

View File

@ -1,10 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./client', () => ({
getBaseUrl: () => 'http://127.0.0.1:3456',
const clientMocks = vi.hoisted(() => ({
baseUrl: 'http://127.0.0.1:3456',
authToken: null as string | null,
}))
import { wsManager } from './websocket'
vi.mock('./client', () => ({
getBaseUrl: () => clientMocks.baseUrl,
getAuthToken: () => clientMocks.authToken,
}))
import { buildSessionWebSocketUrl, wsManager } from './websocket'
type SocketHandler = (() => void) | ((event: { data: string }) => void)
@ -53,6 +59,8 @@ describe('wsManager reconnect buffering', () => {
beforeEach(() => {
vi.useFakeTimers()
clientMocks.baseUrl = 'http://127.0.0.1:3456'
clientMocks.authToken = null
FakeWebSocket.instances = []
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
wsManager.disconnectAll()
@ -89,4 +97,21 @@ describe('wsManager reconnect buffering', () => {
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
])
})
it('builds websocket URLs from http and encodes token query params', () => {
clientMocks.baseUrl = 'http://10.0.0.2:3456'
clientMocks.authToken = 'h5 token/with?chars'
expect(buildSessionWebSocketUrl('session-reconnect')).toBe(
'ws://10.0.0.2:3456/ws/session-reconnect?token=h5+token%2Fwith%3Fchars',
)
})
it('upgrades https backends to wss', () => {
clientMocks.baseUrl = 'https://remote.example.com'
expect(buildSessionWebSocketUrl('secure-session')).toBe(
'wss://remote.example.com/ws/secure-session',
)
})
})

View File

@ -1,5 +1,5 @@
import type { ClientMessage, ServerMessage } from '../types/chat'
import { getBaseUrl } from './client'
import { getAuthToken, getBaseUrl } from './client'
type MessageHandler = (msg: ServerMessage) => void
@ -39,8 +39,7 @@ class WebSocketManager {
return
}
const wsUrl = getBaseUrl().replace(/^http/, 'ws')
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const ws = new WebSocket(buildSessionWebSocketUrl(sessionId))
const conn: Connection = {
ws,
@ -178,4 +177,19 @@ class WebSocketManager {
}
}
export function buildSessionWebSocketUrl(sessionId: string) {
const url = new URL(getBaseUrl())
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/ws/${encodeURIComponent(sessionId)}`
const token = getAuthToken()
if (token) {
url.searchParams.set('token', token)
} else {
url.searchParams.delete('token')
}
return url.toString()
}
export const wsManager = new WebSocketManager()

View File

@ -1,9 +1,10 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
initializeDesktopServerUrl: vi.fn(),
isTauriRuntime: false,
fetchAll: vi.fn(),
restoreTabs: vi.fn(),
connectToSession: vi.fn(),
@ -15,7 +16,9 @@ const mocks = vi.hoisted(() => ({
vi.mock('../../lib/desktopRuntime', () => ({
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
isTauriRuntime: () => false,
isTauriRuntime: () => mocks.isTauriRuntime,
isH5ConnectionRequiredError: (error: unknown) =>
error instanceof Error && error.name === 'H5ConnectionRequiredError',
}))
vi.mock('../../stores/settingsStore', () => ({
@ -68,6 +71,16 @@ vi.mock('./TabBar', () => ({
TabBar: () => <nav>tabs loaded</nav>,
}))
vi.mock('./H5ConnectionView', () => ({
H5ConnectionView: ({ error, onConnected }: { error?: string | null; onConnected: () => void }) => (
<div>
<div>h5 connection view</div>
<div>{error}</div>
<button type="button" onClick={onConnected}>retry h5 bootstrap</button>
</div>
),
}))
vi.mock('../shared/Toast', () => ({
ToastContainer: () => null,
}))
@ -81,6 +94,7 @@ import { AppShell } from './AppShell'
describe('AppShell boot flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.isTauriRuntime = false
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
@ -139,4 +153,54 @@ describe('AppShell boot flow', () => {
expect(mocks.connectToSession).toHaveBeenCalledWith('session-1')
})
})
it('shows the H5 connection view in browser mode when startup needs H5 auth', async () => {
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
Object.assign(new Error('Enter your H5 token to continue.'), {
name: 'H5ConnectionRequiredError',
serverUrl: 'https://remote.example.com',
}),
)
render(<AppShell />)
expect(await screen.findByText('h5 connection view')).toBeInTheDocument()
expect(screen.getByText('Enter your H5 token to continue.')).toBeInTheDocument()
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('retries bootstrap after a successful H5 connection', async () => {
mocks.initializeDesktopServerUrl
.mockRejectedValueOnce(
Object.assign(new Error('The saved H5 token is no longer valid.'), {
name: 'H5ConnectionRequiredError',
serverUrl: 'https://remote.example.com',
}),
)
.mockResolvedValueOnce('https://remote.example.com')
render(<AppShell />)
expect(await screen.findByText('h5 connection view')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'retry h5 bootstrap' }))
await screen.findByText('sidebar loaded')
expect(mocks.initializeDesktopServerUrl).toHaveBeenCalledTimes(2)
expect(mocks.fetchAll).toHaveBeenCalledTimes(1)
})
it('keeps the Tauri startup error path unchanged', async () => {
mocks.isTauriRuntime = true
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
Object.assign(new Error('desktop server startup failed'), {
name: 'H5ConnectionRequiredError',
serverUrl: 'https://remote.example.com',
}),
)
render(<AppShell />)
expect(await screen.findByText('app.serverFailed')).toBeInTheDocument()
expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument()
})
})

View File

@ -6,24 +6,39 @@ import { UpdateChecker } from '../shared/UpdateChecker'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl, isTauriRuntime } from '../../lib/desktopRuntime'
import {
H5ConnectionRequiredError,
initializeDesktopServerUrl,
isH5ConnectionRequiredError,
isTauriRuntime,
} from '../../lib/desktopRuntime'
import { TabBar } from './TabBar'
import { StartupErrorView } from './StartupErrorView'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { H5ConnectionView } from './H5ConnectionView'
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null)
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
const [bootstrapNonce, setBootstrapNonce] = useState(0)
const t = useTranslation()
const tauriRuntime = isTauriRuntime()
useEffect(() => {
let cancelled = false
const bootstrap = async () => {
if (!cancelled) {
setReady(false)
setStartupError(null)
setH5StartupError(null)
}
try {
await initializeDesktopServerUrl()
await fetchSettings()
@ -43,7 +58,13 @@ export function AppShell() {
})().catch(() => {})
} catch (error) {
if (!cancelled) {
setStartupError(error instanceof Error ? error.message : String(error))
if (!tauriRuntime && isH5ConnectionRequiredError(error)) {
setH5StartupError(error)
setStartupError(null)
} else {
setStartupError(error instanceof Error ? error.message : String(error))
setH5StartupError(null)
}
setReady(false)
}
}
@ -54,11 +75,11 @@ export function AppShell() {
return () => {
cancelled = true
}
}, [fetchSettings])
}, [bootstrapNonce, fetchSettings, tauriRuntime])
// Listen for macOS native menu navigation events (About / Settings)
useEffect(() => {
if (!isTauriRuntime()) return
if (!tauriRuntime) return
let unlisten: (() => void) | undefined
import('@tauri-apps/api/event')
.then(({ listen }) =>
@ -77,6 +98,16 @@ export function AppShell() {
useKeyboardShortcuts()
if (!tauriRuntime && h5StartupError) {
return (
<H5ConnectionView
initialServerUrl={h5StartupError.serverUrl}
error={h5StartupError.message}
onConnected={() => setBootstrapNonce((value) => value + 1)}
/>
)
}
if (startupError) {
return <StartupErrorView error={startupError} />
}

View File

@ -0,0 +1,84 @@
import type { FormEvent } from 'react'
import { useState } from 'react'
import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { Button } from '../shared/Button'
import { Input } from '../shared/Input'
type H5ConnectionViewProps = {
initialServerUrl?: string | null
error?: string | null
onConnected: () => void
}
export function H5ConnectionView({
initialServerUrl,
error: initialError,
onConnected,
}: H5ConnectionViewProps) {
const [serverUrl, setServerUrl] = useState(initialServerUrl ?? '')
const [token, setToken] = useState('')
const [error, setError] = useState(initialError ?? '')
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitting(true)
setError('')
try {
await saveAndVerifyH5Connection(serverUrl, token)
onConnected()
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : 'Unable to connect to the H5 server.',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="mb-5">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
Connect to H5 Access
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
Enter the server URL and H5 access token from the desktop app.
</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
label="Server URL"
placeholder="https://chat.example.com"
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
autoComplete="url"
required
/>
<Input
label="H5 Token"
type="password"
placeholder="h5_..."
value={token}
onChange={(event) => setToken(event.target.value)}
autoComplete="current-password"
required
/>
{error ? (
<div className="rounded-lg border border-[var(--color-error)]/30 bg-[var(--color-error)]/8 px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
) : null}
<Button type="submit" size="lg" className="w-full" loading={submitting}>
Connect
</Button>
</form>
</section>
</div>
)
}

View File

@ -1,28 +1,115 @@
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
import { api, getDefaultBaseUrl, setAuthToken, setBaseUrl } from '../api/client'
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
type H5ConnectionFailureReason = 'missing-token' | 'invalid-token' | 'verify-failed'
export type StoredH5Connection = {
serverUrl: string | null
token: string | null
}
export class H5ConnectionRequiredError extends Error {
readonly serverUrl: string
readonly reason: H5ConnectionFailureReason
constructor(message: string, serverUrl: string, reason: H5ConnectionFailureReason) {
super(message)
this.name = 'H5ConnectionRequiredError'
this.serverUrl = serverUrl
this.reason = reason
}
}
export function isTauriRuntime() {
if (typeof window === 'undefined') return false
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
}
export function isBrowserH5Runtime() {
return typeof window !== 'undefined' && !isTauriRuntime()
}
export function readStoredH5Connection(): StoredH5Connection {
if (typeof window === 'undefined') {
return { serverUrl: null, token: null }
}
try {
return {
serverUrl: normalizeServerUrl(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)),
token: normalizeToken(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)),
}
} catch {
return { serverUrl: null, token: null }
}
}
export function clearStoredH5Connection() {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(H5_SERVER_URL_STORAGE_KEY)
window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
} catch {
// Ignore storage failures
}
}
setAuthToken(null)
}
export async function saveAndVerifyH5Connection(serverUrl: string, token: string) {
const normalizedServerUrl = normalizeServerUrl(serverUrl)
const normalizedToken = normalizeToken(token)
if (!normalizedServerUrl) {
throw new Error('Enter a valid server URL.')
}
if (!normalizedToken) {
throw new Error('Enter your H5 access token.')
}
setBaseUrl(normalizedServerUrl)
setAuthToken(normalizedToken)
try {
await waitForHealth(normalizedServerUrl)
await verifyH5Access()
} catch (error) {
setAuthToken(null)
throw normalizeH5VerificationError(error, normalizedServerUrl)
}
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, normalizedServerUrl)
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken)
} catch {
// Ignore storage failures after a successful verification.
}
}
return normalizedServerUrl
}
export function isH5ConnectionRequiredError(error: unknown): error is H5ConnectionRequiredError {
return error instanceof H5ConnectionRequiredError
}
export async function initializeDesktopServerUrl() {
const fallbackUrl = getDefaultBaseUrl()
const queryUrl =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('serverUrl')
: null
const requestedUrl = queryUrl?.trim() || fallbackUrl
if (!isTauriRuntime()) {
setBaseUrl(requestedUrl)
await waitForHealth(requestedUrl)
return requestedUrl
return initializeBrowserServerUrl(fallbackUrl)
}
try {
const { invoke } = await import('@tauri-apps/api/core')
const serverUrl = await invoke<string>('get_server_url')
setBaseUrl(serverUrl)
setAuthToken(null)
await waitForHealth(serverUrl)
return serverUrl
} catch (error) {
@ -33,6 +120,42 @@ export async function initializeDesktopServerUrl() {
}
}
async function initializeBrowserServerUrl(fallbackUrl: string) {
const queryUrl =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('serverUrl')
: null
const stored = readStoredH5Connection()
const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl
const token = stored.token
setBaseUrl(requestedUrl)
setAuthToken(token)
await waitForHealth(requestedUrl)
if (!requiresH5Auth(requestedUrl)) {
return requestedUrl
}
if (!token) {
clearStoredH5Connection()
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
requestedUrl,
'missing-token',
)
}
try {
await verifyH5Access()
} catch (error) {
clearStoredH5Connection()
throw normalizeH5VerificationError(error, requestedUrl)
}
return requestedUrl
}
async function waitForHealth(serverUrl: string) {
let lastError: unknown
@ -54,7 +177,55 @@ async function waitForHealth(serverUrl: string) {
throw new Error(
lastError instanceof Error
? `Local server healthcheck failed: ${lastError.message}`
: 'Local server healthcheck failed',
? `Server healthcheck failed: ${lastError.message}`
: 'Server healthcheck failed',
)
}
async function verifyH5Access() {
await api.post<{ ok: true }>('/api/h5-access/verify')
}
function normalizeServerUrl(value: string | null | undefined) {
const trimmed = value?.trim()
if (!trimmed) return null
try {
return new URL(trimmed).toString().replace(/\/$/, '')
} catch {
return null
}
}
function normalizeToken(value: string | null | undefined) {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function requiresH5Auth(serverUrl: string) {
try {
const hostname = new URL(serverUrl).hostname
return hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1'
} catch {
return false
}
}
function normalizeH5VerificationError(error: unknown, serverUrl: string) {
if (error instanceof H5ConnectionRequiredError) {
return error
}
if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) {
return new Error(`Unable to reach ${serverUrl}. Check the server URL or network access.`)
}
const message =
error instanceof Error ? error.message : 'Unable to verify the H5 access token.'
const unauthorized = message.includes('401') || message.toLowerCase().includes('unauthorized')
return new H5ConnectionRequiredError(
unauthorized ? 'The saved H5 token is no longer valid.' : 'Unable to verify the H5 access token.',
serverUrl,
unauthorized ? 'invalid-token' : 'verify-failed',
)
}