fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)
Dashboard plugins (kanban, hermes-achievements) read window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket URLs with ?token=. That works in loopback/--insecure mode but is rejected on OAuth-gated deployments, where the session token is absent and _ws_auth_ok only accepts single-use ?ticket= auth. The result was 401s on plugin REST calls and 1008/403 on the kanban live-events WS whenever the dashboard ran behind OAuth (e.g. hosted Fly agents). Make the plugin SDK the single sanctioned auth surface: - web/src/lib/api.ts: add authedFetch() (raw Response for FormData uploads / blob downloads, token-or-cookie auth, no throw / no 401 redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct auth param for the active mode — fresh single-use ticket in gated mode, token in loopback). - web/src/plugins/registry.ts: expose authedFetch, buildWsUrl, buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__; add SDK_CONTRACT_VERSION. - web/src/plugins/sdk.d.ts: hand-authored typed contract for the plugin SDK + registry globals (single source of truth for the Window declarations). - plugins/kanban + hermes-achievements dist bundles: stop reading the session token directly; route uploads/downloads through SDK.authedFetch and the live-events WS through SDK.buildWsUrl. - plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the /events WS upgrade to the canonical web_server._ws_auth_ok gate, so it transparently accepts loopback token / gated ticket / internal credential and can never drift from core auth again. - tests: guard test asserting no plugin dist reads __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test. Verified live on a gated staging Fly agent: kanban /events upgrades 101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the old code got 403.
This commit is contained in:
@@ -192,6 +192,63 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
return ["token", token];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated ``fetch`` for dashboard ``/api/...`` requests that aren't
|
||||
* plain JSON — file uploads (``FormData``), binary downloads (blobs), etc.
|
||||
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
|
||||
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
|
||||
*
|
||||
* Auth, in both modes, exactly as ``fetchJSON`` does it:
|
||||
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
|
||||
* - gated OAuth: no token header (it's absent by design); the
|
||||
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
|
||||
*
|
||||
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
|
||||
* non-2xx (the caller decides — a 404 on a download is meaningful), and
|
||||
* does NOT run the global 401 → /login redirect (binary endpoints aren't
|
||||
* navigation targets). Callers that want the redirect behaviour should use
|
||||
* ``fetchJSON``.
|
||||
*/
|
||||
export async function authedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
return fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: init?.credentials ?? "include",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
|
||||
* with the correct auth query param appended for the active mode (fresh
|
||||
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
|
||||
* the SPA should use this instead of hand-assembling a WS URL + reading
|
||||
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
|
||||
* path can never be forgotten.
|
||||
*
|
||||
* ``path`` is the dashboard-relative path (e.g.
|
||||
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
|
||||
* applied here. Extra query params can be supplied via ``params`` and are
|
||||
* merged before the auth param.
|
||||
*/
|
||||
export async function buildWsUrl(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user