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-04 09:17:59 +10:00 committed by Teknium
parent 1c88360fed
commit a6e47314f9
8 changed files with 501 additions and 95 deletions

View file

@ -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
View 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 {};