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.