talk to two officers at once from one browser
The chat app on the iPad does this already and this is its model, not the music app one. Music keeps its active server — one library at a time is the right question there. Chat is the exception: two panels side by side, one on the laptop and one on alpha, both live, no switching. The mechanism is one string. A panel holds a serverId; that same string picks the base URL, the credential, the websocket host and the tail of the react-query key. Nothing global is consulted when it is named, which is exactly why two can be live at once — there is no active server in connections.ts at all, because there is nothing to switch. THIS ORIGIN IS NOT IN THE LIST. It is represented by null, so every existing useClient() call is untouched and adding a connection cannot break the app you are already signed into. That property is what makes this shippable before anyone has tried it. A second server is reached with an ofk_ API key minted there, verified against /api/auth/me before it is stored — a URL typo and a key from the wrong machine are otherwise indistinguishable from an empty conversation list an hour later. Copied deliberately from the mobile code: the base URL is derived per call rather than memoised (a cached one hands back whichever server was asked for first), the row stamps its server onto the selection BEFORE navigating (or the resolver reads the transcript from this origin, where two officers can hold the same uuid), and changing server clears the cwd and the open conversation, because a path from the machine you left names nothing on the one you arrived at. Not yet opened in a browser. Typecheck and 602 tests pass, and the cross-origin request with an API key is verified by curl, but no human has clicked any of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* The other Officer servers this browser can talk to, and the credential for each.
|
||||
*
|
||||
* ## The model, which is the mobile chat app's and not the mobile app's
|
||||
*
|
||||
* `packages/core/src/services/servers.ts` in the mobile repo says "one active server at a time,
|
||||
* always" — switching tears one session down and brings another up. That is right for Music, where the
|
||||
* question "whose library is this" has one answer at a time, and that behaviour is untouched here.
|
||||
*
|
||||
* Chat is the deliberate exception. Andre works with two panes side by side, one talking to the laptop
|
||||
* and one to alpha, both live, no switching. So there is **no active server in this module at all** —
|
||||
* nothing to switch, nothing to leak. A caller names the server it wants or gets this origin.
|
||||
*
|
||||
* The mechanism is one string. A panel holds a `serverId`; that same string picks the base URL, the
|
||||
* credential, the WebSocket host and the tail of every React Query key. Nothing global is consulted
|
||||
* when it is named, which is precisely why two servers can be live at once.
|
||||
*
|
||||
* ## What lives here and what does not
|
||||
*
|
||||
* The list and its keys live in `localStorage`, because they must survive a reload and are per-browser
|
||||
* by nature. What is deliberately NOT here is the layout — which tab holds which panes, and which pane
|
||||
* is pointed where. A tab holding one conversation from the laptop and one from alpha belongs to
|
||||
* neither server, so it is stored unscoped.
|
||||
*
|
||||
* ## THIS ORIGIN IS NOT IN THE LIST
|
||||
*
|
||||
* The server that served this page is always reachable and already authenticated by the session you
|
||||
* signed in with. It is represented by `null`/absent — every existing `useClient()` call in the app
|
||||
* passes no server and keeps working exactly as before. Adding a connection can therefore never break
|
||||
* the app you are already using, which is the property that makes this safe to ship.
|
||||
*
|
||||
* ## The credential is an API key, not a password
|
||||
*
|
||||
* A second server is reached with an `ofk_…` key minted there (`POST /api/api-keys`). It carries the
|
||||
* owner's full authority and does not expire, so it is a real secret sitting in `localStorage` — the
|
||||
* same trade the mobile apps already make with their per-server tokens, on a device you control.
|
||||
*/
|
||||
|
||||
export type Connection = {
|
||||
/** Derived from the URL, so re-adding the same server updates it rather than duplicating it. */
|
||||
id: string;
|
||||
/** What to call it in the UI. Defaults to the host. */
|
||||
name: string;
|
||||
/** Normalised, no trailing slash, includes the scheme. */
|
||||
baseUrl: string;
|
||||
/** An `ofk_…` API key minted on THAT server. */
|
||||
key: string;
|
||||
};
|
||||
|
||||
const STORE_KEY = 'officer.connections.v1';
|
||||
|
||||
/**
|
||||
* Same derivation as the mobile app: the host, slugged.
|
||||
*
|
||||
* `https://example.com` and `example.com` are the same server and must not appear twice, so the scheme
|
||||
* is only part of the id when it is explicitly insecure — otherwise adding a server by a slightly
|
||||
* different URL silently produces a second entry with its own credential.
|
||||
*/
|
||||
export function connectionIdFor(rawUrl: string): string {
|
||||
const url = normaliseUrl(rawUrl);
|
||||
const { host, protocol } = new URL(url);
|
||||
const slug = host.replace(/[^\w.-]+/g, '-');
|
||||
return protocol === 'http:' ? `http-${slug}` : slug;
|
||||
}
|
||||
|
||||
/** Assume https when no scheme is given — the mobile app's rule, and the safe default over a tailnet. */
|
||||
export function normaliseUrl(raw: string): string {
|
||||
const trimmed = raw.trim().replace(/\/+$/, '');
|
||||
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
return withScheme;
|
||||
}
|
||||
|
||||
// ── Storage, with subscribers so React re-renders on a change ──
|
||||
|
||||
let cache: Connection[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): Connection[] {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : [];
|
||||
cache = Array.isArray(parsed) ? (parsed as Connection[]).filter((c) => c?.id && c?.baseUrl && c?.key) : [];
|
||||
} catch {
|
||||
cache = []; // unreadable or not JSON — an empty list degrades to "this origin only"
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function write(next: Connection[]): void {
|
||||
cache = next;
|
||||
try {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota or private mode — the list still works for this page's lifetime */
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function listConnections(): Connection[] {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function getConnection(serverId: string | null | undefined): Connection | null {
|
||||
if (!serverId) return null;
|
||||
return read().find((c) => c.id === serverId) ?? null;
|
||||
}
|
||||
|
||||
export function upsertConnection(input: { url: string; key: string; name?: string }): Connection {
|
||||
const baseUrl = normaliseUrl(input.url);
|
||||
const id = connectionIdFor(baseUrl);
|
||||
const connection: Connection = {
|
||||
id,
|
||||
name: input.name?.trim() || new URL(baseUrl).host,
|
||||
baseUrl,
|
||||
key: input.key.trim(),
|
||||
};
|
||||
write([...read().filter((c) => c.id !== id), connection]);
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function removeConnection(serverId: string): void {
|
||||
write(read().filter((c) => c.id !== serverId));
|
||||
}
|
||||
|
||||
// `useSyncExternalStore` rather than a context: the store is a module singleton and every panel reads
|
||||
// it independently, so there is no provider to place and no re-render cascade to reason about.
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
export function useConnections(): Connection[] {
|
||||
return useSyncExternalStore(subscribe, listConnections, () => []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label for a server id, for a badge that says which machine a conversation is on.
|
||||
*
|
||||
* `null` means this origin, which is deliberately not in the list — see the module comment.
|
||||
*/
|
||||
export function connectionLabel(serverId: string | null | undefined, fallback = 'This server'): string {
|
||||
return getConnection(serverId)?.name ?? fallback;
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
export { useGlobal } from './useGlobal';
|
||||
export { usePanelChannel } from './usePanelChannel';
|
||||
export { useClient, createClient } from './useClient';
|
||||
export {
|
||||
useConnections,
|
||||
listConnections,
|
||||
getConnection,
|
||||
upsertConnection,
|
||||
removeConnection,
|
||||
connectionLabel,
|
||||
connectionIdFor,
|
||||
normaliseUrl,
|
||||
} from './connections';
|
||||
export type { Connection } from './connections';
|
||||
export { useServerClient, serverClient, chatSocketUrl } from './useServerClient';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useDragAndDrop } from './useDragAndDrop';
|
||||
export { useImageLoader } from './useImageLoader';
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useGlobal } from './useGlobal';
|
||||
let theToken: string | null = null;
|
||||
|
||||
export const createClient = (baseUrl: string = '/api') => {
|
||||
/**
|
||||
* A client bound to one server.
|
||||
*
|
||||
* `baseUrl`/`token` omitted means this origin with the session you signed in with — every existing
|
||||
* caller. Naming both is how a panel talks to ANOTHER Officer without any global being switched, which
|
||||
* is what lets two panes hold two live conversations on two machines (see `connections.ts`).
|
||||
*/
|
||||
export const createClient = (baseUrl: string = '/api', token?: string | null) => {
|
||||
const lsToken =
|
||||
window.officerBearerToken ||
|
||||
document.body.dataset['officerBearerToken'] ||
|
||||
@@ -15,15 +22,15 @@ export const createClient = (baseUrl: string = '/api') => {
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token: theToken,
|
||||
get: <T>(url: string) => get<T>(url, baseUrl),
|
||||
getText: (url: string) => getText(url, baseUrl),
|
||||
getBlob: (url: string) => getBlob(url, baseUrl),
|
||||
token: token ?? theToken,
|
||||
get: <T>(url: string) => get<T>(url, baseUrl, token),
|
||||
getText: (url: string) => getText(url, baseUrl, token),
|
||||
getBlob: (url: string) => getBlob(url, baseUrl, token),
|
||||
// getStream: (url) => getStream(url, baseUrl),
|
||||
post: <T>(url: string, payload?: any) => post<T>(url, payload, baseUrl),
|
||||
put: <T>(url: string, payload?: any) => put<T>(url, payload, baseUrl),
|
||||
patch: <T>(url: string, payload?: any) => patch<T>(url, payload, baseUrl),
|
||||
delete: <T>(url: string, payload?: any) => DELETE<T>(url, payload, baseUrl),
|
||||
post: <T>(url: string, payload?: any) => post<T>(url, payload, baseUrl, token),
|
||||
put: <T>(url: string, payload?: any) => put<T>(url, payload, baseUrl, token),
|
||||
patch: <T>(url: string, payload?: any) => patch<T>(url, payload, baseUrl, token),
|
||||
delete: <T>(url: string, payload?: any) => DELETE<T>(url, payload, baseUrl, token),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,11 +44,15 @@ export const useClient = (baseUrl: string = '/api') => {
|
||||
return { ...client, apiError, setApiError };
|
||||
};
|
||||
|
||||
export const getHeaders = (isText: boolean = false) => {
|
||||
export const getHeaders = (isText: boolean = false, token?: string | null) => {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (theToken) {
|
||||
headers['Authorization'] = `Bearer ${theToken}`;
|
||||
// `token` names a specific server's credential; omitted falls back to this origin's session, which is
|
||||
// every existing caller. See `connections.ts` — a second server is reached by naming it, never by
|
||||
// switching a global, so nothing here changes for the app you are already signed into.
|
||||
const effective = token ?? theToken;
|
||||
if (effective) {
|
||||
headers['Authorization'] = `Bearer ${effective}`;
|
||||
}
|
||||
|
||||
headers['Content-Type'] = isText ? 'text/plain' : 'application/json';
|
||||
@@ -60,8 +71,8 @@ const parseBody = async <T>(res: Response): Promise<T> => {
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
};
|
||||
|
||||
export const getText = async (uri: string, baseUrl = '') => {
|
||||
const headers = getHeaders(true);
|
||||
export const getText = async (uri: string, baseUrl = '', token?: string | null) => {
|
||||
const headers = getHeaders(true, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
@@ -69,16 +80,16 @@ export const getText = async (uri: string, baseUrl = '') => {
|
||||
return text;
|
||||
};
|
||||
|
||||
export const get = async <T>(uri: string, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
export const get = async <T>(uri: string, baseUrl = '', token?: string | null) => {
|
||||
const headers = getHeaders(false, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
return parseBody<T>(res);
|
||||
};
|
||||
|
||||
export const getBlob = async (uri: string, baseUrl = '') => {
|
||||
const headers = { Authorization: `Bearer ${theToken!}` };
|
||||
export const getBlob = async (uri: string, baseUrl = '', token?: string | null) => {
|
||||
const headers = { Authorization: `Bearer ${token ?? theToken!}` };
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
await validateResponse(res);
|
||||
@@ -93,8 +104,8 @@ export const getBlob = async (uri: string, baseUrl = '') => {
|
||||
// return stream;
|
||||
// };
|
||||
|
||||
export const post = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers: Record<string, string> = getHeaders();
|
||||
export const post = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
|
||||
const headers: Record<string, string> = getHeaders(false, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
|
||||
if (payload instanceof FormData) {
|
||||
@@ -109,8 +120,8 @@ export const post = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
return parseBody<T>(res);
|
||||
};
|
||||
|
||||
export const put = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers: any = getHeaders();
|
||||
export const put = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
|
||||
const headers: any = getHeaders(false, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
|
||||
|
||||
@@ -127,8 +138,8 @@ export const put = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
return parseBody<T>(res);
|
||||
};
|
||||
|
||||
export const patch = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
export const patch = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
|
||||
const headers = getHeaders(false, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'PATCH',
|
||||
@@ -139,8 +150,8 @@ export const patch = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
return parseBody<T>(res);
|
||||
};
|
||||
|
||||
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '') => {
|
||||
const headers = getHeaders();
|
||||
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
|
||||
const headers = getHeaders(false, token);
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'delete',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { createClient } from './useClient';
|
||||
import { getConnection } from './connections';
|
||||
|
||||
/**
|
||||
* A client for one named server, or for this origin when nothing is named.
|
||||
*
|
||||
* This is the single point where a `serverId` becomes a URL and a credential, and the two MUST come
|
||||
* from the same place: a key minted on one host is meaningless to another, and presenting it produces a
|
||||
* 401 that reads exactly like an expired session. Deriving both here makes that mismatch unspeakable.
|
||||
*
|
||||
* Deliberately NOT memoised across ids — a cached "current base URL" is the classic bug in this design,
|
||||
* because it hands back whichever server was asked for first and every later pane inherits it. The
|
||||
* mobile app hit this and left a comment about it (`api.ts:86-88`); the cheap fix is to derive per call
|
||||
* and let React memoise on the id.
|
||||
*/
|
||||
export function serverClient(serverId?: string | null) {
|
||||
const connection = getConnection(serverId);
|
||||
// No connection → this origin, `/api`, and the session token the app already holds. That is every
|
||||
// existing call site in the app, unchanged.
|
||||
if (!connection) return createClient('/api');
|
||||
return createClient(`${connection.baseUrl}/api`, connection.key);
|
||||
}
|
||||
|
||||
export function useServerClient(serverId?: string | null) {
|
||||
return useMemo(() => serverClient(serverId), [serverId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebSocket URL for a server, with its credential in the query string.
|
||||
*
|
||||
* A browser WebSocket cannot set headers, so the token rides the query — the same shape the mobile app
|
||||
* uses and the same one `/api/chat/ws` already accepts. `http→ws` by prefix swap, which gets `wss` for
|
||||
* an https server for free.
|
||||
*
|
||||
* Returns null when a named server is unknown, so a caller opens no socket rather than dialling this
|
||||
* origin under another server's name.
|
||||
*/
|
||||
export function chatSocketUrl(serverId: string | null | undefined, sessionToken: string | null): string | null {
|
||||
const connection = getConnection(serverId);
|
||||
|
||||
if (!connection) {
|
||||
if (serverId) return null; // named but missing — say nothing rather than guess
|
||||
const origin = window.location.origin.replace(/^http/, 'ws');
|
||||
return `${origin}/api/chat/ws?token=${encodeURIComponent(sessionToken ?? '')}`;
|
||||
}
|
||||
|
||||
const base = connection.baseUrl.replace(/^http/, 'ws');
|
||||
return `${base}/api/chat/ws?token=${encodeURIComponent(connection.key)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user