Files
sencho/frontend/src/lib/api.ts
T
Anso 5dea040ec8 fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream

The deploy progress modal streamed compose output over a WebSocket, but
the deploy itself was coupled to that socket in two ways that could break
or silently abort a deploy:

- The deploy request was gated on the progress socket connecting, so any
  upgrade failure (a reverse proxy blocking WebSocket upgrades, or the
  admin-only stream rejecting a scoped deployer) left the modal stuck on
  "Connecting..." and the deploy never fired.
- The backend terminated the running compose process when that socket
  closed, so minimizing the modal, navigating away, or a network blip
  aborted an in-flight deploy.

Make the progress socket output-only: the deploy is owned by its request
and runs to completion (or the existing command timeout) regardless of
the stream. The modal now degrades to a "Live progress unavailable" state
and still reports success or failure from the request result. Connect
failures, drops, and a connect timeout all release the deploy instead of
blocking it.

Also route progress output per deploy: the frontend sends a correlation
id on both the connectTerminal message and the deploy request header, and
the backend keys progress sockets by that id so concurrent deploys from
different tabs or users no longer cross-stream each other's output.

Cap the in-memory parsed log rows so a very long deploy cannot grow the
modal's state unbounded.

* fix(deploy-progress): generate the deploy session id with a CSPRNG

The per-deploy correlation id keys which WebSocket receives a deploy's
live output, so a guessable id lets one authenticated client register a
victim's id and read its compose output. It was built from Math.random()
plus a timestamp, which is not cryptographically secure.

Generate it with crypto.getRandomValues (128 bits, hex). That is the one
Crypto member available in insecure contexts, so it still works over LAN
HTTP where crypto.randomUUID is unavailable.

* fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal

Address review findings on the progress-stream routing:

- Only an id-less connectTerminal registration may become the id-less
  fallback socket. Previously every connectTerminal (including keyed deploy
  modals) set the fallback, so a headerless operation (bulk update, rollback,
  or a legacy client) resolved via getTerminalWs() into another user's keyed
  deploy modal. Keyed sockets are now excluded from the fallback, and a socket
  that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
  unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
  not the full capability value.
2026-05-28 20:51:45 -04:00

111 lines
3.8 KiB
TypeScript

const API_BASE = '/api';
export interface ApiFetchOptions extends RequestInit {
/** When true, omits the x-node-id header so the request always targets
* the local node regardless of which node is currently active in the UI. */
localOnly?: boolean;
}
/** Header carrying a deploy's progress-stream correlation id. Mirrors the
* `sessionId` the frontend sends in the `connectTerminal` WebSocket message so
* the backend streams compose output to the matching socket.
* Must stay in sync with `DEPLOY_SESSION_HEADER` in `backend/src/websocket/generic.ts`. */
export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id';
/** Tag a deploy/update/down POST with its progress-stream session id, preserving
* any caller-supplied options and headers. */
export function withDeploySession(
deploySessionId: string,
options: ApiFetchOptions = {},
): ApiFetchOptions {
return {
...options,
headers: {
...(options.headers as Record<string, string> | undefined),
[DEPLOY_SESSION_HEADER]: deploySessionId,
},
};
}
export async function apiFetch(
endpoint: string,
options: ApiFetchOptions = {}
): Promise<Response> {
const { localOnly, ...fetchOptions } = options;
const url = `${API_BASE}${endpoint}`;
const activeNodeId = localOnly ? null : localStorage.getItem('sencho-active-node');
const defaultOptions: RequestInit = {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(activeNodeId ? { 'x-node-id': activeNodeId } : {}),
...fetchOptions.headers,
},
};
// Drop headers from fetchOptions before the outer spread so the merged
// defaultOptions.headers (with Content-Type and x-node-id) survives. Without
// this, any caller that passes a `headers` field clobbers the defaults.
const { headers: _callerHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions;
void _callerHeaders;
const response = await fetch(url, { ...defaultOptions, ...fetchOptionsWithoutHeaders });
if (response.status === 401) {
// Only fire the global logout event for local auth failures.
// When the response carries x-sencho-proxy, the 401 came from a remote
// Sencho node (expired/invalid api_token) - not from the user's own session.
// Logging out in that case creates an unrecoverable loop.
if (!response.headers.get('x-sencho-proxy')) {
window.dispatchEvent(new Event('sencho-unauthorized'));
}
throw new Error('Unauthorized');
}
// Intercept 404 Node Not Found responses and force context refresh
if (response.status === 404) {
try {
const clone = response.clone();
const errData = await clone.json();
if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) {
window.dispatchEvent(new Event('node-not-found'));
}
} catch {
// Ignore JSON parse errors, caller handles standard 404s
}
}
return response;
}
/** Fetch against a specific node by ID without touching the localStorage active-node key.
* Used by the notification panel to target individual remote nodes explicitly. */
export async function fetchForNode(
endpoint: string,
nodeId: number,
options: RequestInit = {}
): Promise<Response> {
const { headers: extraHeaders, ...rest } = options;
const response = await fetch(`${API_BASE}${endpoint}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'x-node-id': String(nodeId),
...(extraHeaders as Record<string, string> | undefined),
},
...rest,
});
if (response.status === 401) {
// Same logic as apiFetch: only log out for local auth failures.
if (!response.headers.get('x-sencho-proxy')) {
window.dispatchEvent(new Event('sencho-unauthorized'));
}
throw new Error('Unauthorized');
}
return response;
}
export { API_BASE };