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:
@ -48,22 +48,16 @@
|
||||
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
|
||||
};
|
||||
|
||||
async function api(path, options) {
|
||||
function api(path, options) {
|
||||
// Delegate to the host SDK's fetchJSON so auth is handled correctly in
|
||||
// BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and
|
||||
// gated OAuth (hermes_session_at cookie via credentials:'include').
|
||||
// Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly
|
||||
// 401s in gated mode (the token isn't injected there). fetchJSON throws
|
||||
// Error("<status>: <body>") on non-2xx — the call sites' .catch() relies
|
||||
// on that to surface errors, so we let it propagate (don't swallow).
|
||||
const url = "/api/plugins/hermes-achievements" + path;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = { ...((options && options.headers) || {}) };
|
||||
if (token) headers["X-Hermes-Session-Token"] = token;
|
||||
const res = await fetch(url, { ...(options || {}), headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(function () { return res.statusText; });
|
||||
throw new Error(res.status + ": " + text);
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return SDK.fetchJSON(url, options);
|
||||
}
|
||||
|
||||
function AchievementIcon({ icon }) {
|
||||
|
||||
49
plugins/kanban/dashboard/dist/index.js
vendored
49
plugins/kanban/dashboard/dist/index.js
vendored
@ -588,20 +588,22 @@
|
||||
wsClosedRef.current = false;
|
||||
function openWs() {
|
||||
if (wsClosedRef.current) return;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qsParams = {
|
||||
since: String(cursorRef.current || 0),
|
||||
token: token,
|
||||
};
|
||||
// Build the WS URL via the host SDK so the correct auth param is used
|
||||
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
|
||||
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
|
||||
// path) sends an empty token and is rejected in gated mode. buildWsUrl
|
||||
// also applies the dashboard base-path prefix for reverse-proxied
|
||||
// deployments, which the old inline URL did not. It's async (gated
|
||||
// mode mints a fresh ticket per connect), so resolve then open.
|
||||
const wsParams = { since: String(cursorRef.current || 0) };
|
||||
// Pin the WS stream to the currently-selected board so events
|
||||
// from other boards don't bleed in. Includes "default" so the
|
||||
// dashboard's own board pin always wins over the server-side
|
||||
// ``current`` file — same rationale as ``withBoard()`` above.
|
||||
// Regression: #20879.
|
||||
if (board) qsParams.board = board;
|
||||
const qs = new URLSearchParams(qsParams);
|
||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||
if (board) wsParams.board = board;
|
||||
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
|
||||
if (wsClosedRef.current) return;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
wsRef.current = ws;
|
||||
@ -634,6 +636,14 @@
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
};
|
||||
}).catch(function () {
|
||||
// Ticket mint / URL build failed (e.g. session expired). Back off
|
||||
// and retry; a hard auth failure surfaces via the 1008 close path.
|
||||
if (wsClosedRef.current) return;
|
||||
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
});
|
||||
}
|
||||
openWs();
|
||||
return function () {
|
||||
@ -2837,8 +2847,6 @@
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
@ -2846,7 +2854,11 @@
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent
|
||||
// an empty token and 401'd in gated mode.
|
||||
return SDK.authedFetch(url, { method: "POST", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
@ -3073,15 +3085,16 @@
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
// A plain <a href> can't carry the auth the dashboard middleware requires,
|
||||
// so fetch authenticated and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent an
|
||||
// empty token and 401'd in gated mode.
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
SDK.authedFetch(url)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
|
||||
@ -36,7 +36,6 @@ the port.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@ -63,15 +62,29 @@ router = APIRouter()
|
||||
# existing plugin-bypass; this is documented above).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
"""Constant-time compare against the dashboard session token.
|
||||
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
|
||||
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
|
||||
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).
|
||||
|
||||
Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
|
||||
means this endpoint transparently accepts whatever the core gate accepts
|
||||
in each mode:
|
||||
|
||||
* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
|
||||
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
|
||||
``buildWsUrl`` mints one per connect)
|
||||
* server-internal: the process-lifetime ``?internal=`` credential
|
||||
|
||||
The previous bespoke check only understood ``_SESSION_TOKEN``, so the
|
||||
kanban live-events WS was rejected on every OAuth-gated deployment even
|
||||
though the rest of the dashboard worked. Routing through the shared gate
|
||||
also means this can never drift from core auth again.
|
||||
|
||||
Imported lazily so the plugin still loads in test contexts where the
|
||||
dashboard web_server module isn't importable (e.g. the bare-FastAPI
|
||||
test harness).
|
||||
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
|
||||
test harness); there we accept so the tail loop stays testable, matching
|
||||
the prior behaviour.
|
||||
"""
|
||||
if not provided:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli import web_server as _ws
|
||||
except Exception:
|
||||
@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
# testable; in production the dashboard module always imports
|
||||
# cleanly because it's the caller.
|
||||
return True
|
||||
expected = getattr(_ws, "_SESSION_TOKEN", None)
|
||||
if not expected:
|
||||
return True
|
||||
return hmac.compare_digest(str(provided), str(expected))
|
||||
return bool(_ws._ws_auth_ok(ws))
|
||||
|
||||
|
||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||
@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
|
||||
|
||||
@router.websocket("/events")
|
||||
async def stream_events(ws: WebSocket):
|
||||
# Enforce the dashboard session token as a query param — browsers can't
|
||||
# set Authorization on a WS upgrade. This matches how the PTY bridge
|
||||
# authenticates in hermes_cli/web_server.py.
|
||||
token = ws.query_params.get("token")
|
||||
if not _check_ws_token(token):
|
||||
# Authorize the upgrade via the dashboard's canonical WS gate so the
|
||||
# correct credential is accepted in every mode (loopback token / gated
|
||||
# single-use ticket / server-internal credential). Browsers can't set
|
||||
# Authorization on a WS upgrade, so the credential rides in the query
|
||||
# string — the browser SDK's buildWsUrl() assembles it.
|
||||
if not _ws_upgrade_authorized(ws):
|
||||
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
||||
wrong ?token= query param must be rejected with policy-violation."""
|
||||
"""Loopback mode: a missing or wrong ?token= must be rejected with
|
||||
policy-violation; the correct token is accepted. The kanban WS now
|
||||
delegates to web_server._ws_auth_ok, so we stub that with the real
|
||||
loopback-token semantics (auth_required False → constant-time token
|
||||
compare)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Stub web_server so _check_ws_token has a token to compare against.
|
||||
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
|
||||
# accept only the correct ?token=). Mirrors the real gate's loopback path.
|
||||
import hermes_cli
|
||||
import types
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
return ws.query_params.get("token", "") == "secret-xyz"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
assert ws is not None # handshake succeeded
|
||||
|
||||
|
||||
def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
|
||||
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
|
||||
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
|
||||
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
|
||||
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
|
||||
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
# Gated mode: only a known ticket is accepted; token path rejected.
|
||||
return ws.query_params.get("ticket", "") == "good-ticket"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
c = TestClient(app)
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# Legacy token is rejected in gated mode, even if it's the real one.
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
|
||||
pass
|
||||
assert exc.value.code == 1008
|
||||
|
||||
# A valid ticket is accepted.
|
||||
with c.websocket_connect(
|
||||
"/api/plugins/kanban/events?ticket=good-ticket"
|
||||
) as ws:
|
||||
assert ws is not None
|
||||
|
||||
|
||||
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
|
||||
"""The event stream must honor ``board=default`` even when the global
|
||||
current-board pointer targets a different board.
|
||||
@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Short-circuit the token check — this test is about the cancellation
|
||||
# Short-circuit the auth check — this test is about the cancellation
|
||||
# path, not auth.
|
||||
import plugins.kanban.dashboard.plugin_api as pa
|
||||
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
|
||||
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)
|
||||
|
||||
class _FakeWS:
|
||||
def __init__(self):
|
||||
|
||||
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
||||
|
||||
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
||||
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
||||
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
||||
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
||||
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
||||
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
||||
|
||||
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
||||
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
||||
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
||||
invisible until the dashboard ran gated on hosted Fly agents.
|
||||
|
||||
This test fails if any bundled plugin's frontend reads the token global
|
||||
directly, forcing new/edited plugins through the SDK surface instead. It is
|
||||
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
||||
helpers are the carrot, this test is the stick.
|
||||
|
||||
If you have a legitimate reason to reference the token name (e.g. a comment
|
||||
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Repo root: tests/plugins/<this file> → ../../
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
||||
|
||||
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
||||
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
||||
|
||||
# Files explicitly allowed to mention the token (none today). Map path →
|
||||
# reason so the allowance is self-documenting if one is ever needed.
|
||||
_ALLOWED_FILES: dict[str, str] = {}
|
||||
|
||||
|
||||
def _plugin_frontend_bundles() -> list[Path]:
|
||||
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
||||
if not _PLUGINS_DIR.is_dir():
|
||||
return []
|
||||
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
||||
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
||||
|
||||
|
||||
def test_there_are_plugin_bundles_to_check() -> None:
|
||||
"""Sanity: the glob actually finds the bundles, so a future layout change
|
||||
doesn't silently turn this guard into a no-op."""
|
||||
bundles = _plugin_frontend_bundles()
|
||||
names = {b.parent.parent.parent.name for b in bundles}
|
||||
# kanban + hermes-achievements are bundled today; assert at least one is
|
||||
# found so the guard can't pass vacuously.
|
||||
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
||||
assert names, "could not resolve plugin names from bundle paths"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundle",
|
||||
_plugin_frontend_bundles(),
|
||||
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
||||
)
|
||||
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
||||
rel = str(bundle.relative_to(_REPO_ROOT))
|
||||
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if rel in _ALLOWED_FILES:
|
||||
return # explicitly allowed (with a documented reason)
|
||||
|
||||
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
||||
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
||||
# a code read if it contains the global and the global appears before any
|
||||
# ``//`` comment marker on that line.
|
||||
offending: list[str] = []
|
||||
for i, line in enumerate(text.splitlines(), start=1):
|
||||
idx = line.find(_FORBIDDEN)
|
||||
if idx == -1:
|
||||
continue
|
||||
comment_idx = line.find("//")
|
||||
in_comment = comment_idx != -1 and comment_idx < idx
|
||||
if not in_comment:
|
||||
offending.append(f" {i}: {line.strip()}")
|
||||
|
||||
if not offending:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
||||
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
||||
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
||||
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
||||
)
|
||||
@ -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"),
|
||||
/**
|
||||
|
||||
@ -17,7 +17,7 @@ import React, {
|
||||
useContext,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { api, fetchJSON } from "@/lib/api";
|
||||
import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
@ -88,15 +88,18 @@ export function getRegisteredCount(): number {
|
||||
// Expose SDK + registry on window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__: unknown;
|
||||
__HERMES_PLUGINS__: {
|
||||
register: typeof registerPlugin;
|
||||
registerSlot: typeof registerSlot;
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the
|
||||
* major on any backwards-incompatible change to the exposed surface;
|
||||
* additive changes (new optional fields / helpers) don't require a bump.
|
||||
* Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a
|
||||
* plugin (or a future host-side compatibility gate) can read it.
|
||||
*/
|
||||
export const SDK_CONTRACT_VERSION = "1.1.0";
|
||||
|
||||
// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` —
|
||||
// the single source of truth for the public contract. Don't redeclare them
|
||||
// here (duplicate ambient declarations with differing modifiers conflict).
|
||||
|
||||
export function exposePluginSDK() {
|
||||
window.__HERMES_PLUGINS__ = {
|
||||
@ -105,6 +108,9 @@ export function exposePluginSDK() {
|
||||
};
|
||||
|
||||
window.__HERMES_PLUGIN_SDK__ = {
|
||||
// Contract version of the plugin SDK surface (see plugins/sdk.d.ts).
|
||||
// Bump on backwards-incompatible changes; additive changes don't need it.
|
||||
sdkVersion: SDK_CONTRACT_VERSION,
|
||||
// React core — plugins use these instead of importing react
|
||||
React,
|
||||
hooks: {
|
||||
@ -119,8 +125,19 @@ export function exposePluginSDK() {
|
||||
|
||||
// Hermes API client
|
||||
api,
|
||||
// Raw fetchJSON for plugin-specific endpoints
|
||||
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||
fetchJSON,
|
||||
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||
authedFetch,
|
||||
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||
// plugin WebSocket instead of hand-assembling the URL.
|
||||
buildWsUrl,
|
||||
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||
// plugins that need to build the WS URL themselves.
|
||||
buildWsAuthParam,
|
||||
|
||||
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
||||
components: {
|
||||
|
||||
160
web/src/plugins/sdk.d.ts
vendored
Normal file
160
web/src/plugins/sdk.d.ts
vendored
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Hermes Dashboard Plugin SDK — typed contract (SPIKE)
|
||||
* ====================================================
|
||||
*
|
||||
* This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and
|
||||
* ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to
|
||||
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
|
||||
*
|
||||
* STATUS: spike. This file documents the contract and gives plugin authors
|
||||
* (in-repo IIFEs and external bundles alike) editor types without bundling
|
||||
* their own copies of React / the API client. It is intentionally a
|
||||
* hand-authored ambient declaration rather than ``typeof
|
||||
* window.__HERMES_PLUGIN_SDK__`` because:
|
||||
* 1. The runtime object is assembled from many internal modules
|
||||
* (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would
|
||||
* leak those internal import paths into the public contract and couple
|
||||
* external plugins to the host's internal module layout.
|
||||
* 2. A hand-authored contract is the *versioned API boundary* — changing
|
||||
* it is a deliberate act, visible in review, not an accidental
|
||||
* consequence of refactoring an internal helper.
|
||||
*
|
||||
* Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the
|
||||
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
|
||||
* backwards-incompatible change to this surface. Additive changes
|
||||
* (new optional fields, new helpers) don't require a major bump.
|
||||
*
|
||||
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
|
||||
* - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or
|
||||
* keep in-repo and copy into external plugin repos?
|
||||
* - Should the host assert at runtime that a plugin's declared
|
||||
* ``manifest.sdk_version`` is compatible before executing it?
|
||||
* - The ``components`` map is typed loosely as ``Record<string,
|
||||
* ComponentType>`` here; do we want exact per-component prop types
|
||||
* (pulls @nous-research/ui types into the contract) or is the loose
|
||||
* shape the right boundary for external authors?
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-relevant helpers (the surface this PR adds/sanctions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
|
||||
* modes (loopback session-token header / gated cookie), throws
|
||||
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
|
||||
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
|
||||
*/
|
||||
export type FetchJSON = <T = unknown>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { allowUnauthorized?: boolean },
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
|
||||
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||
*/
|
||||
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||
*/
|
||||
export type BuildWsUrl = (
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
) => Promise<string>;
|
||||
|
||||
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
|
||||
export type BuildWsAuthParam = () => Promise<[string, string]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry surface (window.__HERMES_PLUGINS__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginRegistry {
|
||||
/** Register the plugin's main tab component by manifest name. */
|
||||
register(name: string, component: ComponentType<Record<string, never>>): void;
|
||||
/** Register a component into a named host slot. */
|
||||
registerSlot(slot: string, name: string, component: ComponentType): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK surface (window.__HERMES_PLUGIN_SDK__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HermesPluginSDK {
|
||||
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
|
||||
readonly sdkVersion: string;
|
||||
|
||||
/** React core — use instead of importing/bundling react. */
|
||||
React: typeof import("react").default;
|
||||
hooks: {
|
||||
useState: typeof import("react").useState;
|
||||
useEffect: typeof import("react").useEffect;
|
||||
useCallback: typeof import("react").useCallback;
|
||||
useMemo: typeof import("react").useMemo;
|
||||
useRef: typeof import("react").useRef;
|
||||
useContext: typeof import("react").useContext;
|
||||
createContext: typeof import("react").createContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed convenience client for core dashboard endpoints. Typed permissively
|
||||
* at the boundary (methods vary in arity and return type — most return
|
||||
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
|
||||
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
|
||||
*/
|
||||
api: Record<string, (...args: never[]) => unknown>;
|
||||
|
||||
/** JSON fetch with host auth handling. */
|
||||
fetchJSON: FetchJSON;
|
||||
/** Authenticated raw fetch for uploads / blob downloads. */
|
||||
authedFetch: AuthedFetch;
|
||||
/** Build an auth'd WebSocket URL for the active mode. */
|
||||
buildWsUrl: BuildWsUrl;
|
||||
/** Resolve just the WS auth query-param pair. */
|
||||
buildWsAuthParam: BuildWsAuthParam;
|
||||
|
||||
/**
|
||||
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
|
||||
* boundary: the host's concrete components (some of which require props like
|
||||
* ``active``/``value``/``name``) must be assignable here, and external plugin
|
||||
* authors render them dynamically without the host's internal prop types.
|
||||
* ``ComponentType<never>`` accepts any component regardless of its prop
|
||||
* requirements (props are contravariant).
|
||||
*/
|
||||
components: Record<string, ComponentType<never>>;
|
||||
|
||||
utils: {
|
||||
cn: (...classes: Array<string | false | null | undefined>) => string;
|
||||
/** Relative-time formatter. Accepts an epoch-ms number. */
|
||||
timeAgo: (ts: number) => string;
|
||||
/** Relative-time formatter for an ISO-8601 string. */
|
||||
isoTimeAgo: (iso: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* i18n hook. Returns the host's i18n context value; typed loosely at the
|
||||
* boundary so the contract doesn't couple to the host's internal
|
||||
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
|
||||
*/
|
||||
useI18n: () => unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__?: HermesPluginSDK;
|
||||
__HERMES_PLUGINS__?: PluginRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user