fix(app): normalize cloud core RPC URLs

## Summary

- Normalize cloud core URLs so users can paste a core base URL like `https://example.trycloudflare.com` and still reach the JSON-RPC endpoint.
- Apply the same normalization in the cloud-mode picker, persisted URL reads/writes, restored core mode state, and direct RPC probing.
- Add regression coverage for Cloudflare-style base URLs, existing `/rpc` URLs, and previously persisted base URLs.

## Problem

- Users connecting the desktop client to a self-hosted core through Cloudflare Tunnel may paste the tunnel base URL instead of the `/rpc` endpoint.
- The core root is reachable, but JSON-RPC calls belong on `/rpc`; using the base URL can make the connection flow fail even though the tunnel itself is healthy.
- The issue report surfaced this as a 405 during remote-core connection setup.

## Solution

- Extend `normalizeRpcUrl` to append `/rpc` when the input URL has no path, while preserving existing `/rpc` URLs and non-root paths.
- Reuse `normalizeRpcUrl` across `BootCheckGate`, `coreRpcClient`, `configPersistence`, and `coreModeSlice` so test connection, boot check, cached URL resolution, and localStorage restoration all agree.
- Keep existing HTTP restrictions unchanged: public cloud URLs still require HTTPS, while local/private HTTP hosts remain allowed.

## Submission Checklist

> If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items.

- [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement)
- [x] **Diff coverage >= 80%** — focused Vitest coverage was added for the changed URL normalization paths; CI will enforce the merged diff-coverage gate.
- [x] Coverage matrix updated — N/A: behaviour-only cloud URL normalization fix; no feature matrix row added/removed/renamed.
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — N/A: no coverage-matrix feature ID touched.
- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy))
- [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) — N/A: no release-cut smoke checklist surface changed.
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section

## Impact

- Runtime/platform: desktop/web app cloud-core connection setup and RPC URL resolution.
- Compatibility: existing stored `/rpc` URLs continue to resolve unchanged; previously stored base URLs now self-heal on read.
- Security: public HTTP cloud URLs are still rejected; no auth behavior or token storage behavior changes.

## Related

- Closes #2467
- Follow-up PR(s)/TODOs: none

---

## AI Authored PR Metadata (required for Codex/Linear PRs)

> Keep this section for AI-authored PRs. For human-only PRs, mark each field `N/A`.

### Linear Issue
- Key: N/A
- URL: N/A

### Commit & Branch
- Branch: `yuhao/fix-remote-core-cloudflare-2467`
- Commit SHA: `5e95aeed8a97acee5823d73b6dc8e92f04af00fb`

### Validation Run
- [x] `pnpm --filter openhuman-app format:check`
- [x] `pnpm typecheck`
- [x] Focused tests: `pnpm --dir app exec vitest run --config test/vitest.config.ts src/services/__tests__/coreRpcClient.test.ts src/utils/__tests__/configPersistence.test.ts src/store/coreModeSlice.test.ts src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx` — 200 passed
- [x] Rust fmt/check (if changed): N/A: no Rust source changes; app format gate still ran Rust format checks.
- [x] Tauri fmt/check (if changed): N/A: no Tauri shell source changes; app format gate still ran Tauri Rust format checks.

### Validation Blocked
- `command:` N/A
- `error:` N/A
- `impact:` N/A

### Behavior Changes
- Intended behavior change: cloud core base URLs with no path are normalized to `/rpc`.
- User-visible effect: users can paste a Cloudflare Tunnel base URL into the cloud runtime picker without manually appending `/rpc`.

### Parity Contract
- Legacy behavior preserved: existing `/rpc` URLs, auth token handling, RPC POST envelopes, and public-HTTP rejection behavior are unchanged.
- Guard/fallback/dispatch parity checks: focused tests cover picker continuation, test connection, cached URL resolution, persisted URL reads/writes, and core-mode localStorage restoration.

### Duplicate / Superseded PR Handling
- Duplicate PR(s): none found for #2467 by current open issue/PR review.
- Canonical PR: this PR.
- Resolution (closed/superseded/updated): N/A.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Consistently normalize cloud RPC URLs: trims input, handles trailing slashes, and ensures the /rpc endpoint across input, storage, retrieval, and connection probes.
  * Safer RPC logging: credentials/query/hash are redacted for logged URLs.

* **Tests**
  * Expanded coverage for URL normalization across connection flows, storage/readback, and boot checks.

