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 outdc6b623and my token-resolution change with it — the latter first, because it was written against useServerClient, whichdc6b623introduced. 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:
@@ -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,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';
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user