mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* feat: identify clients via X-Client-Platform/Version/OS
Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.
- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}
Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).
Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.
CORS allowlist extended for the new headers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: address client-identity PR nits
- Memoize the CoreProvider identity object on Web and Desktop, and key
WSProvider's effect on identity primitives instead of the object
reference, so unrelated parent re-renders no longer tear down and
reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
and asserts the "websocket connected" log entry surfaces them as
structured attributes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
151 lines
4.4 KiB
TypeScript
151 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
useCallback,
|
|
useSyncExternalStore,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { WSClient } from "../api/ws-client";
|
|
import type { WSEventType, StorageAdapter } from "../types";
|
|
import type { ClientIdentity } from "../platform/types";
|
|
import type { StoreApi, UseBoundStore } from "zustand";
|
|
import type { AuthState } from "../auth/store";
|
|
import {
|
|
getCurrentSlug,
|
|
subscribeToCurrentSlug,
|
|
} from "../platform/workspace-storage";
|
|
import { createLogger } from "../logger";
|
|
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
|
|
|
type EventHandler = (payload: unknown, actorId?: string) => void;
|
|
|
|
interface WSContextValue {
|
|
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
|
|
onReconnect: (callback: () => void) => () => void;
|
|
}
|
|
|
|
const WSContext = createContext<WSContextValue | null>(null);
|
|
|
|
export interface WSProviderProps {
|
|
children: ReactNode;
|
|
/** WebSocket server URL (e.g. "ws://localhost:8080/ws") */
|
|
wsUrl: string;
|
|
/** Platform-created auth store instance */
|
|
authStore: UseBoundStore<StoreApi<AuthState>>;
|
|
/** Platform-specific storage adapter for reading auth tokens */
|
|
storage: StorageAdapter;
|
|
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
|
|
cookieAuth?: boolean;
|
|
/** Identifies the WS client to the server (sent as query params on the upgrade URL). */
|
|
identity?: ClientIdentity;
|
|
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
|
|
onToast?: (message: string, type?: "info" | "error") => void;
|
|
}
|
|
|
|
export function WSProvider({
|
|
children,
|
|
wsUrl,
|
|
authStore,
|
|
storage,
|
|
cookieAuth,
|
|
identity,
|
|
onToast,
|
|
}: WSProviderProps) {
|
|
const user = authStore((s) => s.user);
|
|
// Reactive read of the current workspace slug (URL-driven singleton in
|
|
// packages/core/platform/workspace-storage.ts). When the workspace switches,
|
|
// the useEffect below tears down the old WS connection and opens a new one
|
|
// bound to the new workspace slug. SSR snapshot is `null` because this
|
|
// provider only renders client-side under CoreProvider.
|
|
const wsSlug = useSyncExternalStore(
|
|
subscribeToCurrentSlug,
|
|
getCurrentSlug,
|
|
() => null,
|
|
);
|
|
const [wsClient, setWsClient] = useState<WSClient | null>(null);
|
|
|
|
// Depend on identity primitives instead of the object reference so a parent
|
|
// re-render that passes a new `{ platform, version, os }` literal does not
|
|
// tear down and reconnect the WS when nothing about the identity actually
|
|
// changed.
|
|
const identityPlatform = identity?.platform;
|
|
const identityVersion = identity?.version;
|
|
const identityOS = identity?.os;
|
|
|
|
useEffect(() => {
|
|
if (!user || !wsSlug) return;
|
|
|
|
// In token mode we need a token from storage; in cookie mode the HttpOnly
|
|
// cookie is sent automatically with the WS upgrade request.
|
|
const token = cookieAuth ? null : storage.getItem("multica_token");
|
|
if (!cookieAuth && !token) return;
|
|
|
|
const ws = new WSClient(wsUrl, {
|
|
logger: createLogger("ws"),
|
|
cookieAuth,
|
|
identity:
|
|
identityPlatform || identityVersion || identityOS
|
|
? {
|
|
platform: identityPlatform,
|
|
version: identityVersion,
|
|
os: identityOS,
|
|
}
|
|
: undefined,
|
|
});
|
|
ws.setAuth(token, wsSlug);
|
|
setWsClient(ws);
|
|
ws.connect();
|
|
|
|
return () => {
|
|
ws.disconnect();
|
|
setWsClient(null);
|
|
};
|
|
}, [
|
|
user,
|
|
wsSlug,
|
|
wsUrl,
|
|
storage,
|
|
cookieAuth,
|
|
identityPlatform,
|
|
identityVersion,
|
|
identityOS,
|
|
]);
|
|
|
|
const stores: RealtimeSyncStores = { authStore };
|
|
|
|
// Centralized WS -> store sync (uses state so it re-subscribes when WS changes)
|
|
useRealtimeSync(wsClient, stores, onToast);
|
|
|
|
const subscribe = useCallback(
|
|
(event: WSEventType, handler: EventHandler) => {
|
|
if (!wsClient) return () => {};
|
|
return wsClient.on(event, handler);
|
|
},
|
|
[wsClient],
|
|
);
|
|
|
|
const onReconnectCb = useCallback(
|
|
(callback: () => void) => {
|
|
if (!wsClient) return () => {};
|
|
return wsClient.onReconnect(callback);
|
|
},
|
|
[wsClient],
|
|
);
|
|
|
|
return (
|
|
<WSContext.Provider value={{ subscribe, onReconnect: onReconnectCb }}>
|
|
{children}
|
|
</WSContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useWS() {
|
|
const ctx = useContext(WSContext);
|
|
if (!ctx) throw new Error("useWS must be used within WSProvider");
|
|
return ctx;
|
|
}
|