|
| 1 | +import { logger } from "@trigger.dev/sdk"; |
| 2 | + |
| 3 | +/** |
| 4 | + * The agent's HTTP surface: the delegated-token GET, the env-JWT exchange and its |
| 5 | + * turn-scoped cache, and the query POST both `run_query` and chart validation use. |
| 6 | + */ |
| 7 | + |
| 8 | +export type FetchResult = { ok: true; data: unknown } | { ok: false; status: number }; |
| 9 | + |
| 10 | +// "query" is the server rejecting the TRQL, "transport" is the request breaking. Chart |
| 11 | +// validation only fails a render on "query". |
| 12 | +export type QueryPostResult = |
| 13 | + | { ok: true; rows: Array<Record<string, unknown>> } |
| 14 | + | { ok: false; kind: "query" | "transport"; error: string }; |
| 15 | + |
| 16 | +export const NO_AUTH = { error: "No delegated access is available for this turn." } as const; |
| 17 | + |
| 18 | +export async function apiGet(origin: string, path: string, token: string): Promise<FetchResult> { |
| 19 | + const res = await fetch(`${origin}${path}`, { |
| 20 | + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, |
| 21 | + }); |
| 22 | + if (!res.ok) return { ok: false, status: res.status }; |
| 23 | + return { ok: true, data: await res.json() }; |
| 24 | +} |
| 25 | + |
| 26 | +// The exchange ceilings these scopes to the delegated token's read-only cap, so the |
| 27 | +// JWT can never widen the grant. Null when there is no current env, or on a denial. |
| 28 | +async function exchangeEnvJwt( |
| 29 | + origin: string, |
| 30 | + userActorToken: string, |
| 31 | + projectRef: string, |
| 32 | + environmentName: string |
| 33 | +): Promise<string | null> { |
| 34 | + const res = await fetch(`${origin}/api/v1/projects/${projectRef}/${environmentName}/jwt`, { |
| 35 | + method: "POST", |
| 36 | + headers: { Authorization: `Bearer ${userActorToken}`, "Content-Type": "application/json" }, |
| 37 | + body: JSON.stringify({ |
| 38 | + claims: { scopes: ["read:runs", "read:deployments", "read:errors", "read:query"] }, |
| 39 | + }), |
| 40 | + }); |
| 41 | + if (!res.ok) return null; |
| 42 | + const data = (await res.json()) as { token?: string }; |
| 43 | + return data.token ?? null; |
| 44 | +} |
| 45 | + |
| 46 | +export type DashboardAgentApiClient = { |
| 47 | + /** The API origin with any trailing slash removed. Empty when none was injected. */ |
| 48 | + origin: string; |
| 49 | + /** Whether this turn has both a delegated token and an origin to spend it on. */ |
| 50 | + hasAuth: boolean; |
| 51 | + /** A GET as the environment JWT. `null` means there is no current environment. */ |
| 52 | + envApiGet(path: string): Promise<FetchResult | null>; |
| 53 | + postQuery(query: string, period: string | undefined): Promise<QueryPostResult | null>; |
| 54 | + validateChartQuery(query: string, period: string | undefined): Promise<string | null>; |
| 55 | +}; |
| 56 | + |
| 57 | +export type ApiClientContext = { |
| 58 | + userActorToken?: string; |
| 59 | + apiOrigin?: string; |
| 60 | + projectRef?: string; |
| 61 | + environmentName?: string; |
| 62 | +}; |
| 63 | + |
| 64 | +export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient { |
| 65 | + const { userActorToken, apiOrigin, projectRef, environmentName } = ctx; |
| 66 | + const origin = apiOrigin ? apiOrigin.replace(/\/$/, "") : ""; |
| 67 | + const hasAuth = Boolean(userActorToken && origin); |
| 68 | + |
| 69 | + // Turn-scoped, since the tool set is rebuilt per turn, and keyed by project + |
| 70 | + // environment. Caching the promise makes concurrent calls share one exchange. |
| 71 | + const envJwts = new Map<string, Promise<string | null>>(); |
| 72 | + function getEnvJwt(refresh = false): Promise<string | null> { |
| 73 | + if (!hasAuth || !projectRef || !environmentName) return Promise.resolve(null); |
| 74 | + const key = `${projectRef}/${environmentName}`; |
| 75 | + if (refresh) envJwts.delete(key); |
| 76 | + let pending = envJwts.get(key); |
| 77 | + if (!pending) { |
| 78 | + pending = exchangeEnvJwt(origin, userActorToken!, projectRef, environmentName); |
| 79 | + envJwts.set(key, pending); |
| 80 | + } |
| 81 | + return pending; |
| 82 | + } |
| 83 | + |
| 84 | + /** |
| 85 | + * `null` means there is no current environment. On an unauthorized result the cache |
| 86 | + * entry is dropped and the call is retried once, since a token can be minted stale. |
| 87 | + */ |
| 88 | + async function withEnvJwt<T>( |
| 89 | + call: (jwt: string) => Promise<T>, |
| 90 | + isUnauthorized: (result: T) => boolean |
| 91 | + ): Promise<T | null> { |
| 92 | + const jwt = await getEnvJwt(); |
| 93 | + if (!jwt) return null; |
| 94 | + const first = await call(jwt); |
| 95 | + if (!isUnauthorized(first)) return first; |
| 96 | + const fresh = await getEnvJwt(true); |
| 97 | + if (!fresh) return first; |
| 98 | + return call(fresh); |
| 99 | + } |
| 100 | + |
| 101 | + const unauthorizedGet = (result: FetchResult) => !result.ok && result.status === 401; |
| 102 | + |
| 103 | + function envApiGet(path: string): Promise<FetchResult | null> { |
| 104 | + return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet); |
| 105 | + } |
| 106 | + |
| 107 | + // A POST, so it can't use envApiGet, but keeps the same JWT cache and one-shot |
| 108 | + // re-exchange on a 401. Shared by run_query and chart-block validation. |
| 109 | + async function postQuery( |
| 110 | + query: string, |
| 111 | + period: string | undefined |
| 112 | + ): Promise<QueryPostResult | null> { |
| 113 | + const attempt = await withEnvJwt<{ res: Response } | { error: string }>( |
| 114 | + async (jwt) => { |
| 115 | + try { |
| 116 | + return { |
| 117 | + res: await fetch(`${origin}/api/v1/query`, { |
| 118 | + method: "POST", |
| 119 | + headers: { |
| 120 | + Authorization: `Bearer ${jwt}`, |
| 121 | + "Content-Type": "application/json", |
| 122 | + Accept: "application/json", |
| 123 | + }, |
| 124 | + body: JSON.stringify({ query, scope: "environment", period, format: "json" }), |
| 125 | + }), |
| 126 | + }; |
| 127 | + } catch (error) { |
| 128 | + return { error: `Query request failed: ${(error as Error).message}` }; |
| 129 | + } |
| 130 | + }, |
| 131 | + (result) => "res" in result && result.res.status === 401 |
| 132 | + ); |
| 133 | + if (!attempt) return null; |
| 134 | + if ("error" in attempt) return { ok: false, kind: "transport", error: attempt.error }; |
| 135 | + const res = attempt.res; |
| 136 | + // The route returns 400 with { error } for invalid TRQL. |
| 137 | + const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string }; |
| 138 | + if (!res.ok) { |
| 139 | + return { |
| 140 | + ok: false, |
| 141 | + kind: res.status >= 500 ? "transport" : "query", |
| 142 | + error: data.error ?? `Query failed (status ${res.status}).`, |
| 143 | + }; |
| 144 | + } |
| 145 | + return { |
| 146 | + ok: true, |
| 147 | + rows: Array.isArray(data.results) ? (data.results as Array<Record<string, unknown>>) : [], |
| 148 | + }; |
| 149 | + } |
| 150 | + |
| 151 | + // Skipped rather than blocking the render when there is no token or the request broke. |
| 152 | + async function validateChartQuery( |
| 153 | + query: string, |
| 154 | + period: string | undefined |
| 155 | + ): Promise<string | null> { |
| 156 | + const result = await postQuery(query, period); |
| 157 | + if (!result || result.ok) return null; |
| 158 | + if (result.kind === "transport") { |
| 159 | + logger.warn("Skipped chart query validation", { error: result.error }); |
| 160 | + return null; |
| 161 | + } |
| 162 | + return result.error; |
| 163 | + } |
| 164 | + |
| 165 | + return { origin, hasAuth, envApiGet, postQuery, validateChartQuery }; |
| 166 | +} |
0 commit comments