Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions hub-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useAuth } from './hooks/useAuth';
import { useAuthProbe } from './hooks/useAuthProbe';
import { resolveActorId as resolveActorIdRequest } from './services/authService';
import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing';
import { resolveSyncServerUrl } from './utils/routing';
import './App.css';

/**
Expand Down Expand Up @@ -68,7 +69,7 @@ async function connectAndLoadContents(
// time when the connection is genuinely slow — exactly the CI case we want to
// wait out. Tree-shaken in production, so no offline-first UX change there.
const peerTimeoutMs = import.meta.env.VITE_E2E === '1' ? 15000 : undefined;
const files = await connect(syncServer, indexDocId, actorId, screenName, color, peerTimeoutMs);
const files = await connect(resolveSyncServerUrl(syncServer), indexDocId, actorId, screenName, color, peerTimeoutMs);
const contents = new Map<string, string>();
for (const file of files) {
const content = getFileContent(file.path);
Expand Down Expand Up @@ -524,8 +525,12 @@ function App() {
// Create the Automerge documents. The resolveActorId callback is
// called after the index doc is created (to derive the HMAC actor
// ID from the indexDocId) but before any file docs are written.
//
// Resolve only the runtime connection value (the WS adapter needs an
// absolute ws(s):// URL); the portable `syncServer` is what we store
// and share below, so it stays origin-independent under a subpath.
const result = await createNewProject({
syncServer,
syncServer: resolveSyncServerUrl(syncServer),
files,
}, undefined, screenName, cursorColor, resolveActorId);

Expand Down
14 changes: 12 additions & 2 deletions hub-client/src/components/auth/LoginScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @vitest-environment jsdom
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import type { ReactNode } from 'react';

Expand All @@ -18,7 +18,10 @@ beforeEach(() => {
mock = createMockAuthProvider();
});

afterEach(cleanup);
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
});

function withProvider(children: ReactNode) {
return (
Expand All @@ -38,6 +41,13 @@ describe('LoginScreen', () => {
expect(mock.lastLoginUri).toBe(window.location.origin + '/auth/callback');
});

it('prefixes the callback with the hub base path under a subpath mount', () => {
vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath');
render(withProvider(<LoginScreen />));

expect(mock.lastLoginUri).toBe(window.location.origin + '/subpath/auth/callback');
});

it('renders the default copy when error is false/absent', () => {
render(withProvider(<LoginScreen />));
expect(screen.getByText(/Sign in with Google to continue/i)).toBeTruthy();
Expand Down
3 changes: 2 additions & 1 deletion hub-client/src/components/auth/LoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { useAuthProvider } from '../../auth/AuthProvider';
import { hubPath } from '../../utils/routing';

export function LoginScreen({ error, message }: { error?: boolean; message?: string }) {
const provider = useAuthProvider();
Expand All @@ -35,7 +36,7 @@ export function LoginScreen({ error, message }: { error?: boolean; message?: str
</p>
)}
<provider.SignInButton
loginUri={window.location.origin + '/auth/callback'}
loginUri={window.location.origin + hubPath('/auth/callback')}
/>
</div>
</div>
Expand Down
38 changes: 38 additions & 0 deletions hub-client/src/services/authService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,44 @@ describe('authService', () => {

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

// ── hub base path (subpath mount) ───────────────────────────

describe('VITE_HUB_BASE_PATH', () => {
it('prefixes auth requests with the configured mount base', async () => {
vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath');
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ email: 'admin', name: 'admin', picture: null }),
} as Response);

await fetchAuthMe();
expect(fetch).toHaveBeenCalledWith('/subpath/auth/me', {
credentials: 'same-origin',
});

await fetchActorId('proj-1');
expect(fetch).toHaveBeenCalledWith(
'/subpath/auth/actor?project=proj-1',
{ credentials: 'same-origin' },
);
});

it('leaves paths origin-absolute when unset (dev / standalone)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ email: 'a@b.com', name: null, picture: null }),
} as Response);

await fetchAuthMe();
expect(fetch).toHaveBeenCalledWith('/auth/me', {
credentials: 'same-origin',
});
});
});

// ── fetchAuthMe ─────────────────────────────────────────────
Expand Down
10 changes: 6 additions & 4 deletions hub-client/src/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* `AuthProvider`'s concern, not this module's.
*/

