revert the second server too: one officer, one token, this origin

Andre wants to log out and log back in against a single server, so this takes out dc6b623 and my
token-resolution change with it — the latter first, because it was written against
useServerClient, which dc6b623 introduced.

Gone: the connections store, the server chips, the per-server client and the per-server socket
url. `useClient()` is back to one origin, `/api`, and the session it already holds. The chat
socket url is back to what it was:

  const token = localStorage.getItem('BEARER_TOKEN');
  const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`;

Verified: the staged tree is byte-identical to dc6b623^ across all of src/.

Two things he should know rather than discover.

The old line reads localStorage and nothing else — the same single spelling I widened an hour ago
and have now removed again. If his token is NOT in localStorage, this code fails exactly as
before, and worse: a missing one interpolates as the literal string "null" rather than an empty
value. Reverting cannot fix that class of problem; it restores it.

`officer.connections.v1` stays in his browser's localStorage with alpha's API key in it. Nothing
reads it now, so it is inert, but it is a credential sitting in a store nobody owns any more and
should be cleared by hand.

Typecheck clean. 600 pass, 2 fail — cliamp and pty, unchanged all evening and unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:37:02 +01:00
co-authored by Claude Opus 5
parent 52d567874f
commit 31ffe084f5
10 changed files with 58 additions and 539 deletions
-146
View File
@@ -1,146 +0,0 @@
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 -13
View File
@@ -1,18 +1,6 @@
export { useGlobal } from './useGlobal';
export { usePanelChannel } from './usePanelChannel';
export { useClient, createClient, resolveBearerToken } 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 { useClient, createClient } from './useClient';
export { useDebounce } from './useDebounce';
export { useDragAndDrop } from './useDragAndDrop';
export { useImageLoader } from './useImageLoader';
+34 -57
View File
@@ -1,48 +1,29 @@
import { useGlobal } from './useGlobal';
let theToken: string | null = null;
/**
* 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`).
*/
/**
* The session token for THIS origin, from every place one is allowed to live.
*
* Exported because it must not be re-spelled anywhere. A caller that reads only
* `localStorage.BEARER_TOKEN` — which the chat socket url did — authenticates for HTTP and fails for
* WebSockets the moment the token is held anywhere else: an embedded host setting
* `window.officerBearerToken`, a `?officerToken=` link, or sessionStorage. The app then loads, renders
* and lists history perfectly while the socket is refused with a 1002 and retries forever, which reads
* as a dead server rather than a missing credential.
*/
export const resolveBearerToken = (): string | null =>
window.officerBearerToken ||
document.body.dataset['officerBearerToken'] ||
document.body.dataset['bearerToken'] ||
new URL(window.location.href).searchParams.get('officerToken') ||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
localStorage.getItem('BEARER_TOKEN') ||
sessionStorage.getItem('BEARER_TOKEN');
export const createClient = (baseUrl: string = '/api', token?: string | null) => {
const lsToken = resolveBearerToken();
export const createClient = (baseUrl: string = '/api') => {
const lsToken =
window.officerBearerToken ||
document.body.dataset['officerBearerToken'] ||
document.body.dataset['bearerToken'] ||
new URL(window.location.href).searchParams.get('officerToken') ||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
localStorage.getItem('BEARER_TOKEN') ||
sessionStorage.getItem('BEARER_TOKEN');
theToken = lsToken;
return {
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),
token: theToken,
get: <T>(url: string) => get<T>(url, baseUrl),
getText: (url: string) => getText(url, baseUrl),
getBlob: (url: string) => getBlob(url, baseUrl),
// getStream: (url) => getStream(url, 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),
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),
};
};
@@ -56,15 +37,11 @@ export const useClient = (baseUrl: string = '/api') => {
return { ...client, apiError, setApiError };
};
export const getHeaders = (isText: boolean = false, token?: string | null) => {
export const getHeaders = (isText: boolean = false) => {
const headers: Record<string, string> = {};
// `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}`;
if (theToken) {
headers['Authorization'] = `Bearer ${theToken}`;
}
headers['Content-Type'] = isText ? 'text/plain' : 'application/json';
@@ -83,8 +60,8 @@ const parseBody = async <T>(res: Response): Promise<T> => {
return (text ? JSON.parse(text) : undefined) as T;
};
export const getText = async (uri: string, baseUrl = '', token?: string | null) => {
const headers = getHeaders(true, token);
export const getText = async (uri: string, baseUrl = '') => {
const headers = getHeaders(true);
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers });
await validateResponse(res);
@@ -92,16 +69,16 @@ export const getText = async (uri: string, baseUrl = '', token?: string | null)
return text;
};
export const get = async <T>(uri: string, baseUrl = '', token?: string | null) => {
const headers = getHeaders(false, token);
export const get = async <T>(uri: string, baseUrl = '') => {
const headers = getHeaders();
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 = '', token?: string | null) => {
const headers = { Authorization: `Bearer ${token ?? theToken!}` };
export const getBlob = async (uri: string, baseUrl = '') => {
const headers = { Authorization: `Bearer ${theToken!}` };
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, { headers });
await validateResponse(res);
@@ -116,8 +93,8 @@ export const getBlob = async (uri: string, baseUrl = '', token?: string | null)
// return stream;
// };
export const post = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
const headers: Record<string, string> = getHeaders(false, token);
export const post = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers: Record<string, string> = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
if (payload instanceof FormData) {
@@ -132,8 +109,8 @@ export const post = async <T>(uri: string, payload?: any, baseUrl = '', token?:
return parseBody<T>(res);
};
export const put = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
const headers: any = getHeaders(false, token);
export const put = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers: any = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const body = payload instanceof FormData ? payload : JSON.stringify(payload);
@@ -150,8 +127,8 @@ export const put = async <T>(uri: string, payload?: any, baseUrl = '', token?: s
return parseBody<T>(res);
};
export const patch = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
const headers = getHeaders(false, token);
export const patch = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, {
method: 'PATCH',
@@ -162,8 +139,8 @@ export const patch = async <T>(uri: string, payload?: any, baseUrl = '', token?:
return parseBody<T>(res);
};
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '', token?: string | null) => {
const headers = getHeaders(false, token);
export const DELETE = async <T>(uri: string, payload?: any, baseUrl = '') => {
const headers = getHeaders();
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
const res = await fetch(theUrl, {
method: 'delete',
@@ -1,50 +0,0 @@
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)}`;
}