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:
Ben
2026-06-03 16:59:36 -07:00
committed by Teknium
parent 1c88360fed
commit a6e47314f9
8 changed files with 500 additions and 94 deletions
+57
View File
@@ -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"),
/**