import { hubPath } from '../utils/routing';

/** User info returned by GET /auth/me. */
export interface AuthState {
email: string;
Expand All @@ -28,7 +30,7 @@ interface AuthMeResponse {

/** Fetch user info from the server. Returns null on 401 (not authenticated). */
export async function fetchAuthMe(): Promise<AuthState | null> {
const res = await fetch('/auth/me', { credentials: 'same-origin' });
const res = await fetch(hubPath('/auth/me'), { credentials: 'same-origin' });
if (res.status === 401 || res.status === 403) return null;
if (!res.ok) throw new Error(`/auth/me failed: ${res.status}`);
const data = await res.json() as AuthMeResponse;
Expand Down Expand Up @@ -56,7 +58,7 @@ interface AuthActorResponse {
*/
export async function fetchActorId(projectId: string): Promise<string | null> {
const res = await fetch(
`/auth/actor?project=${encodeURIComponent(projectId)}`,
hubPath(`/auth/actor?project=${encodeURIComponent(projectId)}`),
{ credentials: 'same-origin' },
);
if (res.status === 401 || res.status === 403) return null;
Expand Down Expand Up @@ -93,7 +95,7 @@ export async function resolveActorId(

/** Clear the auth cookie server-side. */
export async function logout(): Promise<void> {
await fetch('/auth/logout', {
await fetch(hubPath('/auth/logout'), {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
Expand All @@ -105,7 +107,7 @@ export async function logout(): Promise<void> {
* Returns the updated user info on success, null on auth failure.
*/
export async function refreshToken(credential: string): Promise<AuthState | null> {
const res = await fetch('/auth/refresh', {
const res = await fetch(hubPath('/auth/refresh'), {
method: 'POST',
credentials: 'same-origin',
headers: {
Expand Down
5 changes: 3 additions & 2 deletions hub-client/src/services/projectSetService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { DocHandle, DocumentId } from '@automerge/automerge-repo';
import { from as automergeFrom, save as automergeSerialize } from '@automerge/automerge';
import { BrowserWebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
import { IndexedDBStorageAdapter } from '@automerge/automerge-repo-storage-indexeddb';
import { resolveSyncServerUrl } from '../utils/routing';

import type {
ProjectSetDocument,
Expand Down Expand Up @@ -124,7 +125,7 @@ export async function connect(
): Promise<ProjectSetEntry[]> {
await disconnect();

wsAdapter = new BrowserWebSocketClientAdapter(syncServerUrl);
wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl));
repo = new Repo({
network: [wsAdapter],
storage: new IndexedDBStorageAdapter(),
Expand Down Expand Up @@ -192,7 +193,7 @@ export async function createProjectSet(
): Promise<string> {
await disconnect();

wsAdapter = new BrowserWebSocketClientAdapter(syncServerUrl);
wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl));
repo = new Repo({
network: [wsAdapter],
storage: new IndexedDBStorageAdapter(),
Expand Down
78 changes: 77 additions & 1 deletion hub-client/src/utils/routing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Tests for URL routing utilities.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import {
parseHashRoute,
buildHashRoute,
Expand All @@ -11,6 +11,8 @@ import {
sameFile,
savePreAuthHash,
restorePreAuthHash,
resolveSyncServerUrl,
hubPath,
type Route,
type ShareRoute,
type LinkProjectSetRoute,
Expand Down Expand Up @@ -575,6 +577,80 @@ describe('sameFile', () => {
});
});

// ── resolveSyncServerUrl ─────────────────────────────────────────

describe('resolveSyncServerUrl', () => {
const originalWindow = globalThis.window;

afterEach(() => {
// @ts-expect-error - restoring window
globalThis.window = originalWindow;
});

function mockLocation(protocol: string, host: string) {
// @ts-expect-error - mocking window in node environment
globalThis.window = {
location: { protocol, host },
};
}

it('resolves a relative path to wss:// on an https origin', () => {
mockLocation('https:', 'hub.example.com');
expect(resolveSyncServerUrl('/subpath/ws')).toBe(
'wss://hub.example.com/subpath/ws'
);
});

it('resolves a relative path to ws:// on an http origin', () => {
mockLocation('http:', 'localhost:3939');
expect(resolveSyncServerUrl('/subpath/ws')).toBe(
'ws://localhost:3939/subpath/ws'
);
});

it('leaves an absolute wss:// URL unchanged', () => {
mockLocation('https:', 'hub.example.com');
expect(resolveSyncServerUrl('wss://sync.automerge.org')).toBe(
'wss://sync.automerge.org'
);
});

it('leaves an absolute ws:// URL unchanged', () => {
mockLocation('http:', 'localhost:3939');
expect(resolveSyncServerUrl('ws://localhost:3000')).toBe(
'ws://localhost:3000'
);
});

it('leaves an absolute https:// URL unchanged', () => {
mockLocation('https:', 'hub.example.com');
expect(resolveSyncServerUrl('https://sync.example.com')).toBe(
'https://sync.example.com'
);
});
});

// ── hubPath ──────────────────────────────────────────────────────
//
// Single source of truth for the subpath mount: prefixes auth REST
// calls and derives the sync-server default (`hubPath('/ws')`).

describe('hubPath', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('is a no-op when no base path is set (served from the hub origin)', () => {
expect(hubPath('/auth/me')).toBe('/auth/me');
});

it('prefixes the configured mount base (subpath deployment)', () => {
vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath');
expect(hubPath('/auth/me')).toBe('/subpath/auth/me');
expect(hubPath('/ws')).toBe('/subpath/ws');
});
});

// ── Pre-Auth Hash Preservation ──────────────────────────────────

describe('savePreAuthHash / restorePreAuthHash', () => {
Expand Down
36 changes: 33 additions & 3 deletions hub-client/src/utils/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,40 @@
* sensitive indexDocId from appearing in browser history or bookmarks.
*/

/** Default sync server URL used when not specified in shareable URLs.
* Can be overridden at build time via the VITE_DEFAULT_SYNC_SERVER environment variable. */
/** * Prefix a path with the hub's mount base path, if any. */
export function hubPath(path: string): string {
return `${import.meta.env.VITE_HUB_BASE_PATH ?? ''}${path}`;
}

/**
* Default sync server URL used when not specified in shareable URLs.
*
* An explicit `VITE_DEFAULT_SYNC_SERVER` takes precedence, followed by a
* subpath-aware `<base>/ws`, and finally the public automerge.org sync server.
*/
export const DEFAULT_SYNC_SERVER =
import.meta.env.VITE_DEFAULT_SYNC_SERVER || 'wss://sync.automerge.org';
import.meta.env.VITE_DEFAULT_SYNC_SERVER ||
(import.meta.env.VITE_HUB_BASE_PATH ? hubPath('/ws') : 'wss://sync.automerge.org');

/**
* Resolve a sync server URL to an absolute WebSocket URL.
*
* When the hub is mounted under a subpath, `DEFAULT_SYNC_SERVER` is a relative
* path such as `/subpath/ws` (derived from `VITE_HUB_BASE_PATH`).
* This function expands relative paths (those starting with `/`) against the
* current page origin at runtime so they become valid WebSocket URLs. Absolute
* URLs (starting with `wss://`, `ws://`, etc.) are returned unchanged.
*
* @param syncServer - The sync server value from config or a shareable URL.
* @returns An absolute WebSocket URL ready to pass to the WS adapter.
*/
export function resolveSyncServerUrl(syncServer: string): string {
if (!syncServer.startsWith('/')) {
return syncServer;
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}${syncServer}`;
}

// ============================================================================
// Types
Expand Down
2 changes: 2 additions & 0 deletions hub-client/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_DEFAULT_SYNC_SERVER?: string
/** Google OAuth2 client ID. When set, enables authentication. */
readonly VITE_GOOGLE_CLIENT_ID?: string
/** Base path the hub is reverse-proxied at. */
readonly VITE_HUB_BASE_PATH?: string
}

interface ImportMeta {
Expand Down
Loading