* **Localization**
  * Added German translations for subconscious and MCP server/settings UI strings.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2480?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: YUHAO-corn <godcorn001@outlook.com>
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
Yuhao Chen
2026-05-22 18:52:13 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 733fcfe7ca
commit 3e22ffeedf
9 changed files with 225 additions and 22 deletions
@@ -26,6 +26,7 @@ import {
clearStoredCoreMode,
clearStoredCoreToken,
isLocalOrPrivateNetworkHost,
normalizeRpcUrl,
storeCoreMode,
storeCoreToken,
storeRpcUrl,
@@ -122,13 +123,14 @@ function ModePicker({ onConfirm }: PickerProps) {
* paths are passed through verbatim without the bearer value.
*/
const validateInputs = (): { url: string; token: string } | null => {
const trimmedUrl = cloudUrl.trim();
if (!trimmedUrl) {
const rawUrl = cloudUrl.trim();
if (!rawUrl) {
setUrlError(t('bootCheck.invalidUrl'));
return null;
}
const normalizedUrl = normalizeRpcUrl(rawUrl);
try {
const parsed = new URL(trimmedUrl);
const parsed = new URL(normalizedUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
setUrlError(t('bootCheck.urlMustStartWith'));
return null;
@@ -152,7 +154,7 @@ function ModePicker({ onConfirm }: PickerProps) {
}
setTokenError(null);
return { url: trimmedUrl, token: trimmedToken };
return { url: normalizedUrl, token: trimmedToken };
};
const handleTestConnection = async () => {
@@ -187,6 +187,32 @@ describe('BootCheckGate — picker (unset mode)', () => {
);
});
it('normalizes a cloud core base URL to the /rpc endpoint before continuing', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
renderGate();
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
target: { value: 'https://example.trycloudflare.com/' },
});
fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
target: { value: 'tok-1234' },
});
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
expect(mockRunBootCheck).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'cloud',
url: 'https://example.trycloudflare.com/rpc',
token: 'tok-1234',
}),
expect.any(Object)
);
});
it('rejects public HTTP cloud URLs', () => {
renderGate();
@@ -274,6 +300,26 @@ describe('BootCheckGate — picker test connection', () => {
);
});
it('tests /rpc when the user enters a cloud core base URL', async () => {
mockTestCoreRpcConnection.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ result: { ok: true } }),
} as unknown as Response);
renderGate();
fillCloudInputs('https://example.trycloudflare.com/');
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
await waitFor(() => {
expect(screen.getByTestId('test-status-ok')).toBeInTheDocument();
});
expect(mockTestCoreRpcConnection).toHaveBeenCalledWith(
'https://example.trycloudflare.com/rpc',
'tok-abc'
);
});
it('shows Auth failed on a 401 response', async () => {
mockTestCoreRpcConnection.mockResolvedValue({
ok: false,
@@ -582,6 +582,19 @@ describe('coreRpcClient', () => {
});
});
test('normalizes a supplied core base URL before probing', async () => {
vi.resetModules();
vi.mocked(isTauri).mockReturnValue(false);
const { testCoreRpcConnection } = await import('../coreRpcClient');
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response);
await testCoreRpcConnection('https://example.trycloudflare.com/');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('https://example.trycloudflare.com/rpc');
});
test('omits Authorization header when no bearer token is available (non-Tauri)', async () => {
vi.resetModules();
vi.mocked(isTauri).mockReturnValue(false);
@@ -854,6 +867,11 @@ describe('coreRpcClient — typed errors + auth-expired event', () => {
});
describe('getCoreRpcUrl', () => {
const normalizeMockRpcUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '');
return trimmed.endsWith('/rpc') ? trimmed : `${trimmed}/rpc`;
};
// Each test gets a fresh module so module-level caches are cleared
beforeEach(() => {
vi.resetModules();
@@ -865,6 +883,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'http://custom-host:9999/rpc',
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(false);
@@ -873,10 +892,24 @@ describe('getCoreRpcUrl', () => {
expect(url).toBe('http://custom-host:9999/rpc');
});
test('in web mode normalizes a stored core base URL', async () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'https://example.trycloudflare.com/',
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(false);
const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient');
const url = await freshGetCoreRpcUrl();
expect(url).toBe('https://example.trycloudflare.com/rpc');
});
test('in web mode returns default CORE_RPC_URL when nothing is stored', async () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => null,
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(false);
@@ -893,6 +926,7 @@ describe('getCoreRpcUrl', () => {
return null;
},
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(false);
@@ -909,6 +943,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => storedValue,
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(false);
@@ -930,6 +965,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => null,
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
@@ -947,6 +983,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'http://stored-override:4444/rpc',
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
@@ -968,6 +1005,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc',
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
@@ -987,6 +1025,7 @@ describe('getCoreRpcUrl', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => null,
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockRejectedValue(new Error('invoke failed'));
@@ -999,6 +1038,11 @@ describe('getCoreRpcUrl', () => {
});
describe('getCoreRpcToken (cloud-mode persistence)', () => {
const normalizeMockRpcUrl = (url: string) => {
const trimmed = url.replace(/\/+$/, '');
return trimmed.endsWith('/rpc') ? trimmed : `${trimmed}/rpc`;
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
@@ -1009,6 +1053,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'https://core.example.com/rpc',
getStoredCoreToken: () => 'cloud-token-abc',
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
@@ -1038,6 +1083,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => 'https://core.example.com/rpc',
getStoredCoreToken: () => storedToken,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
const fetchMock = vi.mocked(fetch);
@@ -1065,6 +1111,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => {
vi.doMock('../../utils/configPersistence', () => ({
peekStoredRpcUrl: () => null,
getStoredCoreToken: () => null,
normalizeRpcUrl: normalizeMockRpcUrl,
}));
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
+10 -8
View File
@@ -3,7 +3,8 @@ import debug from 'debug';
import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory';
import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config';
import { getStoredCoreToken, peekStoredRpcUrl } from '../utils/configPersistence';
import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence';
import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog';
import { sanitizeError } from '../utils/sanitize';
import { isTauri as coreIsTauri } from '../utils/tauriCommands/common';
import { normalizeRpcMethod } from './rpcMethods';
@@ -278,7 +279,7 @@ export async function getCoreRpcUrl(): Promise<string> {
// null when nothing is stored, which lets us distinguish "user hasn't
// chosen yet" from "user chose a value identical to the default".
const storedUrl = peekStoredRpcUrl();
resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL;
resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL);
return resolvedCoreRpcUrl;
}
@@ -296,8 +297,8 @@ export async function getCoreRpcUrl(): Promise<string> {
// cloud mode where no local sidecar is running.
const storedUrl = peekStoredRpcUrl();
if (storedUrl) {
resolvedCoreRpcUrl = storedUrl;
return storedUrl;
resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl);
return resolvedCoreRpcUrl;
}
const url = await invoke<string>('core_rpc_url');
@@ -307,16 +308,16 @@ export async function getCoreRpcUrl(): Promise<string> {
fallback: CORE_RPC_URL,
});
}
resolvedCoreRpcUrl = trimmed || CORE_RPC_URL;
resolvedCoreRpcUrl = normalizeRpcUrl(trimmed || CORE_RPC_URL);
return resolvedCoreRpcUrl || CORE_RPC_URL;
} catch (err) {
// Tauri invoke failed — fall back to stored URL if any, then the
// build-time default. Keep the underlying invoke failure visible so
// port mismatches and shell misconfiguration are diagnosable.
const storedUrl = peekStoredRpcUrl();
resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL;
resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL);
coreRpcError('core_rpc_url invoke failed; using fallback RPC URL', {
fallback: resolvedCoreRpcUrl,
fallback: redactRpcUrlForLog(resolvedCoreRpcUrl),
usedStoredUrl: Boolean(storedUrl),
error: sanitizeError(err),
});
@@ -397,12 +398,13 @@ export async function testCoreRpcConnection(
tokenOverride?: string,
init?: { signal?: AbortSignal }
): Promise<Response> {
const rpcUrl = normalizeRpcUrl(url);
const token = tokenOverride?.trim() || (await getCoreRpcToken());
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return fetch(url, {
return fetch(rpcUrl, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }),
+18 -1
View File
@@ -67,7 +67,10 @@ describe('coreModeSlice — sync-localStorage-derived initial state', () => {
it('uses local mode when the E2E default core mode config is local', async () => {
localStorage.clear();
vi.resetModules();
vi.doMock('../utils/config', () => ({ E2E_DEFAULT_CORE_MODE: 'local' }));
vi.doMock('../utils/config', () => ({
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
E2E_DEFAULT_CORE_MODE: 'local',
}));
try {
const mod = await import('./coreModeSlice');
const state = mod.default(undefined, { type: '@@INIT' });
@@ -100,6 +103,20 @@ describe('coreModeSlice — sync-localStorage-derived initial state', () => {
});
});
it('normalizes restored cloud base URLs to the /rpc endpoint', async () => {
localStorage.clear();
localStorage.setItem('openhuman_core_mode', 'cloud');
localStorage.setItem('openhuman_core_rpc_url', 'https://example.trycloudflare.com/');
localStorage.setItem('openhuman_core_rpc_token', 'tok-abc');
const mod = await freshImport();
const state = mod.default(undefined, { type: '@@INIT' });
expect(state.mode).toEqual({
kind: 'cloud',
url: 'https://example.trycloudflare.com/rpc',
token: 'tok-abc',
});
});
it('falls back to unset when cloud marker exists but URL or token is missing', async () => {
localStorage.clear();
localStorage.setItem('openhuman_core_mode', 'cloud');
+2 -1
View File
@@ -13,6 +13,7 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { E2E_DEFAULT_CORE_MODE } from '../utils/config';
import { normalizeRpcUrl } from '../utils/configPersistence';
export type CoreMode =
| { kind: 'unset' }
@@ -64,7 +65,7 @@ function deriveInitialMode(): CoreMode {
if (mode === 'cloud') {
const url = localStorage.getItem(RPC_URL_STORAGE_KEY)?.trim();
const token = localStorage.getItem(CORE_TOKEN_STORAGE_KEY)?.trim();
if (url && token) return { kind: 'cloud', url, token };
if (url && token) return { kind: 'cloud', url: normalizeRpcUrl(url), token };
}
} catch {
/* localStorage unavailable — fall through to unset */
@@ -17,6 +17,7 @@ import {
isValidRpcUrl,
normalizeRpcUrl,
peekStoredRpcUrl,
redactRpcUrlForLog,
storeCoreMode,
storeCoreToken,
storeRpcUrl,
@@ -142,7 +143,7 @@ describe('configPersistence', () => {
it('removes trailing slashes', () => {
expect(normalizeRpcUrl('http://localhost:7788/rpc/')).toBe('http://localhost:7788/rpc');
expect(normalizeRpcUrl('http://localhost:7788/')).toBe('http://localhost:7788');
expect(normalizeRpcUrl('http://localhost:7788/')).toBe('http://localhost:7788/rpc');
});
it('handles multiple trailing slashes', () => {
@@ -152,6 +153,28 @@ describe('configPersistence', () => {
it('preserves URL without trailing slash', () => {
expect(normalizeRpcUrl('http://localhost:7788/rpc')).toBe('http://localhost:7788/rpc');
});
it('preserves query and hash values when normalizing paths', () => {
expect(normalizeRpcUrl('https://host.example?next=/')).toBe(
'https://host.example/rpc?next=/'
);
expect(normalizeRpcUrl('https://host.example/#/')).toBe('https://host.example/rpc#/');
expect(normalizeRpcUrl('https://host.example/rpc/?next=/#/')).toBe(
'https://host.example/rpc?next=/#/'
);
});
});
describe('redactRpcUrlForLog', () => {
it('removes credentials, query, and hash values before logging', () => {
expect(redactRpcUrlForLog('https://user:pass@host.example/rpc?token=secret#/token')).toBe(
'https://host.example/rpc'
);
});
it('returns a sentinel for malformed URLs', () => {
expect(redactRpcUrlForLog('not a url')).toBe('[invalid-url]');
});
});
describe('getDefaultRpcUrl', () => {
@@ -258,8 +281,11 @@ describe('configPersistence', () => {
});
describe('normalizeRpcUrl — edge cases', () => {
it('does not add /rpc suffix when missing (normalizeRpcUrl only strips, not appends)', () => {
expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788');
it('adds /rpc suffix when given a core base URL', () => {
expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788/rpc');
expect(normalizeRpcUrl('https://example.trycloudflare.com/')).toBe(
'https://example.trycloudflare.com/rpc'
);
});
it('does not double-add /rpc — leaves existing /rpc alone', () => {
@@ -285,6 +311,19 @@ describe('configPersistence', () => {
});
describe('storeRpcUrl + getStoredRpcUrl — round-trip', () => {
it('stores normalized base core URLs as RPC endpoints', () => {
storeRpcUrl('https://remote.example.com');
expect(localStorage.getItem(STORAGE_KEY)).toBe('https://remote.example.com/rpc');
expect(getStoredRpcUrl()).toBe('https://remote.example.com/rpc');
expect(peekStoredRpcUrl()).toBe('https://remote.example.com/rpc');
});
it('normalizes previously persisted base core URLs on read', () => {
localStorage.setItem(STORAGE_KEY, 'https://old.example.com/');
expect(getStoredRpcUrl()).toBe('https://old.example.com/rpc');
expect(peekStoredRpcUrl()).toBe('https://old.example.com/rpc');
});
it('round-trips an HTTPS URL', () => {
storeRpcUrl('https://remote.example.com/rpc');
expect(getStoredRpcUrl()).toBe('https://remote.example.com/rpc');
+42 -5
View File
@@ -4,9 +4,16 @@
* Handles storing/retrieving user preferences like RPC URL using
* localStorage (web) or Tauri store (desktop).
*/
import debug from 'debug';
import { CORE_RPC_URL, E2E_DEFAULT_CORE_MODE } from './config';
import { redactRpcUrlForLog } from './redactRpcUrlForLog';
import { isTauri } from './tauriCommands';
export { redactRpcUrlForLog } from './redactRpcUrlForLog';
const log = debug('config-persistence');
// Storage key for RPC URL preference
const RPC_URL_STORAGE_KEY = 'openhuman_core_rpc_url';
@@ -43,7 +50,7 @@ export function getStoredRpcUrl(): string {
try {
const stored = localStorage.getItem(RPC_URL_STORAGE_KEY);
if (stored && stored.trim().length > 0) {
return stored.trim();
return normalizeRpcUrl(stored);
}
} catch {
// localStorage might be unavailable in some environments
@@ -68,7 +75,7 @@ export function peekStoredRpcUrl(): string | null {
try {
const stored = localStorage.getItem(RPC_URL_STORAGE_KEY);
if (stored && stored.trim().length > 0) {
return stored.trim();
return normalizeRpcUrl(stored);
}
} catch {
console.warn('[configPersistence] Unable to access localStorage');
@@ -84,8 +91,9 @@ export function peekStoredRpcUrl(): string | null {
export function storeRpcUrl(url: string): void {
try {
if (url && url.trim().length > 0) {
localStorage.setItem(RPC_URL_STORAGE_KEY, url.trim());
console.debug('[configPersistence] Stored RPC URL:', { url: url.trim() });
const normalized = normalizeRpcUrl(url);
localStorage.setItem(RPC_URL_STORAGE_KEY, normalized);
log('Stored RPC URL: %s', redactRpcUrlForLog(normalized));
} else {
// Allow clearing the stored URL to reset to default
localStorage.removeItem(RPC_URL_STORAGE_KEY);
@@ -174,12 +182,41 @@ export function isAllowedCloudRpcUrl(url: string): boolean {
/**
* Normalize an RPC URL by trimming whitespace and trailing slashes.
* When the user provides a core base URL with no path, treat it as the
* JSON-RPC endpoint base and append `/rpc`.
*
* @param url - The URL to normalize
* @returns The normalized URL
*/
export function normalizeRpcUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
const trimmed = url.trim();
try {
// Parse before trimming path slashes so query/hash values such as ?next=/
// or #/ stay byte-for-byte intact.
new URL(trimmed);
const suffixStart = firstUrlSuffixIndex(trimmed);
const base = suffixStart === -1 ? trimmed : trimmed.slice(0, suffixStart);
const suffix = suffixStart === -1 ? '' : trimmed.slice(suffixStart);
const pathStart = base.indexOf('/', base.indexOf('://') + 3);
const origin = pathStart === -1 ? base : base.slice(0, pathStart);
const path = pathStart === -1 ? '' : base.slice(pathStart);
const pathWithoutTrailingSlashes = path.replace(/\/+$/, '');
const normalizedPath = pathWithoutTrailingSlashes || '/rpc';
return `${origin}${normalizedPath}${suffix}`;
} catch {
// Validation reports malformed URLs. Keep this helper side-effect free.
}
return trimmed.replace(/\/+$/, '');
}
function firstUrlSuffixIndex(url: string): number {
const searchIndex = url.indexOf('?');
const hashIndex = url.indexOf('#');
if (searchIndex === -1) return hashIndex;
if (hashIndex === -1) return searchIndex;
return Math.min(searchIndex, hashIndex);
}
/**
+12
View File
@@ -0,0 +1,12 @@
export function redactRpcUrlForLog(url: string): string {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
parsed.search = '';
parsed.hash = '';
return parsed.origin + parsed.pathname;
} catch {
return '[invalid-url]';
}
}