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.
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
/**
|
|
* Dashboard Plugin SDK + Registry
|
|
*
|
|
* Exposes React, UI components, hooks, and utilities on the window so
|
|
* that plugin bundles can use them without bundling their own copies.
|
|
*
|
|
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
|
|
* to register their tab component.
|
|
*/
|
|
|
|
import React, {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useContext,
|
|
createContext,
|
|
} from "react";
|
|
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";
|
|
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
|
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|
import { Card, CardHeader, CardTitle, CardContent } from "@nous-research/ui/ui/components/card";
|
|
import { Input } from "@nous-research/ui/ui/components/input";
|
|
import { Label } from "@nous-research/ui/ui/components/label";
|
|
import { Separator } from "@nous-research/ui/ui/components/separator";
|
|
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
|
|
import { useI18n } from "@/i18n";
|
|
import { registerSlot, PluginSlot } from "./slots";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin registry — plugins call register() to add their component.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type RegistryListener = () => void;
|
|
|
|
const _registered: Map<string, React.ComponentType> = new Map();
|
|
const _loadErrors: Map<string, string> = new Map();
|
|
const _listeners: Set<RegistryListener> = new Set();
|
|
|
|
function _notify() {
|
|
for (const fn of _listeners) {
|
|
try { fn(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
|
|
export function notifyPluginRegistry() {
|
|
_notify();
|
|
}
|
|
|
|
/** Register a plugin component. Called by plugin JS bundles. */
|
|
function registerPlugin(name: string, component: React.ComponentType) {
|
|
_loadErrors.delete(name);
|
|
_registered.set(name, component);
|
|
_notify();
|
|
}
|
|
|
|
/** Get a registered component by plugin name. */
|
|
export function getPluginComponent(name: string): React.ComponentType | undefined {
|
|
return _registered.get(name);
|
|
}
|
|
|
|
export function getPluginLoadError(name: string): string | undefined {
|
|
return _loadErrors.get(name);
|
|
}
|
|
|
|
export function setPluginLoadError(name: string, message: string) {
|
|
_loadErrors.set(name, message);
|
|
_notify();
|
|
}
|
|
|
|
/** Subscribe to registry changes (returns unsubscribe fn). */
|
|
export function onPluginRegistered(fn: RegistryListener): () => void {
|
|
_listeners.add(fn);
|
|
return () => _listeners.delete(fn);
|
|
}
|
|
|
|
/** Get current count of registered plugins. */
|
|
export function getRegisteredCount(): number {
|
|
return _registered.size;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Expose SDK + registry on window
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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__ = {
|
|
register: registerPlugin,
|
|
registerSlot,
|
|
};
|
|
|
|
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: {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useContext,
|
|
createContext,
|
|
},
|
|
|
|
// Hermes API client
|
|
api,
|
|
// 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: {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Input,
|
|
Label,
|
|
Select,
|
|
SelectOption,
|
|
Separator,
|
|
Tabs,
|
|
TabsList,
|
|
TabsTrigger,
|
|
PluginSlot,
|
|
},
|
|
|
|
// Utilities
|
|
utils: { cn, timeAgo, isoTimeAgo },
|
|
|
|
// Hooks
|
|
useI18n,
|
|
};
|
|
}
|