@honeyhive/control-plane-sdk 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util.ts ADDED
@@ -0,0 +1,386 @@
1
+ import axios from 'axios';
2
+ import createClient, { type ClientOptions, type Middleware } from 'openapi-fetch';
3
+
4
+ import { SDK_VERSION } from './generated/version.js';
5
+
6
+ // The control plane's public, API-key entrypoint. This is deliberately not the
7
+ // URL the HoneyHive web app talks to: the app authenticates with a session
8
+ // cookie rather than an API key, so its endpoint is not usable by this client.
9
+ // Self-hosted and per-customer deployments override via the `controlPlaneUrl`
10
+ // option or `HH_CONTROL_PLANE_URL`.
11
+ const DEFAULT_CONTROL_PLANE_URL = 'https://api.cp.us.honeyhive.ai';
12
+
13
+ /**
14
+ * Gets an environment variable value, or returns the default value if the
15
+ * environment variable is not set or is the empty string.
16
+ *
17
+ * Empty string is treated the same as unset because `FOO= node script.js`
18
+ * (and `unset FOO; export FOO=`) are common shell patterns for "no value
19
+ * here", and downstream code (`??` chains, URL fallbacks) would otherwise
20
+ * propagate the empty string as if it were a real value.
21
+ *
22
+ * **This is cross-cutting behavior, not specific to URL resolution.** Every
23
+ * caller (currently `HH_CONTROL_PLANE_API_KEY`, `HH_CONTROL_PLANE_URL`,
24
+ * `HH_VERBOSE`) sees the empty-string-as-unset behavior. When adding a new
25
+ * env var via `getEnv('HH_FOO', 'default')`, be aware that `HH_FOO=""`
26
+ * will resolve to `'default'`, not `''`.
27
+ *
28
+ * This function is also isomorphic. If run from a non-Node.js environment,
29
+ * it will return the default value.
30
+ */
31
+ function getEnv(key: string, defaultValue?: string): string | undefined {
32
+ if (typeof process !== 'undefined' && process.env) {
33
+ const v = process.env[key];
34
+ return v === undefined || v === '' ? defaultValue : v;
35
+ }
36
+ return defaultValue;
37
+ }
38
+
39
+ /**
40
+ * The prefix of a fine-grained control-plane API key, whose values have the
41
+ * shape `hh_fgcp_<key id>_<key secret>`. In practice this is the only credential
42
+ * the control plane's API accepts, but the SDK does not enforce that — the
43
+ * prefix's job here is masking: it identifies the values whose key id can be
44
+ * shown, and everything else is redacted wholesale.
45
+ */
46
+ const FGCP_KEY_PREFIX = 'hh_fgcp_';
47
+
48
+ /**
49
+ * The shape of the key id segment of a fine-grained key value: exactly 24
50
+ * alphanumeric characters, with `_` and `-` excluded so that an id can never
51
+ * read as two segments.
52
+ *
53
+ * The length and the alphabet are fixed properties of the key format, so a value
54
+ * whose id segment doesn't match this either wasn't issued by HoneyHive or has
55
+ * been altered in transit — either way it is redacted rather than rendered.
56
+ */
57
+ const FGCP_KEY_ID_PATTERN = /^[A-Za-z0-9]{24}$/;
58
+
59
+ /**
60
+ * Returns a display-safe rendering of an API key for verbose logging.
61
+ *
62
+ * Renders `hh_fgcp_<key id>_******` — the key's id and none of its secret. This
63
+ * is character-for-character the masked form HoneyHive displays for that key, so
64
+ * a verbose log line can be matched directly against a key in your account.
65
+ * Masking from the id rather than from the secret is what makes it unique per
66
+ * key and free of secret material.
67
+ *
68
+ * The id is `_`-free by construction, so it is everything up to the first `_`
69
+ * after the prefix.
70
+ *
71
+ * Anything else collapses to 8 fixed-width asterisks, revealing neither its
72
+ * length nor its content. That covers both a value that isn't a fine-grained key
73
+ * at all — the SDK forwards whatever it is given, so this function must assume
74
+ * it may be handed a coarse-grained HoneyHive key or a credential belonging to
75
+ * some other system entirely — and a fine-grained value whose id segment is
76
+ * truncated or mangled, where the characters after the prefix could be secret
77
+ * material rather than an id.
78
+ *
79
+ * Both guards are load-bearing, and the prefix guard especially: without it the
80
+ * unconditional slice below would chop 8 characters off an arbitrary token and
81
+ * any value whose next 24 characters happened to be alphanumerics followed by
82
+ * `_` would render as `hh_fgcp_<24 chars of that token>_******`, echoing part of
83
+ * a foreign credential under our own prefix. So the output is always either
84
+ * exactly the server's mask or fully redacted, never a partial echo.
85
+ */
86
+ function maskApiKey(apiKey: string): string {
87
+ if (!apiKey.startsWith(FGCP_KEY_PREFIX)) {
88
+ return '********';
89
+ }
90
+ const rest = apiKey.slice(FGCP_KEY_PREFIX.length);
91
+ const separator = rest.indexOf('_');
92
+ // An empty id never matches the pattern, so a value with no separator at all
93
+ // takes the redacted path without a second branch.
94
+ const keyId = separator === -1 ? '' : rest.slice(0, separator);
95
+ if (!FGCP_KEY_ID_PATTERN.test(keyId)) {
96
+ return '********';
97
+ }
98
+ return `${FGCP_KEY_PREFIX}${keyId}_******`;
99
+ }
100
+
101
+ /**
102
+ * Configuration options for the HoneyHive Control Plane client. They extend the
103
+ * options from openapi-fetch, but replace 'baseUrl' with 'controlPlaneUrl' so
104
+ * the name is unambiguous (the data plane SDK uses 'dataPlaneUrl').
105
+ */
106
+ export interface ClientConfig extends Omit<ClientOptions, 'baseUrl' | 'headers'> {
107
+ /**
108
+ * A fine-grained control-plane API key (`hh_fgcp_…`), the only credential the
109
+ * control plane's API accepts in practice. Sent as a bearer token as-is; the
110
+ * control plane is what rejects a key it doesn't accept. Defaults to the
111
+ * `HH_CONTROL_PLANE_API_KEY` environment variable.
112
+ */
113
+ apiKey?: string;
114
+ controlPlaneUrl?: string;
115
+ middleware?: Middleware[];
116
+
117
+ /**
118
+ * When true, logs the resolved control plane URL, a masked API key, and the
119
+ * SDK package + version via `console.error` on client construction (stderr in
120
+ * Node, devtools in the browser). Useful for confirming which environment,
121
+ * credential, and SDK build the client is configured with. Defaults to
122
+ * true when the `HH_VERBOSE` environment variable is set to `'true'`
123
+ * (case-insensitive).
124
+ */
125
+ verbose?: boolean;
126
+
127
+ /**
128
+ * @internal HoneyHive use only. Overrides the default SDK provenance headers
129
+ * with custom values (e.g. for the CLI or frontend).
130
+ */
131
+ _internal_provenance?: {
132
+ // Constrain the type to known values to further discourage
133
+ // customers from setting something arbitrary
134
+ package: 'cp-frontend' | '@honeyhive/cli';
135
+ version: string;
136
+ };
137
+
138
+ // Technically speaking headers can be more complicated than this (e.g.
139
+ // arrays), but to keep the implementation simple we constrain headers to how
140
+ // most people use them anyways
141
+ headers?: Record<string, string>;
142
+ }
143
+
144
+ /**
145
+ * Custom query serializer that delegates to axios.
146
+ *
147
+ * openapi-fetch defaults to "explode" style (`key=a&key=b`), which the HoneyHive
148
+ * API reads as a plain string rather than a one-element array. Axios uses bracket
149
+ * notation (`key[]=a&key[]=b`) and handles nested objects and arrays
150
+ * recursively, which the API parses correctly.
151
+ */
152
+ function querySerializer(queryParams: Record<string, unknown>): string {
153
+ const uri = axios.getUri({ url: '', params: queryParams });
154
+ return uri.startsWith('?') ? uri.slice(1) : uri;
155
+ }
156
+
157
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- needs to match openapi-fetch's own createClient<Paths extends {}> signature
158
+ export function createApiClient<Paths extends {}>(
159
+ options: ClientConfig,
160
+ ): ReturnType<typeof createClient<Paths>> {
161
+ const { apiKey, controlPlaneUrl, middleware, verbose, _internal_provenance, ...clientOptions } =
162
+ options;
163
+ const resolvedApiKey = apiKey ?? getEnv('HH_CONTROL_PLANE_API_KEY');
164
+
165
+ // Resolution order: option > env var > default. For the option, any
166
+ // non-undefined value wins (so explicit undefined falls back). For the env
167
+ // var, both unset and empty-string fall back, because `getEnv` normalizes
168
+ // empty-string to undefined — see `getEnv` for the rationale. The default
169
+ // targets HoneyHive's managed control plane; self-hosted and per-customer
170
+ // deployments override via the option or `HH_CONTROL_PLANE_URL`.
171
+ const resolvedControlPlaneUrl =
172
+ controlPlaneUrl ?? getEnv('HH_CONTROL_PLANE_URL') ?? DEFAULT_CONTROL_PLANE_URL;
173
+
174
+ const resolvedVerbose = verbose ?? getEnv('HH_VERBOSE')?.toLowerCase() === 'true';
175
+ const provenance = _internal_provenance ?? {
176
+ package: '@honeyhive/control-plane-sdk',
177
+ version: SDK_VERSION,
178
+ };
179
+
180
+ // Log before the missing-key check so verbose users can see what *did*
181
+ // resolve when construction is about to fail.
182
+ if (resolvedVerbose) {
183
+ console.error(`Control plane URL: ${resolvedControlPlaneUrl}`);
184
+ console.error(`API key: ${resolvedApiKey ? maskApiKey(resolvedApiKey) : '(none)'}`);
185
+ console.error(`Package: ${provenance.package} v${provenance.version}`);
186
+ }
187
+
188
+ // When middleware is supplied, it is assumed to handle authentication itself
189
+ // (for example by attaching a short-lived token per request), so no key is
190
+ // required. The URL always resolves (option > env > default), so only the key
191
+ // can be missing here.
192
+ if (!resolvedApiKey && !middleware?.length) {
193
+ throw new Error(
194
+ 'Missing API key: provide apiKey in options or set the HH_CONTROL_PLANE_API_KEY environment variable',
195
+ );
196
+ }
197
+
198
+ const headers: Record<string, string> = {
199
+ 'hh-client-package': provenance.package,
200
+ 'hh-client-version': provenance.version,
201
+ 'hh-client-language': 'typescript',
202
+ };
203
+ if (resolvedApiKey) {
204
+ headers.Authorization = `Bearer ${resolvedApiKey}`;
205
+ }
206
+
207
+ const client = createClient<Paths>({
208
+ ...clientOptions,
209
+ querySerializer,
210
+ // Always set (option > env > default). Middleware, when supplied, may still
211
+ // rewrite the request URL per-request, but the resolved value is the default
212
+ // otherwise.
213
+ baseUrl: resolvedControlPlaneUrl,
214
+ headers: {
215
+ ...headers,
216
+ ...clientOptions.headers,
217
+ },
218
+ });
219
+
220
+ if (middleware?.length) {
221
+ client.use(...middleware);
222
+ }
223
+
224
+ return client;
225
+ }
226
+
227
+ /**
228
+ * Per-request fetch-level options that are orthogonal to the API request
229
+ * payload. These are passed through to the underlying `fetch()` call via
230
+ * openapi-fetch's init spread.
231
+ *
232
+ * Intentionally kept separate from `*Request` types so API-domain interfaces
233
+ * stay serializable and free of DOM/transport concerns.
234
+ */
235
+ export interface FetchOptions {
236
+ /**
237
+ * An `AbortSignal` to cancel the in-flight HTTP request. When the signal
238
+ * fires, the underlying `fetch()` rejects with an `AbortError` wrapped in
239
+ * a `NetworkError`.
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * const controller = new AbortController();
244
+ * setTimeout(() => controller.abort(), 5000);
245
+ * const result = await client.alerts.get(request, { signal: controller.signal });
246
+ * ```
247
+ */
248
+ signal?: AbortSignal;
249
+ }
250
+
251
+ /** Structural match for both branches of openapi-fetch's FetchResponse union. */
252
+ type FetchResult<T = unknown, E = unknown> =
253
+ | { data: T; error?: undefined; response: Response }
254
+ | { data?: undefined; error: E; response: Response };
255
+
256
+ /**
257
+ * HoneyHiveError is a base class for all errors thrown by the HoneyHive control
258
+ * plane client.
259
+ *
260
+ * This error is never thrown directly, but is useful for determining if an
261
+ * error is from the client with `err instanceof HoneyHiveError`
262
+ */
263
+ export class HoneyHiveError extends Error {}
264
+
265
+ /**
266
+ * Type guard that returns the payload as ErrorResponse if it matches the
267
+ * canonical shape, or undefined otherwise.
268
+ */
269
+ function asErrorResponse(e: unknown): ErrorResponse | undefined {
270
+ if (
271
+ typeof e === 'object' &&
272
+ e !== null &&
273
+ 'message' in e &&
274
+ typeof e.message === 'string' &&
275
+ 'statusCode' in e &&
276
+ typeof e.statusCode === 'number' &&
277
+ 'success' in e &&
278
+ typeof e.success === 'boolean' &&
279
+ // `errorCode` is optional here because not every error response carries it
280
+ // yet; it will become required in a future major version.
281
+ ('errorCode' in e ? typeof e.errorCode === 'string' : true)
282
+ ) {
283
+ return e as ErrorResponse;
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ /**
289
+ * An error that is thrown when the API call was not successful
290
+ *
291
+ * @property status - The HTTP status code of the response
292
+ * @property response - The Response object from the fetch call. Call
293
+ * `await err.response.text()` to see details of the error.
294
+ */
295
+ export class ApiError extends HoneyHiveError {
296
+ public readonly status: number;
297
+ public readonly response: Response;
298
+ public readonly error: unknown;
299
+
300
+ constructor(status: number, error: unknown, response: Response) {
301
+ const parsed = asErrorResponse(error);
302
+ const message =
303
+ parsed !== undefined ? `API error ${status}: ${parsed.message}` : `API error ${status}`;
304
+ super(message, { cause: error });
305
+ this.name = 'ApiError';
306
+ this.status = status;
307
+ this.response = response;
308
+ this.error = error;
309
+ }
310
+
311
+ /**
312
+ * Returns the parsed error response body with its known type, or `undefined`
313
+ * if the body doesn't match the expected shape.
314
+ *
315
+ * The HoneyHive API returns `{ statusCode, message, success, errorCode }` as
316
+ * JSON for all error responses. However, failures that happen before a request
317
+ * reaches the API (e.g. an HTML 502 from a load balancer, or a generic 404) can
318
+ * arrive in an unrecognized shape, in which case we return undefined.
319
+ */
320
+ public parseError(): ErrorResponse | undefined {
321
+ return asErrorResponse(this.error);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * The standard error response shape the HoneyHive API returns for all non-2xx
327
+ * responses.
328
+ */
329
+ export interface ErrorResponse {
330
+ statusCode: number;
331
+ message: string;
332
+ success: boolean;
333
+ errorCode: string;
334
+ }
335
+
336
+ /**
337
+ * An error that is thrown when the API call fails at the network level
338
+ * (e.g. DNS failures, timeouts, connection refused)
339
+ */
340
+ export class NetworkError extends HoneyHiveError {
341
+ readonly error: unknown;
342
+
343
+ constructor(error: unknown) {
344
+ super(error instanceof Error ? error.message : String(error), { cause: error });
345
+ this.name = 'NetworkError';
346
+ this.error = error;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Narrows a FetchResult to its success branch while also stripping the
352
+ * `undefined` that openapi-fetch adds to `data` across both union members.
353
+ *
354
+ * By declaring `result is { data: T; … }` (without `| undefined`), TypeScript
355
+ * narrows `data` from `T | undefined` to `T` after the guard — no cast needed.
356
+ */
357
+ function isSuccess<T, E>(
358
+ result: FetchResult<T | undefined, E>,
359
+ ): result is { data: T; error?: undefined; response: Response } {
360
+ return result.error === undefined;
361
+ }
362
+
363
+ /**
364
+ * Unwraps a fetch result, throwing an ApiError if the result contains an error.
365
+ * This enables a more ergonomic way of consuming the results of API calls.
366
+ *
367
+ * The generic accepts `FetchResult<T | undefined, E>` so that `T` itself is
368
+ * inferred without `undefined`. openapi-fetch's union types `data` as
369
+ * `ResponseType | undefined` across both branches; by absorbing the
370
+ * `undefined` in the parameter, the return type is a clean `Promise<T>`.
371
+ */
372
+ export async function unwrap<T, E>(promise: Promise<FetchResult<T | undefined, E>>): Promise<T> {
373
+ let result: FetchResult<T | undefined, E>;
374
+ try {
375
+ result = await promise;
376
+ } catch (error) {
377
+ if (error instanceof HoneyHiveError) {
378
+ throw error;
379
+ }
380
+ throw new NetworkError(error);
381
+ }
382
+ if (!isSuccess(result)) {
383
+ throw new ApiError(result.response.status, result.error, result.response);
384
+ }
385
+ return result.data;
386
+ }