@basis-theory-labs/cli 4.2.0 → 4.3.0

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.
@@ -0,0 +1,571 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveEventsOrigin = exports.readEventPage = exports.EventsReadError = void 0;
4
+ const node_sdk_1 = require("@basis-theory/node-sdk");
5
+ const PAGE_SIZE = 20;
6
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
7
+ const MAX_CURSOR_LENGTH = 12000;
8
+ const MAX_TIMER_DELAY = 2147483647;
9
+ class EventsReadError extends Error {
10
+ constructor(message, options = {}) {
11
+ var _a;
12
+ super(message);
13
+ this.name = 'EventsReadError';
14
+ this.retryable = (_a = options.retryable) !== null && _a !== void 0 ? _a : false;
15
+ this.retryAfterMs = options.retryAfterMs;
16
+ }
17
+ }
18
+ exports.EventsReadError = EventsReadError;
19
+ /** Resolve and validate the API origin before any credential is sent. */
20
+ const resolveEventsOrigin = (apiBaseUrl) => {
21
+ if (apiBaseUrl === undefined) {
22
+ return node_sdk_1.BasisTheoryEnvironment.Default;
23
+ }
24
+ if (typeof apiBaseUrl !== 'string' ||
25
+ apiBaseUrl.length === 0 ||
26
+ apiBaseUrl.trim() !== apiBaseUrl ||
27
+ // eslint-disable-next-line no-control-regex -- Reject control characters in an untrusted configured URL.
28
+ /[\u0000-\u0020#?\u007F]/u.test(apiBaseUrl) ||
29
+ !/^https?:\/\/[^/]+\/?$/iu.test(apiBaseUrl)) {
30
+ throw new Error('Events API URL must be a whitespace-free root origin.');
31
+ }
32
+ let url;
33
+ try {
34
+ url = new URL(apiBaseUrl);
35
+ }
36
+ catch {
37
+ throw new Error('Events API URL must be a valid root origin.');
38
+ }
39
+ const authorityStart = apiBaseUrl.indexOf('://') + 3;
40
+ const authorityEnd = apiBaseUrl.indexOf('/', authorityStart);
41
+ const authority = apiBaseUrl.slice(authorityStart, authorityEnd === -1 ? undefined : authorityEnd);
42
+ const loopbackHosts = {
43
+ localhost: true,
44
+ '127.0.0.1': true,
45
+ '[::1]': true,
46
+ '::1': true,
47
+ };
48
+ const isSecure = url.protocol === 'https:';
49
+ const isLoopbackHttp = url.protocol === 'http:' &&
50
+ loopbackHosts[url.hostname.toLowerCase()] === true;
51
+ if ((!isSecure && !isLoopbackHttp) ||
52
+ authority.includes('@') ||
53
+ url.username.length > 0 ||
54
+ url.password.length > 0 ||
55
+ url.pathname !== '/' ||
56
+ url.search.length > 0 ||
57
+ url.hash.length > 0) {
58
+ throw new Error('Events API URL must be HTTPS, or loopback HTTP, with no credentials, path, query, or fragment.');
59
+ }
60
+ return url.origin;
61
+ };
62
+ exports.resolveEventsOrigin = resolveEventsOrigin;
63
+ const isObject = (value) => typeof value === 'object' &&
64
+ // eslint-disable-next-line unicorn/no-null -- JSON object detection requires a null check.
65
+ value !== null &&
66
+ !Array.isArray(value);
67
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
68
+ const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
69
+ const invalidPage = () => {
70
+ throw new EventsReadError('Events returned an invalid response page.');
71
+ };
72
+ const validTimestamp = (value) => {
73
+ if (typeof value !== 'string') {
74
+ return false;
75
+ }
76
+ const match =
77
+ // eslint-disable-next-line security/detect-unsafe-regex -- Anchored fixed-width ISO timestamp components cannot cause catastrophic backtracking.
78
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u.exec(value);
79
+ if (!match) {
80
+ return false;
81
+ }
82
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match;
83
+ const year = Number(yearText);
84
+ const month = Number(monthText);
85
+ const day = Number(dayText);
86
+ const hour = Number(hourText);
87
+ const minute = Number(minuteText);
88
+ const second = Number(secondText);
89
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
90
+ const daysInMonth = [
91
+ 31,
92
+ leapYear ? 29 : 28,
93
+ 31,
94
+ 30,
95
+ 31,
96
+ 30,
97
+ 31,
98
+ 31,
99
+ 30,
100
+ 31,
101
+ 30,
102
+ 31,
103
+ ];
104
+ return (year > 0 &&
105
+ month >= 1 &&
106
+ month <= 12 &&
107
+ day >= 1 &&
108
+ day <= daysInMonth[month - 1] &&
109
+ hour <= 23 &&
110
+ minute <= 59 &&
111
+ second <= 59 &&
112
+ Number.isFinite(Date.parse(value)));
113
+ };
114
+ const nonnegativeSafeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
115
+ const validateOptionalString = (object, key) => {
116
+ if (hasOwn(object, key) && typeof object[key] !== 'string') {
117
+ invalidPage();
118
+ }
119
+ };
120
+ const validateRecord = (input, context, sequenceStart, sequenceEnd) => {
121
+ if (!isObject(input)) {
122
+ return invalidPage();
123
+ }
124
+ const sequence = input.sequence;
125
+ if (!validTimestamp(input.occurred_at) ||
126
+ !nonnegativeSafeInteger(sequence) ||
127
+ sequence < sequenceStart ||
128
+ sequence > sequenceEnd ||
129
+ (input.source !== 'application' && input.source !== 'platform') ||
130
+ (input.record_type !== 'log' && input.record_type !== 'lifecycle') ||
131
+ (input.source === 'application' && input.record_type !== 'log') ||
132
+ (input.source === 'platform' && input.record_type !== 'lifecycle')) {
133
+ return invalidPage();
134
+ }
135
+ validateOptionalString(input, 'event');
136
+ validateOptionalString(input, 'level');
137
+ validateOptionalString(input, 'outcome');
138
+ if (hasOwn(input, 'message') &&
139
+ // eslint-disable-next-line unicorn/no-null -- A supplied JSON null message is a supported public value.
140
+ input.message !== null &&
141
+ typeof input.message !== 'string') {
142
+ return invalidPage();
143
+ }
144
+ if (hasOwn(input, 'attributes') && !isObject(input.attributes)) {
145
+ return invalidPage();
146
+ }
147
+ let projectedError;
148
+ if (hasOwn(input, 'error')) {
149
+ if (!isObject(input.error)) {
150
+ return invalidPage();
151
+ }
152
+ if (typeof input.error.type !== 'string' ||
153
+ typeof input.error.message !== 'string' ||
154
+ (hasOwn(input.error, 'stack_trace') &&
155
+ typeof input.error.stack_trace !== 'string')) {
156
+ return invalidPage();
157
+ }
158
+ projectedError = {
159
+ type: input.error.type,
160
+ message: input.error.message,
161
+ ...(hasOwn(input.error, 'stack_trace')
162
+ ? { stack_trace: input.error.stack_trace }
163
+ : {}),
164
+ };
165
+ }
166
+ if (hasOwn(input, 'duration_ms') &&
167
+ (typeof input.duration_ms !== 'number' ||
168
+ !Number.isFinite(input.duration_ms))) {
169
+ return invalidPage();
170
+ }
171
+ return {
172
+ event_id: context.eventId,
173
+ event_type: context.eventType,
174
+ event_timestamp: context.eventTimestamp,
175
+ tenant_id: context.tenantId,
176
+ ...(context.traceId !== undefined ? { trace_id: context.traceId } : {}),
177
+ resource_type: context.resourceType,
178
+ resource_id: context.resourceId,
179
+ invocation_id: context.invocationId,
180
+ ...(context.transformType !== undefined
181
+ ? { transform_type: context.transformType }
182
+ : {}),
183
+ occurred_at: input.occurred_at,
184
+ sequence,
185
+ source: input.source,
186
+ record_type: input.record_type,
187
+ ...(hasOwn(input, 'level') ? { level: input.level } : {}),
188
+ ...(hasOwn(input, 'message')
189
+ ? { message: input.message }
190
+ : {}),
191
+ ...(hasOwn(input, 'attributes')
192
+ ? { attributes: input.attributes }
193
+ : {}),
194
+ ...(projectedError !== undefined ? { error: projectedError } : {}),
195
+ ...(hasOwn(input, 'event') ? { event: input.event } : {}),
196
+ ...(hasOwn(input, 'outcome') ? { outcome: input.outcome } : {}),
197
+ ...(hasOwn(input, 'duration_ms')
198
+ ? { duration_ms: input.duration_ms }
199
+ : {}),
200
+ };
201
+ };
202
+ const projectBatch = (input, options) => {
203
+ if (!isObject(input)) {
204
+ return invalidPage();
205
+ }
206
+ const eventId = input.id;
207
+ const eventType = options.resourceType === 'reactor' ? 'reactor.log' : 'proxy.log';
208
+ const eventTimestamp = input.timestamp;
209
+ const tenantId = input.tenantId;
210
+ const data = input.data;
211
+ if (!isNonEmptyString(eventId) ||
212
+ input.type !== eventType ||
213
+ !validTimestamp(eventTimestamp) ||
214
+ Date.parse(eventTimestamp) < options.start ||
215
+ Date.parse(eventTimestamp) >= options.end ||
216
+ !isNonEmptyString(tenantId) ||
217
+ !isObject(data) ||
218
+ !isNonEmptyString(data.invocation_id)) {
219
+ return invalidPage();
220
+ }
221
+ const resource = data[options.resourceType];
222
+ if (!isObject(resource) ||
223
+ !isNonEmptyString(resource.id) ||
224
+ resource.id !== options.resourceId) {
225
+ return invalidPage();
226
+ }
227
+ if (input.traceId !== undefined && typeof input.traceId !== 'string') {
228
+ return invalidPage();
229
+ }
230
+ let transformType;
231
+ if (hasOwn(data, 'transform_type')) {
232
+ if (typeof data.transform_type !== 'string') {
233
+ return invalidPage();
234
+ }
235
+ if (options.resourceType === 'proxy') {
236
+ if (data.transform_type !== 'request_transform' &&
237
+ data.transform_type !== 'response_transform') {
238
+ return invalidPage();
239
+ }
240
+ transformType = data.transform_type;
241
+ }
242
+ }
243
+ else if (options.resourceType === 'proxy') {
244
+ return invalidPage();
245
+ }
246
+ if (!nonnegativeSafeInteger(data.sequence_start) ||
247
+ !nonnegativeSafeInteger(data.sequence_end) ||
248
+ data.sequence_start > data.sequence_end ||
249
+ !Array.isArray(data.records) ||
250
+ data.records.length === 0) {
251
+ return invalidPage();
252
+ }
253
+ const records = [];
254
+ let previousSequence = -1;
255
+ const context = {
256
+ eventId,
257
+ eventType,
258
+ eventTimestamp,
259
+ tenantId,
260
+ ...(input.traceId !== undefined
261
+ ? { traceId: input.traceId }
262
+ : {}),
263
+ resourceType: options.resourceType,
264
+ resourceId: options.resourceId,
265
+ invocationId: data.invocation_id,
266
+ ...(transformType !== undefined ? { transformType } : {}),
267
+ };
268
+ for (const record of data.records) {
269
+ const projected = validateRecord(record, context, data.sequence_start, data.sequence_end);
270
+ if (projected.sequence <= previousSequence) {
271
+ return invalidPage();
272
+ }
273
+ records.push(projected);
274
+ previousSequence = projected.sequence;
275
+ }
276
+ if (records[0].sequence !== data.sequence_start ||
277
+ records[records.length - 1].sequence !== data.sequence_end) {
278
+ return invalidPage();
279
+ }
280
+ return records;
281
+ };
282
+ const readResponseBody = async (response, signal, onBytes) => {
283
+ if (!response.body) {
284
+ if (signal.aborted) {
285
+ throw new EventsReadError('Events request was interrupted.', {
286
+ retryable: true,
287
+ });
288
+ }
289
+ return new Uint8Array();
290
+ }
291
+ const reader = response.body.getReader();
292
+ const chunks = [];
293
+ let totalBytes = 0;
294
+ let callbackFailed = false;
295
+ let callbackError;
296
+ const cancelReader = () => {
297
+ try {
298
+ // eslint-disable-next-line no-void -- Cancellation is best-effort while the pending read rejects through its signal.
299
+ void reader.cancel().catch(() => undefined);
300
+ }
301
+ catch {
302
+ // The signal or stream may already have closed the reader.
303
+ }
304
+ };
305
+ const onAbort = () => cancelReader();
306
+ signal.addEventListener('abort', onAbort, { once: true });
307
+ try {
308
+ while (true) {
309
+ if (signal.aborted) {
310
+ throw new EventsReadError('Events request was interrupted.', {
311
+ retryable: true,
312
+ });
313
+ }
314
+ // eslint-disable-next-line no-await-in-loop -- Each stream chunk must be consumed before enforcing its byte bound.
315
+ const { done, value } = await reader.read();
316
+ if (signal.aborted) {
317
+ throw new EventsReadError('Events request was interrupted.', {
318
+ retryable: true,
319
+ });
320
+ }
321
+ if (done) {
322
+ break;
323
+ }
324
+ try {
325
+ onBytes(value.byteLength);
326
+ }
327
+ catch (error) {
328
+ callbackFailed = true;
329
+ callbackError = error;
330
+ cancelReader();
331
+ throw error;
332
+ }
333
+ totalBytes += value.byteLength;
334
+ if (totalBytes > MAX_RESPONSE_BYTES) {
335
+ cancelReader();
336
+ throw new EventsReadError('Events response exceeded the 8 MiB size limit.');
337
+ }
338
+ chunks.push(value);
339
+ }
340
+ }
341
+ catch (error) {
342
+ cancelReader();
343
+ if (callbackFailed) {
344
+ if (callbackError instanceof EventsReadError && callbackError.retryable) {
345
+ throw new EventsReadError(callbackError.message);
346
+ }
347
+ throw callbackError;
348
+ }
349
+ if (error instanceof EventsReadError) {
350
+ throw error;
351
+ }
352
+ throw new EventsReadError(signal.aborted
353
+ ? 'Events request was interrupted.'
354
+ : 'Events response could not be read.', { retryable: true });
355
+ }
356
+ finally {
357
+ signal.removeEventListener('abort', onAbort);
358
+ try {
359
+ reader.releaseLock();
360
+ }
361
+ catch {
362
+ // A pending read is already being interrupted by the aborted signal.
363
+ }
364
+ }
365
+ if (totalBytes === 0) {
366
+ return new Uint8Array();
367
+ }
368
+ const body = new Uint8Array(totalBytes);
369
+ let offset = 0;
370
+ for (const chunk of chunks) {
371
+ body.set(chunk, offset);
372
+ offset += chunk.byteLength;
373
+ }
374
+ return body;
375
+ };
376
+ const parseRetryAfter = (value) => {
377
+ // eslint-disable-next-line unicorn/no-null -- Fetch headers represent a missing Retry-After as null.
378
+ if (value === null) {
379
+ return undefined;
380
+ }
381
+ if (/^\d+$/u.test(value)) {
382
+ const delay = Number(value) * 1000;
383
+ return Number.isFinite(delay)
384
+ ? Math.min(delay, MAX_TIMER_DELAY)
385
+ : MAX_TIMER_DELAY;
386
+ }
387
+ const httpDate = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/u;
388
+ if (!httpDate.test(value)) {
389
+ return undefined;
390
+ }
391
+ const date = Date.parse(value);
392
+ if (!Number.isFinite(date) || new Date(date).toUTCString() !== value) {
393
+ return undefined;
394
+ }
395
+ return Math.min(Math.max(0, date - Date.now()), MAX_TIMER_DELAY);
396
+ };
397
+ const httpError = (response) => {
398
+ if (response.status === 429 || response.status === 503) {
399
+ return new EventsReadError(`Events service is temporarily unavailable (HTTP ${response.status}).`, {
400
+ retryable: true,
401
+ retryAfterMs: parseRetryAfter(response.headers.get('retry-after')),
402
+ });
403
+ }
404
+ if (response.status >= 300 && response.status < 400) {
405
+ return new EventsReadError('Events request was redirected; redirects are not followed.');
406
+ }
407
+ if (response.status === 400) {
408
+ return new EventsReadError('Events rejected the request (HTTP 400): malformed query or invalid/mismatched cursor.');
409
+ }
410
+ if (response.status === 401) {
411
+ return new EventsReadError('Events rejected the credentials (HTTP 401): the API key may be invalid or expired.');
412
+ }
413
+ if (response.status === 403) {
414
+ return new EventsReadError('Events denied the request (HTTP 403): the API key needs event:read.');
415
+ }
416
+ return new EventsReadError(`Events request failed (HTTP ${response.status}).`);
417
+ };
418
+ /** Read and validate one Events page. Continuations contain only the opaque cursor. */
419
+ const readEventPage = async (options) => {
420
+ var _a;
421
+ const origin = resolveEventsOrigin(options.origin);
422
+ if (!isNonEmptyString(options.apiKey) ||
423
+ // eslint-disable-next-line no-control-regex -- API keys containing control characters must be rejected.
424
+ /[\u0000-\u001F\u007F]/u.test(options.apiKey) ||
425
+ !isNonEmptyString(options.resourceId) ||
426
+ (options.resourceType !== 'reactor' && options.resourceType !== 'proxy') ||
427
+ !Number.isSafeInteger(options.start) ||
428
+ !Number.isSafeInteger(options.end) ||
429
+ options.start > options.end ||
430
+ !Number.isFinite(new Date(options.start).getTime()) ||
431
+ !Number.isFinite(new Date(options.end).getTime()) ||
432
+ (options.cursor !== undefined &&
433
+ (typeof options.cursor !== 'string' ||
434
+ options.cursor.length === 0 ||
435
+ options.cursor.length > MAX_CURSOR_LENGTH))) {
436
+ throw new Error('Events query options are invalid.');
437
+ }
438
+ let transportError;
439
+ // The SDK owns authentication, query serialization, and the Events envelope.
440
+ // Its transport hook preserves CLI byte bounds, cancellation, and redirect
441
+ // policy without adding SDK retry or timeout loops to the tail's own bounds.
442
+ const bt = new node_sdk_1.BasisTheoryClient({
443
+ apiKey: options.apiKey,
444
+ environment: origin,
445
+ maxRetries: 0,
446
+ logging: { silent: true },
447
+ fetcher: async (request) => {
448
+ const { url, method, headers, queryString, abortSignal } = request;
449
+ try {
450
+ let response;
451
+ const requestHeaders = new Headers({ Accept: 'application/json' });
452
+ // This client supplies only static string headers and an API key.
453
+ for (const [name, value] of Object.entries(headers !== null && headers !== void 0 ? headers : {})) {
454
+ if (typeof value === 'string') {
455
+ requestHeaders.set(name, value);
456
+ }
457
+ }
458
+ try {
459
+ response = await fetch(`${url}?${queryString}`, {
460
+ method,
461
+ headers: requestHeaders,
462
+ redirect: 'manual',
463
+ signal: abortSignal,
464
+ });
465
+ }
466
+ catch {
467
+ throw new EventsReadError('Events request failed due to a transport error.', { retryable: true });
468
+ }
469
+ const body = await readResponseBody(response, options.signal, options.onBytes).catch((error) => {
470
+ // Preserve known HTTP policy when an error body fails to arrive.
471
+ // Non-retryable byte/cycle bounds still take precedence.
472
+ if (!response.ok &&
473
+ error instanceof EventsReadError &&
474
+ error.retryable) {
475
+ throw httpError(response);
476
+ }
477
+ throw error;
478
+ });
479
+ if (!response.ok) {
480
+ throw httpError(response);
481
+ }
482
+ let text;
483
+ let envelope;
484
+ try {
485
+ text = new TextDecoder('utf-8', { fatal: true }).decode(body);
486
+ envelope = JSON.parse(text);
487
+ }
488
+ catch {
489
+ throw new EventsReadError('Events returned malformed JSON.');
490
+ }
491
+ // The SDK represents both null and missing next as undefined. Preserve
492
+ // the wire contract's required continuation field before normalization.
493
+ if (!isObject(envelope) ||
494
+ !isObject(envelope.pagination) ||
495
+ !hasOwn(envelope.pagination, 'next')) {
496
+ return invalidPage();
497
+ }
498
+ return {
499
+ ok: true,
500
+ body: envelope,
501
+ rawResponse: response,
502
+ };
503
+ }
504
+ catch (error) {
505
+ // Distinguish safe transport/limit errors from SDK decoding failures.
506
+ transportError = error;
507
+ throw error;
508
+ }
509
+ },
510
+ });
511
+ let payload;
512
+ try {
513
+ const page = await bt.events.list(options.cursor === undefined
514
+ ? {
515
+ type: options.resourceType === 'reactor' ? 'reactor.log' : 'proxy.log',
516
+ startDate: new Date(options.start),
517
+ endDate: new Date(options.end),
518
+ size: PAGE_SIZE,
519
+ }
520
+ : { start: options.cursor }, {
521
+ abortSignal: options.signal,
522
+ maxRetries: 0,
523
+ queryParams: options.cursor === undefined
524
+ ? { [`data.${options.resourceType}.id`]: options.resourceId }
525
+ : undefined,
526
+ });
527
+ payload = page.response;
528
+ }
529
+ catch {
530
+ if (transportError !== undefined) {
531
+ throw transportError;
532
+ }
533
+ return invalidPage();
534
+ }
535
+ if (!isObject(payload) ||
536
+ !Array.isArray(payload.data) ||
537
+ !isObject(payload.pagination)) {
538
+ return invalidPage();
539
+ }
540
+ const pageSize = payload.pagination.pageSize;
541
+ // eslint-disable-next-line unicorn/no-null -- The CLI uses null for an exhausted cursor.
542
+ const next = (_a = payload.pagination.next) !== null && _a !== void 0 ? _a : null;
543
+ if (!Number.isSafeInteger(pageSize) ||
544
+ pageSize !== payload.data.length ||
545
+ pageSize > PAGE_SIZE ||
546
+ // eslint-disable-next-line unicorn/no-null -- The public pagination contract uses null for no continuation.
547
+ (next !== null &&
548
+ (typeof next !== 'string' ||
549
+ next.length === 0 ||
550
+ next.length > MAX_CURSOR_LENGTH)) ||
551
+ (options.cursor !== undefined && next === options.cursor)) {
552
+ return invalidPage();
553
+ }
554
+ const batches = payload.data.map((batch) => projectBatch(batch, {
555
+ resourceType: options.resourceType,
556
+ resourceId: options.resourceId,
557
+ start: options.start,
558
+ end: options.end,
559
+ }));
560
+ if (options.signal.aborted) {
561
+ throw new EventsReadError('Events request was interrupted.', {
562
+ retryable: true,
563
+ });
564
+ }
565
+ return {
566
+ batches,
567
+ next: next,
568
+ };
569
+ };
570
+ exports.readEventPage = readEventPage;
571
+ /* eslint-enable camelcase */
@@ -0,0 +1,10 @@
1
+ import type { EventLogRecord } from './events';
2
+ type RuntimeLogFormat = 'pretty' | 'json' | 'json-pretty';
3
+ interface FormatRuntimeLogOptions {
4
+ format: RuntimeLogFormat;
5
+ color?: boolean;
6
+ includeResourceHeader?: boolean;
7
+ }
8
+ declare const formatRuntimeLogRecord: (record: EventLogRecord, options: FormatRuntimeLogOptions) => string;
9
+ export type { RuntimeLogFormat };
10
+ export { formatRuntimeLogRecord };