diff --git a/plugins/network/src/NetworkApp.vue b/plugins/network/src/NetworkApp.vue index e074538..f2bad90 100644 --- a/plugins/network/src/NetworkApp.vue +++ b/plugins/network/src/NetworkApp.vue @@ -1,36 +1,46 @@ + + diff --git a/plugins/network/src/api/authToken.ts b/plugins/network/src/api/authToken.ts index 46afa66..d2bcf46 100644 --- a/plugins/network/src/api/authToken.ts +++ b/plugins/network/src/api/authToken.ts @@ -1,9 +1,70 @@ // Shared auth token for backend calls. Set by main.ts from the host's -// authContext on mount; read by ApiClient + HadesClient. Replaces the -// () => null stub — Atlas3 provides a Bearer token instead. +// authContext on mount; read by graphqlClient + hadesClient. Replaces the +// sibyl session-cookie assumption — Atlas3 provides a Bearer token instead. +// +// Under a d2e/Atlas host the token is a Logto RS256 access token, which trex +// core's HS256-only auth middleware rejects — requests would run as the +// grant-less `anon` Postgres role. ensureAuthToken() exchanges it via the +// /trex-token function for a trex-native token before it is sent. Tokens that +// already carry aud "authenticated" are trex-native and are sent unchanged. let token: string | null = null; -export function setAuthToken(t: string | null): void { token = t; } -export function getAuthToken(): string | null { return token; } +let trexToken: string | null = null; +let exchange: Promise | null = null; + +export function setAuthToken(t: string | null): void { + token = t; + trexToken = null; + exchange = null; +} + +function jwtPayload(t: string): Record | null { + try { + const b64 = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(b64)) as Record; + } catch { + return null; + } +} + +function expiresSoon(t: string): boolean { + const exp = jwtPayload(t)?.exp; + return typeof exp === 'number' && exp * 1000 < Date.now() + 30_000; +} + +async function exchangeToken(t: string): Promise { + try { + const resp = await fetch(`${location.origin}/trex-token`, { + method: 'POST', + headers: { Authorization: `Bearer ${t}` }, + }); + if (!resp.ok) return null; + const body = await resp.json(); + return typeof body.access_token === 'string' ? body.access_token : null; + } catch { + return null; + } +} + +/** Resolve the token authHeaders() will send; await before any backend request. */ +export async function ensureAuthToken(): Promise { + if (!token) return; + const payload = jwtPayload(token); + if (!payload || payload.aud === 'authenticated') return; + if (trexToken && !expiresSoon(trexToken)) return; + if (!exchange) { + exchange = exchangeToken(token).finally(() => { + exchange = null; + }); + } + const p = exchange; + trexToken = (await p) ?? trexToken; +} + +export function getAuthToken(): string | null { + return trexToken ?? token; +} + export function authHeaders(): Record { - return token ? { Authorization: `Bearer ${token}` } : {}; + const t = trexToken ?? token; + return t ? { Authorization: `Bearer ${t}` } : {}; } diff --git a/plugins/network/src/api/client.ts b/plugins/network/src/api/client.ts index 0c69d3b..597e569 100644 --- a/plugins/network/src/api/client.ts +++ b/plugins/network/src/api/client.ts @@ -5,6 +5,8 @@ export class ApiClientError extends Error { } } +import { ensureAuthToken } from './authToken'; + type Fetch = typeof fetch; export class ApiClient { @@ -18,6 +20,7 @@ export class ApiClient { ) {} private async request(method: string, path: string, body?: unknown): Promise { + await ensureAuthToken(); const headers: Record = {}; const token = this.getToken(); if (token) headers.authorization = `Bearer ${token}`; diff --git a/plugins/network/src/api/hadesClient.ts b/plugins/network/src/api/hadesClient.ts index 4bc7a49..0cbaa79 100644 --- a/plugins/network/src/api/hadesClient.ts +++ b/plugins/network/src/api/hadesClient.ts @@ -1,10 +1,11 @@ import type { HadesJobDetail, RunRequest } from './hadesTypes'; -import { authHeaders } from './authToken'; +import { authHeaders, ensureAuthToken } from './authToken'; export class HadesClient { constructor(private base: string, private fetchImpl: typeof fetch = fetch.bind(globalThis)) {} private async req(path: string, init?: RequestInit): Promise { + await ensureAuthToken(); const resp = await this.fetchImpl(`${this.base}${path}`, { headers: { 'Content-Type': 'application/json', ...authHeaders() }, ...init, diff --git a/plugins/network/src/components/SectionHero.vue b/plugins/network/src/components/SectionHero.vue new file mode 100644 index 0000000..7d74494 --- /dev/null +++ b/plugins/network/src/components/SectionHero.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/plugins/network/src/config.ts b/plugins/network/src/config.ts index a112c48..9266c48 100644 --- a/plugins/network/src/config.ts +++ b/plugins/network/src/config.ts @@ -26,7 +26,7 @@ export function loadConfig(): NetworkConfig { injected.proxyUrl ?? env.VITE_PROXY_URL ?? (typeof location !== 'undefined' - ? `${location.origin}/plugins/network-api/network-api` + ? `${location.origin}/network-api` : ''), }; } diff --git a/plugins/network/src/main.ts b/plugins/network/src/main.ts index 1d26d93..9674e44 100644 --- a/plugins/network/src/main.ts +++ b/plugins/network/src/main.ts @@ -46,6 +46,7 @@ export interface PluginProps { uiFilesUrl?: string; authContext?: unknown; messageBus?: unknown; + section?: 'main' | 'configuration'; } function getSharedDefaults(): Record> { @@ -75,7 +76,7 @@ const vueLifecycles = singleSpaVue({ createApp, appOptions: { render() { - return h(NetworkApp); + return h(NetworkApp, { section: (this as PluginProps).section }); }, }, handleInstance(app) { diff --git a/plugins/notebook-plugin/src/NotebookApp.vue b/plugins/notebook-plugin/src/NotebookApp.vue index 7bdaee2..08b1ee9 100644 --- a/plugins/notebook-plugin/src/NotebookApp.vue +++ b/plugins/notebook-plugin/src/NotebookApp.vue @@ -1,22 +1,23 @@