feat(desktop): support remote runtime headers

This commit is contained in:
Bohdan Triapitsyn
2026-06-30 00:30:48 +03:00
parent 9c1eb755f9
commit 359c73fcf3
23 changed files with 458 additions and 45 deletions
@@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({
if (!apiOrigin) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({
return;
}
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) });
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
onHostSwitched?.();
setSwitchingHostId(null);
return;
@@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({
const url = resolved.persistedUrl;
const label = (editLabel || redactSensitiveUrl(url)).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
if (resolved.redeemUrl) {
@@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => {
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
@@ -199,6 +199,36 @@ const formatLogLine = (line: string): string => {
return `[${iso}] [${level}] ${message}`;
};
type HeaderDraft = {
id: string;
name: string;
value: string;
};
const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({
id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `header-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name,
value,
});
const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
const buildRequestHeaders = (headers: HeaderDraft[]): Record<string, string> | undefined => {
const next: Record<string, string> = {};
for (const header of headers) {
const name = header.name.trim();
const value = header.value.trim();
if (name && value && !isReservedRequestHeaderName(name)) next[name] = value;
}
return Object.keys(next).length > 0 ? next : undefined;
};
const readRequestHeaderDrafts = (headers: Record<string, string> | undefined): HeaderDraft[] => {
return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value));
};
const navigateToUrl = (rawUrl: string): void => {
const target = rawUrl.trim();
if (!target) {
@@ -301,6 +331,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directLabel, setDirectLabel] = React.useState('');
const [directUrl, setDirectUrl] = React.useState('');
const [directToken, setDirectToken] = React.useState('');
const [directHeaders, setDirectHeaders] = React.useState<HeaderDraft[]>([]);
const [directConnectLink, setDirectConnectLink] = React.useState('');
const [directError, setDirectError] = React.useState<string | null>(null);
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
@@ -309,6 +340,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directEditLabel, setDirectEditLabel] = React.useState('');
const [directEditUrl, setDirectEditUrl] = React.useState('');
const [directEditToken, setDirectEditToken] = React.useState('');
const [directEditHeaders, setDirectEditHeaders] = React.useState<HeaderDraft[]>([]);
const [remoteClients, setRemoteClients] = React.useState<RemoteClientRecord[]>([]);
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
@@ -374,16 +406,18 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}),
};
await persistDirectHosts([host, ...directHosts], directDefaultHostId);
setDirectLabel('');
setDirectUrl('');
setDirectToken('');
setDirectHeaders([]);
setDirectAddDialogOpen(false);
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
}, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
const importDirectConnectLink = React.useCallback(async () => {
const payload = parseClientConnectionPayload(directConnectLink);
@@ -427,6 +461,7 @@ export const RemoteInstancesPage: React.FC = () => {
setDirectEditLabel(host.label);
setDirectEditUrl(host.apiUrl || host.url);
setDirectEditToken(host.clientToken || '');
setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders));
setDirectError(null);
}, []);
@@ -445,6 +480,7 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
clientToken: directEditToken.trim() || undefined,
requestHeaders: buildRequestHeaders(directEditHeaders),
}
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
@@ -452,7 +488,7 @@ export const RemoteInstancesPage: React.FC = () => {
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
}, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
}, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
const createSshInstanceFromDialog = React.useCallback(async () => {
const command = sshCommandDraft.trim();
@@ -1095,6 +1131,25 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directLabel} onChange={(event) => setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directUrl} onChange={(event) => setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directToken} onChange={(event) => setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
<button type="button" onClick={() => setDirectHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving || !directUrl.trim()}>{t('settings.remoteInstances.direct.actions.add')}</Button>
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => {
<Input className="h-8" value={directEditLabel} onChange={(event) => setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
<Input className="h-8" value={directEditUrl} onChange={(event) => setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
<Input className="h-8" value={directEditToken} onChange={(event) => setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
<div className="space-y-2">
<div>
<p className="typography-ui-label text-foreground">{t('settings.remoteInstances.direct.headers.title')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.direct.headers.description')}</p>
</div>
{directEditHeaders.map((header) => (
<div key={header.id} className="flex w-full gap-2">
<Input className="h-8 font-mono text-xs" value={header.name} onChange={(event) => setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
<Input className="h-8 font-mono text-xs" value={header.value} onChange={(event) => setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
<button type="button" onClick={() => setDirectEditHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
))}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setDirectEditHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.direct.headers.actions.add')}
</Button>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={directSaving}>{t('settings.common.actions.saveChanges')}</Button>
+84 -1
View File
@@ -1,5 +1,26 @@
import { describe, expect, test } from 'bun:test';
import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__OPENCHAMBER_DESKTOP__: {
invoke: handler,
},
},
});
try {
return await run();
} finally {
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
};
describe('resolveDesktopHostUrl', () => {
test('keeps regular host URLs unchanged', () => {
@@ -32,3 +53,65 @@ describe('resolveDesktopHostUrl', () => {
);
});
});
describe('desktop host runtime headers', () => {
test('parses persisted request headers from desktop config', async () => {
await withDesktopBridge(async (cmd) => {
expect(cmd).toBe('desktop_hosts_get');
return {
hosts: [{
id: 'remote-1',
label: 'Remote',
url: 'https://remote.example',
requestHeaders: {
' CF-Access-Client-Id ': ' client-id ',
Authorization: 'Bearer should-not-be-read',
'Bad:Name': 'bad',
},
}],
defaultHostId: 'remote-1',
initialHostChoiceCompleted: true,
};
}, async () => {
const config = await desktopHostsGet();
expect(config.hosts[0]?.requestHeaders).toEqual({
'CF-Access-Client-Id': 'client-id',
});
});
});
test('passes request headers through host save and probe IPC calls', async () => {
const calls: Array<{ cmd: string; args: Record<string, unknown> }> = [];
await withDesktopBridge(async (cmd, args) => {
calls.push({ cmd, args });
if (cmd === 'desktop_host_probe') return { status: 'ok', latencyMs: 7 };
return null;
}, async () => {
const requestHeaders = { 'CF-Access-Client-Id': 'client-id' };
await desktopHostsSet({
hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders }],
defaultHostId: 'remote-1',
});
const probe = await desktopHostProbe('https://remote.example', { requestHeaders });
expect(probe).toEqual({ status: 'ok', latencyMs: 7 });
});
expect(calls[0]).toEqual({
cmd: 'desktop_hosts_set',
args: {
input: {
hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders: { 'CF-Access-Client-Id': 'client-id' } }],
defaultHostId: 'remote-1',
initialHostChoiceCompleted: undefined,
},
},
});
expect(calls[1]).toEqual({
cmd: 'desktop_host_probe',
args: {
url: 'https://remote.example',
requestHeaders: { 'CF-Access-Client-Id': 'client-id' },
},
});
});
});
+27 -8
View File
@@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | undefined => {
if (!isRecord(headers)) return undefined;
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const name = key.trim();
const headerValue = typeof value === 'string' ? value.trim() : '';
if (!name || !headerValue || /[\r\n:]/.test(name) || /[\r\n]/.test(headerValue)) continue;
if (isReservedRequestHeaderName(name)) continue;
next[name] = headerValue;
}
return Object.keys(next).length > 0 ? next : undefined;
};
export type DesktopHost = {
id: string;
label: string;
@@ -11,6 +30,8 @@ export type DesktopHost = {
apiUrl?: string;
/** Remote client bearer token for packaged-client API access. */
clientToken?: string;
/** Extra headers for desktop runtime API requests. */
requestHeaders?: Record<string, string>;
};
export type DesktopHostsConfig = {
@@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool
}
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const readString = (obj: Record<string, unknown>, key: string): string | null => {
const val = obj[key];
return typeof val === 'string' ? val : null;
@@ -156,6 +173,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
const url = readString(value, 'url');
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
const requestHeaders = sanitizeRequestHeaders(value.requestHeaders);
if (!id || !label || !url) return null;
return {
id,
@@ -163,6 +181,7 @@ const parseHost = (value: unknown): DesktopHost | null => {
url,
...(apiUrl ? { apiUrl } : {}),
...(clientToken ? { clientToken } : {}),
...(requestHeaders ? { requestHeaders } : {}),
};
};
@@ -226,13 +245,13 @@ export const desktopLocalClientTokenGet = async (): Promise<string> => {
return typeof raw === 'string' ? raw.trim() : '';
};
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise<HostProbeResult> => {
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
const invoke = getInvoke();
if (!invoke) {
return { status: 'unreachable', latencyMs: 0 };
}
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined });
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
if (!isRecord(raw)) {
return { status: 'unreachable', latencyMs: 0 };
}
@@ -247,8 +266,8 @@ export const desktopHostProbe = async (url: string, options?: { clientToken?: st
return { status, latencyMs };
};
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise<void> => {
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined });
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
};
@@ -258,6 +258,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)',
'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': 'Add Server',
'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -225,6 +225,12 @@ export const settingsDict = {
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
"settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)",
"settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.",
"settings.remoteInstances.direct.headers.title": "Additional headers",
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
"settings.remoteInstances.direct.headers.actions.add": "Add header",
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
"settings.remoteInstances.direct.actions.add": "Añadir servidor",
"settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.",
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
@@ -1729,6 +1729,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)',
'settings.remoteInstances.direct.note': 'Les tokens de connexion sont enregistrés sur cet appareil et utilisés uniquement lorsque cette application se connecte à ce serveur.',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': 'Ajouter le serveur',
'settings.remoteInstances.direct.import.description': 'Collez un lien de connexion provenant dun autre serveur OpenChamber.',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -258,6 +258,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)',
'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': 'サーバーを追加',
'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -225,6 +225,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)',
'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': '서버 추가',
'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -1434,6 +1434,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)',
'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': 'Dodaj serwer',
'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -225,6 +225,12 @@ export const settingsDict = {
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
"settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)",
"settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.",
"settings.remoteInstances.direct.headers.title": "Additional headers",
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
"settings.remoteInstances.direct.headers.actions.add": "Add header",
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
"settings.remoteInstances.direct.actions.add": "Adicionar servidor",
"settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.",
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
@@ -225,6 +225,12 @@ export const settingsDict = {
"settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port",
"settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)",
"settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.",
"settings.remoteInstances.direct.headers.title": "Additional headers",
"settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.",
"settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name",
"settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value",
"settings.remoteInstances.direct.headers.actions.add": "Add header",
"settings.remoteInstances.direct.headers.removeAria": "Remove header",
"settings.remoteInstances.direct.actions.add": "Додати сервер",
"settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.",
"settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...",
@@ -225,6 +225,12 @@ export const settingsDict = {
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port',
'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)',
'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': '添加服务器',
'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。',
'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...',
@@ -231,6 +231,12 @@
'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com',
'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)',
'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。',
'settings.remoteInstances.direct.headers.title': 'Additional headers',
'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.',
'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name',
'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value',
'settings.remoteInstances.direct.headers.actions.add': 'Add header',
'settings.remoteInstances.direct.headers.removeAria': 'Remove header',
'settings.remoteInstances.direct.actions.add': '新增直接連線',
'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。',
'settings.remoteInstances.direct.import.placeholder': '貼上連線連結',
+55
View File
@@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test';
import {
buildRuntimeAuthHeaders,
clearRuntimeAuthCredentialProvider,
clearRuntimeUrlAuthToken,
getRuntimeBearerTokenSync,
refreshRuntimeUrlAuthToken,
setRuntimeAuthCredentialProvider,
setRuntimeBearerToken,
setRuntimeExtraHeaders,
} from './runtime-auth';
describe('runtime auth headers', () => {
@@ -60,4 +63,56 @@ describe('runtime auth headers', () => {
}
}
});
test('adds runtime extra headers without overriding bearer authorization', async () => {
try {
setRuntimeBearerToken('runtime-token');
setRuntimeExtraHeaders({
'CF-Access-Client-Id': 'client-id',
Authorization: 'Bearer proxy-token',
});
const headers = await buildRuntimeAuthHeaders();
expect(headers.get('CF-Access-Client-Id')).toBe('client-id');
expect(headers.get('Authorization')).toBe('Bearer runtime-token');
} finally {
setRuntimeExtraHeaders(null);
clearRuntimeAuthCredentialProvider();
}
});
test('sends runtime extra headers when minting URL auth tokens', async () => {
const previousFetch = globalThis.fetch;
let seenUrl = '';
let seenHeaders = new Headers();
try {
clearRuntimeUrlAuthToken();
setRuntimeBearerToken('runtime-token');
setRuntimeExtraHeaders({
'CF-Access-Client-Id': 'client-id',
Authorization: 'Bearer proxy-token',
});
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenHeaders = new Headers(init?.headers);
return new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;
const token = await refreshRuntimeUrlAuthToken('https://runtime.example');
expect(token).toBe('url-token');
expect(seenUrl).toBe('https://runtime.example/auth/url-token');
expect(seenHeaders.get('CF-Access-Client-Id')).toBe('client-id');
expect(seenHeaders.get('Authorization')).toBe('Bearer runtime-token');
} finally {
globalThis.fetch = previousFetch;
clearRuntimeUrlAuthToken();
setRuntimeExtraHeaders(null);
clearRuntimeAuthCredentialProvider();
}
});
});
+33
View File
@@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis
let credentialProvider: RuntimeAuthCredentialProvider = () => null;
let runtimeBearerToken = '';
let runtimeExtraHeaders: Record<string, string> = {};
let runtimeUrlAuthToken = '';
let runtimeUrlAuthTokenExpiresAt = 0;
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
@@ -13,6 +14,18 @@ let runtimeAuthGeneration = 0;
const URL_AUTH_REFRESH_SKEW_MS = 10_000;
const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization';
const sanitizeRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): Record<string, string> => {
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(headers || {})) {
const name = key.trim();
const headerValue = value.trim();
if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue;
}
return next;
};
const normalizeBearerToken = (token: string | null | undefined): string => {
if (typeof token !== 'string') return '';
return token.trim();
@@ -74,6 +87,20 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void =>
credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null;
};
export const setRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): void => {
// These headers are for runtime HTTP fetches and URL-token minting. Browser-owned
// realtime transports (EventSource/WebSocket) cannot attach arbitrary headers.
runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers);
resetRuntimeAuthGeneration();
};
export const getRuntimeExtraHeadersSync = (): Record<string, string> => {
if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders;
if (typeof window === 'undefined') return {};
const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string> }).__OPENCHAMBER_RUNTIME_HEADERS__;
return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {};
};
export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken();
export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
@@ -127,6 +154,9 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
const refreshPromise = (async () => {
const credential = await getRuntimeAuthCredential();
const headers = new Headers();
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
headers.set(key, value);
}
if (credential?.type === 'bearer') {
headers.set('Authorization', `Bearer ${credential.token}`);
}
@@ -257,6 +287,9 @@ export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void)
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
const next = new Headers(headers);
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
if (!next.has(key)) next.set(key, value);
}
if (next.has('Authorization')) {
return next;
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth';
import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth';
import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
export type RuntimeEndpointChangedDetail = {
@@ -68,7 +68,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null;
activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl));
};
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => {
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record<string, string> | null }): void => {
const apiBaseUrl = options.apiBaseUrl.trim();
const previousApiBaseUrl = getRuntimeApiBaseUrl();
const previousRuntimeKey = getRuntimeKey();
@@ -79,11 +79,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
const runtimeWindow = window as typeof window & {
__OPENCHAMBER_API_BASE_URL__?: string;
__OPENCHAMBER_CLIENT_TOKEN__?: string;
__OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string>;
};
runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl;
runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined;
runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined;
}
configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl });
setRuntimeExtraHeaders(options.requestHeaders || null);
setRuntimeBearerToken(options.clientToken || null);
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
if (typeof window !== 'undefined') {
+1 -1
View File
@@ -404,7 +404,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'remote-instances',
titleKey: 'settings.remoteInstances.direct.title',
descriptionKey: 'settings.remoteInstances.direct.description',
keywords: ['server url', 'connection token', 'import link', 'host switcher'],
keywords: ['server url', 'connection token', 'import link', 'host switcher', 'additional headers', 'request headers', 'cloudflare access', 'service token'],
isAvailable: (ctx) => ctx.isDesktop,
},
{