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.
161 lines
6.9 KiB
TypeScript
161 lines
6.9 KiB
TypeScript
/**
|
|
* 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 {};
|