@calmlens/sdk 0.5.0 → 0.6.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.
package/bin/calmlens.js DELETED
@@ -1,1323 +0,0 @@
1
- #!/usr/bin/env node
2
- let node_util = require("node:util");
3
- let api_def = require("api-def");
4
- let sendable_error = require("sendable-error");
5
- let node_crypto = require("node:crypto");
6
- //#region ../shared/src/AssetPayload.ts
7
- const uint8ArrayInputToNumbers = (value) => {
8
- if (Array.isArray(value)) return value;
9
- return Array.from(value);
10
- };
11
- const bufferInputToBytes = (value) => {
12
- if (value instanceof ArrayBuffer) return new Uint8Array(value);
13
- return Uint8Array.from(value);
14
- };
15
- const BASE64_PART_BOUNDARY_ERROR = "Invalid base64 part. For multipart uploads, split the original bytes first, then base64-encode each part. Do not split one full base64 string into substrings.";
16
- const MULTIPART_PART_TOO_SMALL_ERROR = `Multipart upload parts must be at least 5MiB except the final part.`;
17
- const getRawBase64 = (value) => {
18
- const markerIndex = value.indexOf(";base64,");
19
- if (markerIndex === -1) return value;
20
- return value.slice(markerIndex + 8);
21
- };
22
- const validateBase64PayloadString = (payload, options) => {
23
- const raw = getRawBase64(payload.base64).replace(/\s/g, "");
24
- if (options?.multipart && raw.length % 4 !== 0) return {
25
- ok: false,
26
- error: BASE64_PART_BOUNDARY_ERROR
27
- };
28
- return { ok: true };
29
- };
30
- const assetToData = (payload) => {
31
- if ("plainText" in payload && payload.plainText !== void 0) return new TextEncoder().encode(payload.plainText);
32
- if ("data" in payload && payload.data) return payload.data;
33
- if ("buffer" in payload) {
34
- if (payload.buffer instanceof ArrayBuffer) return payload.buffer;
35
- return bufferInputToBytes(payload.buffer);
36
- }
37
- if (payload.uint8Array !== void 0) return Uint8Array.from(uint8ArrayInputToNumbers(payload.uint8Array));
38
- if (payload.base64 === void 0) throw new Error("Invalid asset payload");
39
- const raw = getRawBase64(payload.base64);
40
- if (typeof Buffer !== "undefined") return Uint8Array.from(Buffer.from(raw, "base64"));
41
- const binary = atob(raw);
42
- return Uint8Array.from(binary, (char) => char.charCodeAt(0));
43
- };
44
- const getAssetPayloadByteLength = (payload) => {
45
- if ("plainText" in payload && payload.plainText !== void 0) return new TextEncoder().encode(payload.plainText).byteLength;
46
- if ("data" in payload && payload.data) {
47
- if (payload.data instanceof Blob) return payload.data.size;
48
- if (payload.data instanceof Response) {
49
- const contentLength = payload.data.headers.get("content-length");
50
- return contentLength ? Number(contentLength) : void 0;
51
- }
52
- return;
53
- }
54
- if ("buffer" in payload && payload.buffer) return payload.buffer.byteLength;
55
- if (payload.uint8Array !== void 0) {
56
- if (Array.isArray(payload.uint8Array)) return payload.uint8Array.length;
57
- return payload.uint8Array.byteLength;
58
- }
59
- if (payload.base64 === void 0) return;
60
- const raw = getRawBase64(payload.base64).replace(/\s/g, "");
61
- const padding = raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0;
62
- return Math.max(0, Math.floor(raw.length * 3 / 4) - padding);
63
- };
64
- const validateAssetPayload = (payload, options) => {
65
- if ("base64" in payload && payload.base64 !== void 0) {
66
- const validation = validateBase64PayloadString(payload, options);
67
- if (!validation.ok) return {
68
- ok: false,
69
- error: validation.error
70
- };
71
- }
72
- const byteLength = getAssetPayloadByteLength(payload);
73
- if (options?.multipart && !options.isFinal && byteLength !== void 0 && byteLength < 5242880) return {
74
- ok: false,
75
- error: MULTIPART_PART_TOO_SMALL_ERROR
76
- };
77
- return { ok: true };
78
- };
79
- //#endregion
80
- //#region ../shared/src/JsonUtils.ts
81
- const stringifyJsonCanonical = (value) => JSON.stringify(value, (_key, nestedValue) => {
82
- if (!nestedValue || typeof nestedValue !== "object" || Array.isArray(nestedValue)) return nestedValue;
83
- return Object.fromEntries(Object.keys(nestedValue).sort().map((key) => [key, nestedValue[key]]));
84
- });
85
- //#endregion
86
- //#region ../shared/src/PublicErrorCodes.ts
87
- const PUBLIC_ERROR_CODES = {
88
- MISSING_TOKEN: new sendable_error.ErrorCode({
89
- id: "auth/missing-token",
90
- defaultMessage: "Authentication required. Send your project API key in the X-Api-Key header.",
91
- status: 401
92
- }),
93
- INVALID_API_KEY: new sendable_error.ErrorCode({
94
- id: "auth/invalid-api-key",
95
- defaultMessage: "The API key is invalid, revoked or expired. Create a key under Developer → API Keys.",
96
- status: 401
97
- }),
98
- API_KEY_PROJECT_MISMATCH: new sendable_error.ErrorCode({
99
- id: "auth/project-mismatch",
100
- defaultMessage: "This API key belongs to a different project. Use a key created for the project that owns this resource.",
101
- status: 403
102
- }),
103
- API_KEY_NOT_ACCEPTED: new sendable_error.ErrorCode({
104
- id: "auth/api-key-not-accepted",
105
- defaultMessage: "API keys are only accepted by the public API at /v1.",
106
- status: 403
107
- }),
108
- FILE_TOO_LARGE: new sendable_error.ErrorCode({
109
- id: "upload/file-too-large",
110
- defaultMessage: "File size exceeds limit. Maximum direct image size is 10MB, audio/video size is 50MiB, and other files is 5GB.",
111
- status: 400
112
- }),
113
- IMAGE_REQUIRES_MULTIPART_UPLOAD: new sendable_error.ErrorCode({
114
- id: "upload/image-requires-multipart-upload",
115
- defaultMessage: "Images of 10MB or more must use a multipart upload.",
116
- status: 400
117
- }),
118
- MULTIPART_IMAGE_TOO_SMALL: new sendable_error.ErrorCode({
119
- id: "upload/multipart-image-too-small",
120
- defaultMessage: "Multipart image uploads must total at least 10MB.",
121
- status: 400
122
- }),
123
- MULTIPART_IMAGE_TOO_LARGE: new sendable_error.ErrorCode({
124
- id: "upload/multipart-image-too-large",
125
- defaultMessage: "Multipart image uploads cannot exceed 20MB.",
126
- status: 400
127
- }),
128
- IMAGE_EXCEEDED_MAX_DIMENSIONS: new sendable_error.ErrorCode({
129
- id: "upload/image-exceeded-max-dimensions",
130
- defaultMessage: "Image exceeds maximum dimensions. Maximum dimensions are 12,000px.",
131
- status: 400
132
- }),
133
- UPLOAD_LIMIT_EXCEEDED: new sendable_error.ErrorCode({
134
- id: "upload/limit-exceeded",
135
- defaultMessage: "Upload limit exceeded. You have used all your free uploads. Please upgrade to Plus or Pro for unlimited uploads.",
136
- status: 403
137
- }),
138
- INVALID_UPLOAD_MIME_TYPE: new sendable_error.ErrorCode({
139
- id: "upload/invalid-mime-type",
140
- defaultMessage: "Upload file type is not supported. Uploads must use a concrete file MIME type that CalmLens can store and process, such as image/png, image/jpeg, application/pdf, or text/plain.",
141
- status: 400
142
- }),
143
- INVALID_EVENT_CURSOR: new sendable_error.ErrorCode({
144
- id: "events/invalid-cursor",
145
- defaultMessage: "The events cursor is invalid. Use the cursor from a previous response, or omit it to start from now.",
146
- status: 400
147
- }),
148
- INVALID_UPLOAD_PAYLOAD: new sendable_error.ErrorCode({
149
- id: "upload/invalid-payload",
150
- defaultMessage: "Upload payload is invalid.",
151
- status: 400
152
- }),
153
- USE_UPLOAD_PART: new sendable_error.ErrorCode({
154
- id: "upload/use-upload-part",
155
- defaultMessage: "Use uploadPart for multipart upload parts after the first part",
156
- status: 409
157
- }),
158
- UPLOAD_NOT_OPEN: new sendable_error.ErrorCode({
159
- id: "upload/not-open",
160
- defaultMessage: "Asset is not accepting additional parts",
161
- status: 409
162
- }),
163
- INVALID_UPLOAD_PART_NUMBER: new sendable_error.ErrorCode({
164
- id: "upload/invalid-part-number",
165
- defaultMessage: "Multipart upload part number is invalid.",
166
- status: 409
167
- }),
168
- UPLOAD_MISSING: new sendable_error.ErrorCode({
169
- id: "upload/missing",
170
- defaultMessage: "Multipart upload state is missing",
171
- status: 409
172
- }),
173
- ASSET_NOT_FOUND: new sendable_error.ErrorCode({
174
- id: "asset/not-found",
175
- defaultMessage: "Asset not found",
176
- status: 404
177
- }),
178
- PROJECT_NOT_FOUND: new sendable_error.ErrorCode({
179
- id: "project/not-found",
180
- defaultMessage: "Project not found",
181
- status: 404
182
- }),
183
- ASSET_UPDATE_FAILED: new sendable_error.ErrorCode({
184
- id: "asset/update-failed",
185
- defaultMessage: "Failed to update asset",
186
- status: 500
187
- }),
188
- ASSET_SAFETY_HELD: new sendable_error.ErrorCode({
189
- id: "asset/safety-held",
190
- defaultMessage: "Asset access is restricted by a safety hold.",
191
- status: 423
192
- }),
193
- ASSET_NOT_RETRYABLE: new sendable_error.ErrorCode({
194
- id: "asset/not-retryable",
195
- defaultMessage: "Only assets that failed to process (status error) can be retried.",
196
- status: 409
197
- }),
198
- ASSET_CONTENT_UNAVAILABLE: new sendable_error.ErrorCode({
199
- id: "asset/content-unavailable",
200
- defaultMessage: "This asset's content is no longer stored, so it can't be processed again. Upload it again instead.",
201
- status: 409
202
- }),
203
- SUPPORT_TICKET_NOT_FOUND: new sendable_error.ErrorCode({
204
- id: "support/ticket-not-found",
205
- defaultMessage: "Support ticket not found.",
206
- status: 404
207
- }),
208
- DUPLICATE_EXTERNAL_ID: new sendable_error.ErrorCode({
209
- id: "asset/duplicate-external-id",
210
- defaultMessage: "An asset with this externalId already exists in the project.",
211
- status: 409
212
- }),
213
- FOLDER_NOT_FOUND: new sendable_error.ErrorCode({
214
- id: "folder/not-found",
215
- defaultMessage: "Folder not found",
216
- status: 404
217
- }),
218
- DUPLICATE_FOLDER_NAME: new sendable_error.ErrorCode({
219
- id: "folder/duplicate-name",
220
- defaultMessage: "A folder with this name already exists in this location.",
221
- status: 409
222
- }),
223
- FOLDER_NOT_EMPTY: new sendable_error.ErrorCode({
224
- id: "folder/not-empty",
225
- defaultMessage: "Folder must be empty before it can be deleted.",
226
- status: 409
227
- }),
228
- FOLDER_PARENT_CYCLE: new sendable_error.ErrorCode({
229
- id: "folder/parent-cycle",
230
- defaultMessage: "A folder cannot be moved inside itself or one of its descendants.",
231
- status: 409
232
- }),
233
- LENS_VERSION_CONFLICT: new sendable_error.ErrorCode({
234
- id: "lens/version-conflict",
235
- defaultMessage: "Lens updates require a version greater than the current version.",
236
- status: 409
237
- })
238
- };
239
- PUBLIC_ERROR_CODES.MISSING_TOKEN, PUBLIC_ERROR_CODES.INVALID_API_KEY, PUBLIC_ERROR_CODES.API_KEY_PROJECT_MISMATCH;
240
- const MAX_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 20971520)();
241
- const MIN_MULTIPART_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 10485760)();
242
- const REJECTED_UPLOAD_MIME_TYPE_SET = /* @__PURE__ */ new Set([
243
- "application/octet-stream",
244
- "multipart/form-data",
245
- "multipart/mixed",
246
- "multipart/alternative",
247
- "multipart/related",
248
- "multipart/signed",
249
- "multipart/encrypted"
250
- ]);
251
- const normalizeUploadMimeType = (mimeType) => mimeType.toLowerCase().split(";")[0]?.trim() || mimeType.toLowerCase().trim();
252
- const isRejectedUploadMimeType = (mimeType) => REJECTED_UPLOAD_MIME_TYPE_SET.has(normalizeUploadMimeType(mimeType));
253
- const getRejectedUploadMimeTypeError = (mimeType) => `${normalizeUploadMimeType(mimeType)} is not a valid upload file type. Uploads must use a concrete file MIME type that CalmLens can store and process, such as image/png, image/jpeg, application/pdf, or text/plain.`;
254
- //#endregion
255
- //#region src/client/CalmLensError.ts
256
- const SDK_ERRORS = { NO_CDN_FETCH: {
257
- id: "sdk/no-cdn-fetch",
258
- defaultMessage: "No fetch implementation is available for CDN requests"
259
- } };
260
- var CalmLensError = class extends Error {
261
- constructor(errorCode, options) {
262
- const code = "getId" in errorCode ? errorCode.getId() : errorCode.id;
263
- const defaultMessage = "getDefaultMessage" in errorCode ? errorCode.getDefaultMessage() : errorCode.defaultMessage;
264
- super(`[calmlens] ${options?.message ?? defaultMessage ?? code}`, { cause: options?.cause });
265
- this.name = "CalmLensError";
266
- this.code = code;
267
- this.details = options?.details;
268
- this.status = options?.status;
269
- }
270
- };
271
- const calmLensError = (errorCode, options) => new CalmLensError(errorCode, options);
272
- //#endregion
273
- //#region src/client/CalmLensUtils.ts
274
- const toTextAssetOptions = ({ text, name, ...options }) => {
275
- if (typeof text !== "string" || !text.trim()) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Text to moderate must be a non-empty string" });
276
- return {
277
- ...options,
278
- name: name ?? "text",
279
- content: {
280
- plainText: text,
281
- type: "text/plain"
282
- }
283
- };
284
- };
285
- //#endregion
286
- //#region src/client/CalmLensWebhooks.ts
287
- const WEBHOOK_DELIVERY_HEADER = "x-calmlens-delivery";
288
- const WEBHOOK_TIMESTAMP_HEADER = "x-calmlens-timestamp";
289
- const WEBHOOK_SIGNATURE_HEADER = "x-calmlens-signature";
290
- /** The JSON body of a webhook delivery for a stored event. */
291
- const toWebhookEventPayload = (event) => ({
292
- id: event.id,
293
- type: event.type,
294
- createdAt: event.createdAt,
295
- projectId: event.projectId,
296
- data: {
297
- severity: event.severity,
298
- title: event.title,
299
- description: event.description ?? null,
300
- assetId: event.assetId ?? null,
301
- asset: event.asset ?? null,
302
- facts: event.facts
303
- }
304
- });
305
- const encoder = new TextEncoder();
306
- const hmacSha256Hex = async (secret, message) => {
307
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
308
- name: "HMAC",
309
- hash: "SHA-256"
310
- }, false, ["sign"]);
311
- const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
312
- return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
313
- };
314
- /** Constant-time comparison of two strings of the same length. */
315
- const timingSafeEqual = (a, b) => {
316
- if (a.length !== b.length) return false;
317
- let diff = 0;
318
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
319
- return diff === 0;
320
- };
321
- /** `v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`, the `x-calmlens-signature` header value. */
322
- const createWebhookSignatureHeader = async (secret, timestamp, body) => `v1=${await hmacSha256Hex(secret, `${timestamp}.${body}`)}`;
323
- /** Headers for one signed delivery of `body`. */
324
- const createWebhookHeaders = async (options) => {
325
- const timestamp = options.timestampSeconds ?? Math.floor(Date.now() / 1e3);
326
- return {
327
- "content-type": "application/json",
328
- [WEBHOOK_DELIVERY_HEADER]: options.deliveryId,
329
- [WEBHOOK_TIMESTAMP_HEADER]: String(timestamp),
330
- [WEBHOOK_SIGNATURE_HEADER]: await createWebhookSignatureHeader(options.secret, timestamp, options.body)
331
- };
332
- };
333
- /**
334
- * Verifies a webhook delivery: `x-calmlens-signature` must contain a `v1=` HMAC-SHA256 of
335
- * `"<x-calmlens-timestamp>.<raw body>"`, and the timestamp must be within `toleranceSeconds`
336
- * (default 300) of now. Returns `false` for anything missing or malformed.
337
- */
338
- async function verifyWebhookSignature({ payload, signature, timestamp, secret, toleranceSeconds = 300, now }) {
339
- if (!secret || !signature || timestamp === null || timestamp === void 0) return false;
340
- const timestampText = String(timestamp).trim();
341
- if (!/^\d+$/.test(timestampText)) return false;
342
- const nowMs = now === void 0 ? Date.now() : now instanceof Date ? now.getTime() : now;
343
- if (Math.abs(Math.floor(nowMs / 1e3) - Number(timestampText)) > toleranceSeconds) return false;
344
- const candidates = signature.split(",").map((part) => part.trim()).filter((part) => part.startsWith("v1=")).map((part) => part.slice(3).toLowerCase());
345
- if (candidates.length === 0) return false;
346
- const body = typeof payload === "string" ? payload : JSON.stringify(payload);
347
- const expected = await hmacSha256Hex(secret, `${timestampText}.${body}`);
348
- let matched = false;
349
- for (const candidate of candidates) if (timingSafeEqual(expected, candidate)) matched = true;
350
- return matched;
351
- }
352
- //#endregion
353
- //#region src/client/CalmLensClient.ts
354
- const toBlobPartBytes = (value) => {
355
- if (value instanceof ArrayBuffer) return new Uint8Array(value);
356
- return Uint8Array.from(value);
357
- };
358
- const normalizePayloadForRequest = (payload) => {
359
- if ("base64" in payload && payload.base64) return {
360
- ...payload,
361
- base64: getRawBase64(payload.base64)
362
- };
363
- if ("uint8Array" in payload && payload.uint8Array) {
364
- const { uint8Array, ...rest } = payload;
365
- return {
366
- ...rest,
367
- data: new Blob([toBlobPartBytes(uint8Array)])
368
- };
369
- }
370
- if ("buffer" in payload && payload.buffer) {
371
- const { buffer, ...rest } = payload;
372
- return {
373
- ...rest,
374
- data: new Blob([toBlobPartBytes(buffer)])
375
- };
376
- }
377
- return payload;
378
- };
379
- const normalizeMetadataForMultipart = (metadata) => {
380
- if (!Array.isArray(metadata)) return metadata;
381
- return Object.fromEntries(metadata.map((item, index) => [index, item]));
382
- };
383
- const normalizeMetadataOptionsForRequest = (options) => {
384
- if (!("metadata" in options) || !Array.isArray(options.metadata)) return options;
385
- return {
386
- ...options,
387
- metadata: normalizeMetadataForMultipart(options.metadata)
388
- };
389
- };
390
- const normalizeAssetOptionsForRequest = (options) => {
391
- const normalizedOptions = normalizeMetadataOptionsForRequest(options);
392
- if ("part" in normalizedOptions) return {
393
- ...normalizedOptions,
394
- part: normalizePayloadForRequest(normalizedOptions.part)
395
- };
396
- if ("content" in normalizedOptions && !("plainText" in normalizedOptions.content)) return {
397
- ...normalizedOptions,
398
- content: normalizePayloadForRequest(normalizedOptions.content)
399
- };
400
- return normalizedOptions;
401
- };
402
- const toRawUploadBody = (payload) => {
403
- const data = assetToData(normalizePayloadForRequest(payload));
404
- if (data instanceof Response) {
405
- if (!data.body) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Response upload data must have a body" });
406
- return data.body;
407
- }
408
- if (data instanceof ArrayBuffer || data instanceof Blob) return data;
409
- if ("getReader" in data) return data;
410
- return new Blob([toBlobPartBytes(data)]);
411
- };
412
- const setOptionalQueryValue = (params, key, value) => {
413
- if (value !== void 0 && value !== null) params.set(key, typeof value === "string" ? value : JSON.stringify(value));
414
- };
415
- const assertValidUploadMimeType = (mimeType) => {
416
- if (isRejectedUploadMimeType(mimeType)) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
417
- message: getRejectedUploadMimeTypeError(mimeType),
418
- details: { mimeType }
419
- });
420
- };
421
- const isImageMimeType = (mimeType) => mimeType.toLowerCase().startsWith("image/");
422
- const prepareSubmitAssetOptions = (options) => {
423
- if (!("content" in options) && !("url" in options) && !("part" in options)) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Either file content, URL, or multipart part is required" });
424
- if (!options.name) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset name is required" });
425
- if (!("part" in options) && "isFinal" in options) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "isFinal is only valid for multipart uploads" });
426
- if ("part" in options && options.part.partNumber && options.part.partNumber > 1) throw calmLensError(PUBLIC_ERROR_CODES.USE_UPLOAD_PART);
427
- if ("content" in options) {
428
- assertValidUploadMimeType(options.content.type);
429
- if (!("plainText" in options.content)) {
430
- const validation = validateAssetPayload(options.content);
431
- if (!validation.ok) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
432
- }
433
- }
434
- if ("part" in options) {
435
- assertValidUploadMimeType(options.part.type);
436
- const partSizeInBytes = options.part.sizeInBytes ?? getAssetPayloadByteLength(options.part);
437
- if (isImageMimeType(options.part.type) && partSizeInBytes !== void 0) {
438
- if (partSizeInBytes > MAX_IMAGE_UPLOAD_BYTES) throw calmLensError(PUBLIC_ERROR_CODES.MULTIPART_IMAGE_TOO_LARGE);
439
- if (options.part.isFinal && partSizeInBytes < MIN_MULTIPART_IMAGE_UPLOAD_BYTES) throw calmLensError(PUBLIC_ERROR_CODES.MULTIPART_IMAGE_TOO_SMALL);
440
- }
441
- const validation = validateAssetPayload(options.part, {
442
- multipart: true,
443
- isFinal: options.part.isFinal
444
- });
445
- if (!validation.ok) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
446
- }
447
- return options;
448
- };
449
- const appendCdnSelector = (url, options) => {
450
- if (options.lens && options.transform) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Specify either a lens or a transform, not both" });
451
- if (options.lens) return `${url}/@${encodeURIComponent(options.lens)}`;
452
- if (!options.transform) return url;
453
- return `${url}/${encodeURI(options.transform.replace(/^\/+/, ""))}`;
454
- };
455
- const getEnsureLensCacheKey = (options) => stringifyJsonCanonical({
456
- identifier: options.identifier,
457
- name: options.name,
458
- description: options.description ?? null,
459
- type: options.type,
460
- active: options.active,
461
- transforms: options.transforms ?? null,
462
- version: options.version
463
- });
464
- const withApiKeyHeader = (init, apiKey) => {
465
- const headers = new Headers(init?.headers);
466
- headers.set("X-Api-Key", apiKey);
467
- return {
468
- ...init,
469
- redirect: "error",
470
- headers: Object.fromEntries(headers.entries())
471
- };
472
- };
473
- const requestSignal = (options, signal) => {
474
- const signals = [
475
- options.signal,
476
- signal,
477
- AbortSignal.timeout(options.timeoutMs ?? 12e4)
478
- ].filter((value) => value != null);
479
- return AbortSignal.any(signals);
480
- };
481
- const safeFetch = (fetchImpl, options) => (async (input, init) => {
482
- const signal = requestSignal(options, init?.signal ?? (input instanceof Request ? input.signal : void 0));
483
- try {
484
- signal.throwIfAborted();
485
- return await fetchImpl(input, {
486
- ...init,
487
- redirect: "error",
488
- signal
489
- });
490
- } catch (cause) {
491
- throw calmLensError({ id: signal.aborted ? signal.reason?.name === "TimeoutError" ? "sdk/timeout" : "sdk/cancelled" : "sdk/transport-error" }, {
492
- message: signal.aborted ? "Request aborted" : "Request failed",
493
- cause
494
- });
495
- }
496
- });
497
- const createApi = (options) => {
498
- return new api_def.Api({
499
- name: "Calm Lens",
500
- baseUrl: options.baseUrl,
501
- middleware: [
502
- (0, api_def.RequestCoalescingMiddleware)(),
503
- { beforeSend: (context) => {
504
- context.updateHeaders({ "X-Api-Key": options.apiKey });
505
- } },
506
- { [api_def.RequestEvent.ERROR]: (context) => {
507
- if (context.error) throw normalizeApiError(context.error);
508
- } }
509
- ],
510
- requestBackend: options.requestBackend ? Object.assign(Object.create(options.requestBackend), "fetch" in options.requestBackend && typeof options.requestBackend.fetch === "function" ? { fetch: safeFetch(options.requestBackend.fetch, options) } : {}) : new api_def.FetchRequestBackend(safeFetch(globalThis.fetch, options))
511
- });
512
- };
513
- const isApiErrorPayload = (value) => {
514
- return !!value && typeof value === "object" && typeof value.code === "string";
515
- };
516
- const normalizeApiError = (error) => {
517
- let cause = error;
518
- const seen = /* @__PURE__ */ new Set();
519
- while (cause instanceof Error && !seen.has(cause)) {
520
- seen.add(cause);
521
- if (cause instanceof CalmLensError) return cause;
522
- if (cause.name === "AbortError" || cause.name === "TimeoutError") return calmLensError({ id: cause.name === "TimeoutError" ? "sdk/timeout" : "sdk/cancelled" }, {
523
- message: "Request aborted",
524
- cause
525
- });
526
- cause = cause.cause;
527
- }
528
- const response = error instanceof Error ? (0, api_def.getErrorResponse)(error) : void 0;
529
- if (error instanceof Error && (0, api_def.isRequestError)(error) && isApiErrorPayload(response?.data)) {
530
- const { code, message, details, traceId } = response.data;
531
- return calmLensError({
532
- id: code,
533
- defaultMessage: message
534
- }, {
535
- message,
536
- status: response?.status,
537
- cause: error,
538
- details: {
539
- ...details,
540
- traceId,
541
- network: true
542
- }
543
- });
544
- }
545
- if (error instanceof CalmLensError) return error;
546
- return calmLensError({ id: response ? response.ok ? "sdk/invalid-response" : "sdk/http-error" : "sdk/transport-error" }, {
547
- message: response ? `HTTP ${response.status}` : error.message,
548
- status: response?.status,
549
- cause: error
550
- });
551
- };
552
- const createEndpoints = (api) => {
553
- const getFolders = api.endpoint().paramsOf().responseOf().build({
554
- id: "getFolders",
555
- method: "get",
556
- path: "/projects/:projectId/folders"
557
- });
558
- const getFolder = api.endpoint().paramsOf().responseOf().build({
559
- id: "getFolder",
560
- method: "get",
561
- path: "/folders/:folderId"
562
- });
563
- const postFolder = api.endpoint().paramsOf().bodyOf().responseOf().build({
564
- id: "postFolder",
565
- method: "post",
566
- path: "/projects/:projectId/folders"
567
- });
568
- const patchFolder = api.endpoint().paramsOf().bodyOf().responseOf().build({
569
- id: "patchFolder",
570
- method: "patch",
571
- path: "/folders/:folderId"
572
- });
573
- const deleteFolder = api.endpoint().paramsOf().responseOf().build({
574
- id: "deleteFolder",
575
- method: "delete",
576
- path: "/folders/:folderId"
577
- });
578
- const moveFolder = api.endpoint().paramsOf().bodyOf().responseOf().build({
579
- id: "moveFolder",
580
- method: "post",
581
- path: "/folders/:folderId/move"
582
- });
583
- const getAssetsPage = api.endpoint().queryOf().paramsOf().responseOf().build({
584
- id: "getAssetsPage",
585
- method: "get",
586
- path: "/projects/:projectId/assets/page"
587
- });
588
- const getAsset = api.endpoint().paramsOf().responseOf().build({
589
- id: "getAsset",
590
- method: "get",
591
- path: "/assets/:assetId"
592
- });
593
- const getAssetChildren = api.endpoint().paramsOf().queryOf().responseOf().build({
594
- id: "getAssetChildren",
595
- method: "get",
596
- path: "/assets/:assetId/children"
597
- });
598
- const patchAsset = api.endpoint().paramsOf().bodyOf().responseOf().build({
599
- id: "patchAsset",
600
- method: "patch",
601
- path: "/assets/:assetId"
602
- });
603
- const postUploadAsset = api.endpoint().bodyOf({ encoding: "multipart/form-data" }).paramsOf().queryOf().responseOf().build({
604
- id: "postUploadAsset",
605
- method: "post",
606
- path: "/projects/:projectId/assets"
607
- });
608
- const postAssetSignedUrl = api.endpoint().bodyOf().paramsOf().responseOf().build({
609
- id: "postAssetSignedUrl",
610
- method: "post",
611
- path: "/assets/:assetId/signed-url"
612
- });
613
- const postAssetPart = api.endpoint().bodyOf({ encoding: "multipart/form-data" }).paramsOf().queryOf().responseOf().build({
614
- id: "postAssetPart",
615
- method: "post",
616
- path: "/assets/:assetId/part"
617
- });
618
- const moveAsset = api.endpoint().paramsOf().bodyOf().responseOf().build({
619
- id: "moveAsset",
620
- method: "post",
621
- path: "/assets/:assetId/move"
622
- });
623
- const retryAsset = api.endpoint().paramsOf().responseOf().build({
624
- id: "retryAsset",
625
- method: "post",
626
- path: "/assets/:assetId/retry"
627
- });
628
- const getEvents = api.endpoint().paramsOf().queryOf().responseOf().build({
629
- id: "getEvents",
630
- method: "get",
631
- path: "/projects/:projectId/events"
632
- });
633
- return {
634
- getFolders,
635
- getFolder,
636
- postFolder,
637
- patchFolder,
638
- deleteFolder,
639
- moveFolder,
640
- getAssetsPage,
641
- getAsset,
642
- getAssetChildren,
643
- patchAsset,
644
- postUploadAsset,
645
- postAssetSignedUrl,
646
- postAssetPart,
647
- moveAsset,
648
- retryAsset,
649
- ensureLens: api.endpoint().bodyOf().paramsOf().responseOf().build({
650
- id: "ensureLens",
651
- method: "put",
652
- path: "/lenses/:lensIdentifier"
653
- }),
654
- ensureLenses: api.endpoint().bodyOf().paramsOf().responseOf().build({
655
- id: "ensureLenses",
656
- method: "put",
657
- path: "/projects/:projectId/lenses"
658
- }),
659
- getEvents
660
- };
661
- };
662
- var CalmLensClient = class CalmLensClient {
663
- /**
664
- * Verifies that a webhook delivery came from CalmLens: checks the `v1=` HMAC-SHA256 in
665
- * `x-calmlens-signature` over `"<x-calmlens-timestamp>.<raw body>"` and rejects timestamps
666
- * more than `toleranceSeconds` (default 300) from now, which stops replayed deliveries.
667
- *
668
- * @param options.payload - The raw request body, exactly as received (verify before parsing)
669
- * @param options.signature - The 'x-calmlens-signature' header value
670
- * @param options.timestamp - The 'x-calmlens-timestamp' header value
671
- * @param options.secret - The connector's signing secret
672
- * @param options.toleranceSeconds - Allowed clock distance in seconds (default 300)
673
- * @param options.now - Override the current time (Date or epoch milliseconds), for tests
674
- * @returns Promise<boolean> - True if the signature is valid and fresh, false otherwise
675
- *
676
- * @example
677
- * ```javascript
678
- * // In your webhook handler (e.g. a Fetch API Request)
679
- * const payload = await request.text();
680
- * const isValid = await CalmLensClient.verifyWebhookSignature({
681
- * payload,
682
- * signature: request.headers.get('x-calmlens-signature'),
683
- * timestamp: request.headers.get('x-calmlens-timestamp'),
684
- * secret: WEBHOOK_SECRET,
685
- * });
686
- *
687
- * if (!isValid) {
688
- * return new Response('Unauthorized', { status: 401 });
689
- * }
690
- * const event = JSON.parse(payload); // { id, type, createdAt, projectId, data }
691
- * ```
692
- */
693
- static async verifyWebhookSignature(options) {
694
- return verifyWebhookSignature(options);
695
- }
696
- constructor(options) {
697
- this.options = options;
698
- this.ensuredLenses = /* @__PURE__ */ new Map();
699
- this.pendingEnsureLenses = /* @__PURE__ */ new Map();
700
- this.multipartImageUploadSizes = /* @__PURE__ */ new Map();
701
- this.multipartUploadQueues = /* @__PURE__ */ new Map();
702
- if (!options.apiKey) throw calmLensError({ id: "upload/invalid-payload" }, { message: "API key is required" });
703
- if (!options.projectId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Project ID is required" });
704
- if (options.timeoutMs !== void 0 && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw calmLensError({ id: "upload/invalid-payload" }, { message: "timeoutMs must be a positive integer" });
705
- this.options = { ...options };
706
- this.options.baseUrl = this.options.baseUrl ?? `https://api.calmlens.com/v1`;
707
- this.options.cdnBaseUrl = this.options.cdnBaseUrl ?? `https://cdn.calmlens.com/v1`;
708
- this.api = createApi(this.options);
709
- this.endpoints = createEndpoints(this.api);
710
- }
711
- /** Scope cancellation/timeout to an operation without changing this client's defaults. */
712
- withRequestOptions(options) {
713
- const client = new CalmLensClient({
714
- ...this.options,
715
- ...options
716
- });
717
- client.multipartUploadQueues = this.multipartUploadQueues;
718
- client.multipartImageUploadSizes = this.multipartImageUploadSizes;
719
- return client;
720
- }
721
- getUploadFetch() {
722
- const requestBackendFetch = this.options.requestBackend?.fetch;
723
- const fetchImpl = this.options.uploadFetch ?? requestBackendFetch ?? globalThis.fetch;
724
- if (!fetchImpl) throw calmLensError(SDK_ERRORS.NO_CDN_FETCH);
725
- return safeFetch(fetchImpl, this.options);
726
- }
727
- async submitRawAssetUpload(options, queue) {
728
- const query = new URLSearchParams();
729
- query.set("name", options.name);
730
- query.set("queue", String(queue));
731
- setOptionalQueryValue(query, "description", options.description);
732
- setOptionalQueryValue(query, "processingOptions", options.processingOptions);
733
- setOptionalQueryValue(query, "visibility", options.visibility);
734
- setOptionalQueryValue(query, "metadata", options.metadata);
735
- setOptionalQueryValue(query, "externalId", options.externalId);
736
- setOptionalQueryValue(query, "externalUrl", options.externalUrl);
737
- setOptionalQueryValue(query, "folderId", options.folderId);
738
- const isMultipart = "part" in options;
739
- const payload = isMultipart ? options.part : options.content;
740
- query.set("contentType", payload.type);
741
- setOptionalQueryValue(query, "sizeInBytes", payload.sizeInBytes ?? getAssetPayloadByteLength(payload));
742
- if (isMultipart) {
743
- const part = payload;
744
- const multipartOptions = options;
745
- query.set("partNumber", String(part.partNumber ?? 1));
746
- query.set("isFinal", String(part.isFinal ?? false));
747
- setOptionalQueryValue(query, "sizeInBytes", part.sizeInBytes ?? multipartOptions.sizeInBytes);
748
- }
749
- const baseUrl = this.options.baseUrl.replace(/\/$/, "");
750
- const path = isMultipart ? "assets/multipart-upload" : "assets/upload";
751
- const url = `${baseUrl}/projects/${encodeURIComponent(this.options.projectId)}/${path}?${query}`;
752
- const response = await this.getUploadFetch()(url, {
753
- method: "POST",
754
- headers: {
755
- "Content-Type": payload.type,
756
- "X-Api-Key": this.options.apiKey,
757
- "X-CalmLens-Upload": isMultipart ? "part" : "content"
758
- },
759
- body: toRawUploadBody(payload),
760
- duplex: "half"
761
- });
762
- const asset = await this.parseRawUploadResponse(response);
763
- if (isMultipart && asset.status === "uploading") {
764
- this.multipartUploadQueues.set(asset.id, queue);
765
- if (isImageMimeType(payload.type)) this.multipartImageUploadSizes.set(asset.id, asset.sizeInBytes ?? 0);
766
- }
767
- return asset;
768
- }
769
- async submitRawAssetPart(assetId, part, queue) {
770
- const query = new URLSearchParams({
771
- queue: String(queue),
772
- partNumber: String(part.partNumber),
773
- isFinal: String(part.isFinal ?? false)
774
- });
775
- setOptionalQueryValue(query, "sizeInBytes", part.sizeInBytes ?? getAssetPayloadByteLength(part));
776
- const url = `${this.options.baseUrl.replace(/\/$/, "")}/assets/${encodeURIComponent(assetId)}/part?${query}`;
777
- const response = await this.getUploadFetch()(url, {
778
- method: "POST",
779
- headers: {
780
- "X-Api-Key": this.options.apiKey,
781
- "X-CalmLens-Upload": "part"
782
- },
783
- body: toRawUploadBody(part),
784
- duplex: "half"
785
- });
786
- const asset = await this.parseRawUploadResponse(response);
787
- if (asset.status === "uploading") this.multipartImageUploadSizes.set(asset.id, asset.sizeInBytes ?? 0);
788
- else {
789
- this.multipartImageUploadSizes.delete(assetId);
790
- this.multipartUploadQueues.delete(assetId);
791
- }
792
- return asset;
793
- }
794
- async parseRawUploadResponse(response) {
795
- let data;
796
- try {
797
- data = await response.json();
798
- } catch (cause) {
799
- throw calmLensError({ id: cause instanceof Error && ["TimeoutError", "AbortError"].includes(cause.name) ? cause.name === "TimeoutError" ? "sdk/timeout" : "sdk/cancelled" : response.ok ? "sdk/invalid-response" : "sdk/http-error" }, {
800
- message: `Invalid JSON response (HTTP ${response.status})`,
801
- status: response.status,
802
- cause
803
- });
804
- }
805
- if (!response.ok) {
806
- if (isApiErrorPayload(data)) throw calmLensError({
807
- id: data.code,
808
- defaultMessage: data.message
809
- }, {
810
- message: data.message,
811
- status: response.status,
812
- details: {
813
- ...data.details,
814
- traceId: data.traceId,
815
- network: true
816
- }
817
- });
818
- throw calmLensError({ id: "sdk/http-error" }, {
819
- message: `HTTP ${response.status}`,
820
- status: response.status
821
- });
822
- }
823
- if (!data || typeof data !== "object" || !("id" in data) || typeof data.id !== "string" || !data.id || !("status" in data) || ![
824
- "uploading",
825
- "pending",
826
- "approved",
827
- "rejected",
828
- "error"
829
- ].includes(String(data.status))) throw calmLensError({ id: "sdk/invalid-response" }, {
830
- message: "Expected an asset response",
831
- status: response.status
832
- });
833
- return data;
834
- }
835
- get cdn() {
836
- return {
837
- /**
838
- * Create a CDN URL for asset content or previews.
839
- * Private assets require an authenticated request or signed URL.
840
- */
841
- getAssetUrl: ({ assetIdentifier, ...options }) => {
842
- if (!assetIdentifier) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset identifier is required" });
843
- const resource = options.resource ?? "content";
844
- const url = `${this.options.cdnBaseUrl.replace(/\/$/, "")}/assets/${encodeURIComponent(this.options.projectId)}/${encodeURIComponent(assetIdentifier)}/${resource}`;
845
- return appendCdnSelector(url, options);
846
- },
847
- /**
848
- * Fetch asset content or previews from the CDN.
849
- * Private assets are authenticated with the client's API key.
850
- */
851
- serve: async ({ assetIdentifier, init, ...urlOptions }) => {
852
- const fetchImpl = this.options.cdnFetch ?? globalThis.fetch;
853
- if (!fetchImpl) throw calmLensError(SDK_ERRORS.NO_CDN_FETCH);
854
- return safeFetch(fetchImpl, this.options)(this.cdn.getAssetUrl({
855
- assetIdentifier,
856
- ...urlOptions
857
- }), withApiKeyHeader(init, this.options.apiKey));
858
- }
859
- };
860
- }
861
- get text() {
862
- return { moderate: async (options) => this.assets.moderate(toTextAssetOptions(options)) };
863
- }
864
- get assets() {
865
- return {
866
- /**
867
- * List all assets for a project
868
- * @param options - Pagination and filtering options
869
- * @returns Promise with paginated assets
870
- */
871
- list: async (options) => {
872
- return (await this.endpoints.getAssetsPage.submit({
873
- params: { projectId: this.options.projectId },
874
- query: {
875
- ...options,
876
- pageIndex: options.pageIndex ?? 0
877
- }
878
- })).data;
879
- },
880
- /**
881
- * Retrieve a specific asset by ID
882
- * @param assetId - The asset ID
883
- * @returns Promise with asset data
884
- */
885
- retrieve: async ({ assetId }) => {
886
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
887
- return (await this.endpoints.getAsset.submit({ params: { assetId } })).data;
888
- },
889
- update: async ({ assetId, ...body }) => {
890
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
891
- return (await this.endpoints.patchAsset.submit({
892
- params: { assetId },
893
- body
894
- })).data;
895
- },
896
- /**
897
- * Get child assets of a parent asset
898
- * @param assetId - The parent asset ID
899
- * @param options - Pagination options
900
- * @returns Promise with paginated child assets
901
- */
902
- listChildren: async ({ assetId, ...options }) => {
903
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
904
- return (await this.endpoints.getAssetChildren.submit({
905
- params: { assetId },
906
- query: {
907
- ...options,
908
- pageIndex: options.pageIndex ?? 0
909
- }
910
- })).data;
911
- },
912
- /**
913
- * Submit a new asset for processing (queued asynchronously)
914
- * The asset will be queued for classification and returned immediately.
915
- * @param options - Asset submission options
916
- * @returns Promise with created asset
917
- */
918
- create: async (options) => {
919
- const body = prepareSubmitAssetOptions(options);
920
- if ("content" in body || "part" in body) return await this.submitRawAssetUpload(body, true);
921
- return (await this.endpoints.postUploadAsset.submit({
922
- body: normalizeAssetOptionsForRequest(body),
923
- params: { projectId: this.options.projectId },
924
- query: { queue: true }
925
- })).data;
926
- },
927
- /**
928
- * Submit a new asset and moderate it synchronously
929
- * The asset will be classified immediately before the response is returned.
930
- * This may take longer but ensures the asset is fully processed.
931
- * @param options - Asset submission options
932
- * @returns Promise with created and analyzed asset
933
- */
934
- moderate: async (options) => {
935
- if ("content" in options && "plainText" in options.content && (typeof options.content.plainText !== "string" || !options.content.plainText.trim())) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Text to moderate must be a non-empty string" });
936
- const body = prepareSubmitAssetOptions(options);
937
- if ("content" in body || "part" in body) return await this.submitRawAssetUpload(body, false);
938
- return (await this.endpoints.postUploadAsset.submit({
939
- body: normalizeAssetOptionsForRequest(body),
940
- params: { projectId: this.options.projectId },
941
- query: { queue: false }
942
- })).data;
943
- },
944
- /**
945
- * Upload a part for a multipart upload
946
- * @param assetId - The asset ID
947
- * @param part - The part to upload
948
- * @returns Promise with asset data
949
- */
950
- uploadPart: async ({ assetId, part }) => {
951
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
952
- if (!part) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Part options are required" });
953
- const validation = validateAssetPayload(part, {
954
- multipart: true,
955
- isFinal: part.isFinal
956
- });
957
- if (!validation.ok) throw calmLensError(PUBLIC_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
958
- const existingImageSizeInBytes = this.multipartImageUploadSizes.get(assetId);
959
- const partSizeInBytes = part.sizeInBytes ?? getAssetPayloadByteLength(part);
960
- if (existingImageSizeInBytes !== void 0 && partSizeInBytes !== void 0) {
961
- const totalSizeInBytes = existingImageSizeInBytes + partSizeInBytes;
962
- if (totalSizeInBytes > MAX_IMAGE_UPLOAD_BYTES) throw calmLensError(PUBLIC_ERROR_CODES.MULTIPART_IMAGE_TOO_LARGE);
963
- if (part.isFinal && totalSizeInBytes < MIN_MULTIPART_IMAGE_UPLOAD_BYTES) throw calmLensError(PUBLIC_ERROR_CODES.MULTIPART_IMAGE_TOO_SMALL);
964
- }
965
- return await this.submitRawAssetPart(assetId, part, this.multipartUploadQueues.get(assetId) ?? false);
966
- },
967
- /**
968
- * Create a short-lived signed CDN URL for an asset.
969
- * This is useful for private assets where clients should not send app
970
- * auth cookies or API keys to the CDN on every media request.
971
- */
972
- createSignedUrl: async ({ assetId, ...options }) => {
973
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
974
- if (options.lens && options.transform) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Specify either a lens or a transform, not both" });
975
- return (await this.endpoints.postAssetSignedUrl.submit({
976
- body: {
977
- resource: options.resource ?? "content",
978
- expiresInSeconds: options.expiresInSeconds ?? 900,
979
- lens: options.lens,
980
- transform: options.transform
981
- },
982
- params: { assetId }
983
- })).data;
984
- },
985
- move: async ({ assetId, folderId }) => {
986
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
987
- return (await this.endpoints.moveAsset.submit({
988
- params: { assetId },
989
- body: { folderId }
990
- })).data;
991
- },
992
- /**
993
- * Processes an asset that failed (status `error`) again. It returns to `pending` and is
994
- * moderated like a new upload; its content must still be stored.
995
- */
996
- retry: async ({ assetId }) => {
997
- if (!assetId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Asset ID is required" });
998
- return (await this.endpoints.retryAsset.submit({ params: { assetId } })).data;
999
- }
1000
- };
1001
- }
1002
- get events() {
1003
- return {
1004
- /**
1005
- * List project events after a cursor, oldest first. Call without `after` to get a cursor
1006
- * for "now", then poll with the returned `cursor`. Webhook deliveries carry the same events
1007
- * reshaped as `{ id, type, createdAt, projectId, data }`.
1008
- */
1009
- list: async (options = {}) => {
1010
- return (await this.endpoints.getEvents.submit({
1011
- params: { projectId: this.options.projectId },
1012
- query: options
1013
- })).data;
1014
- } };
1015
- }
1016
- get folders() {
1017
- return {
1018
- list: async () => {
1019
- return (await this.endpoints.getFolders.submit({ params: { projectId: this.options.projectId } })).data.folders;
1020
- },
1021
- retrieve: async ({ folderId }) => {
1022
- if (!folderId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Folder ID is required" });
1023
- return (await this.endpoints.getFolder.submit({ params: { folderId } })).data;
1024
- },
1025
- create: async (options) => {
1026
- return (await this.endpoints.postFolder.submit({
1027
- params: { projectId: this.options.projectId },
1028
- body: options
1029
- })).data;
1030
- },
1031
- update: async ({ folderId, ...body }) => {
1032
- if (!folderId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Folder ID is required" });
1033
- return (await this.endpoints.patchFolder.submit({
1034
- params: { folderId },
1035
- body
1036
- })).data;
1037
- },
1038
- delete: async ({ folderId }) => {
1039
- if (!folderId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Folder ID is required" });
1040
- await this.endpoints.deleteFolder.submit({ params: { folderId } });
1041
- },
1042
- move: async ({ folderId, parentId }) => {
1043
- if (!folderId) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Folder ID is required" });
1044
- return (await this.endpoints.moveFolder.submit({
1045
- params: { folderId },
1046
- body: { parentId }
1047
- })).data;
1048
- }
1049
- };
1050
- }
1051
- /**
1052
- * Create a named lens at any positive version, or update it with a greater version.
1053
- * Supplying the same definition and version is idempotent.
1054
- */
1055
- async ensureLens(options) {
1056
- if (!options.identifier) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Lens identifier is required" });
1057
- const [lens] = await this.ensureLenses({ lenses: [options] });
1058
- return lens;
1059
- }
1060
- /**
1061
- * Create or update several named lenses in one API request.
1062
- * Each lens follows the same versioning and idempotency rules as ensureLens().
1063
- */
1064
- async ensureLenses({ lenses }) {
1065
- if (lenses.length === 0) throw calmLensError({ id: "upload/invalid-payload" }, { message: "At least one lens is required" });
1066
- if (lenses.some((option) => !option.identifier)) throw calmLensError({ id: "upload/invalid-payload" }, { message: "Lens identifier is required" });
1067
- const missingOptions = [];
1068
- const missingKeys = /* @__PURE__ */ new Set();
1069
- for (const option of lenses) {
1070
- const cacheKey = getEnsureLensCacheKey(option);
1071
- if (!this.ensuredLenses.has(cacheKey) && !missingKeys.has(cacheKey)) {
1072
- missingKeys.add(cacheKey);
1073
- missingOptions.push(option);
1074
- }
1075
- }
1076
- if (missingOptions.length === 0) return lenses.map((option) => this.ensuredLenses.get(getEnsureLensCacheKey(option)));
1077
- const batchCacheKey = JSON.stringify([...missingKeys].sort());
1078
- let pending = this.pendingEnsureLenses.get(batchCacheKey);
1079
- if (!pending) {
1080
- pending = this.submitEnsureLenses(missingOptions);
1081
- this.pendingEnsureLenses.set(batchCacheKey, pending);
1082
- pending.then(() => this.pendingEnsureLenses.delete(batchCacheKey), () => this.pendingEnsureLenses.delete(batchCacheKey));
1083
- }
1084
- await pending;
1085
- return lenses.map((option) => this.ensuredLenses.get(getEnsureLensCacheKey(option)));
1086
- }
1087
- async submitEnsureLenses(options) {
1088
- const result = await this.endpoints.ensureLenses.submit({
1089
- body: { lenses: options },
1090
- params: { projectId: this.options.projectId }
1091
- });
1092
- for (const [index, lens] of result.data.lenses.entries()) {
1093
- const optionsForLens = options[index];
1094
- if (!optionsForLens) throw calmLensError({ id: "sdk/invalid-response" }, { message: "CalmLens returned an unexpected lens response" });
1095
- for (const [cacheKey, cachedLens] of this.ensuredLenses) if (cachedLens.identifier === optionsForLens.identifier) this.ensuredLenses.delete(cacheKey);
1096
- this.ensuredLenses.set(getEnsureLensCacheKey(optionsForLens), lens);
1097
- }
1098
- }
1099
- };
1100
- //#endregion
1101
- //#region src/cli/listen.ts
1102
- const FORWARD_TIMEOUT_MS = 1e4;
1103
- const MAX_BACKOFF_MS = 3e4;
1104
- /** Fatal errors stop the CLI instead of retrying: they need the user to change something. */
1105
- const FATAL_ERROR_CODES = new Set([
1106
- PUBLIC_ERROR_CODES.MISSING_TOKEN,
1107
- PUBLIC_ERROR_CODES.INVALID_API_KEY,
1108
- PUBLIC_ERROR_CODES.API_KEY_PROJECT_MISMATCH,
1109
- PUBLIC_ERROR_CODES.PROJECT_NOT_FOUND
1110
- ].map((code) => code.getId()));
1111
- const createLocalSigningSecret = () => `whsec_local_${(0, node_crypto.randomBytes)(24).toString("hex")}`;
1112
- const forwardEvent = async (event, options) => {
1113
- const body = JSON.stringify(toWebhookEventPayload(event));
1114
- const startedAt = Date.now();
1115
- try {
1116
- const headers = await createWebhookHeaders({
1117
- secret: options.secret,
1118
- body,
1119
- deliveryId: `cli_${(0, node_crypto.randomUUID)()}`
1120
- });
1121
- const response = await (options.fetchImpl ?? fetch)(options.forwardTo, {
1122
- method: "POST",
1123
- headers,
1124
- body,
1125
- signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS)
1126
- });
1127
- return {
1128
- ok: response.ok,
1129
- status: response.status,
1130
- durationMs: Date.now() - startedAt
1131
- };
1132
- } catch (error) {
1133
- return {
1134
- ok: false,
1135
- error: error.cause?.code ?? (error instanceof Error ? error.message : String(error)),
1136
- durationMs: Date.now() - startedAt
1137
- };
1138
- }
1139
- };
1140
- const formatTime = (date) => date.toTimeString().slice(0, 8);
1141
- const formatForwardLine = (event, result, now = /* @__PURE__ */ new Date()) => {
1142
- const outcome = result.status ? `${result.status}${result.ok ? "" : " (handler returned an error)"}` : `failed: ${result.error}${result.error === "ECONNREFUSED" ? " (is your server running?)" : ""}`;
1143
- return `${formatTime(now)} ${event.type.padEnd(16)} ${event.id} → ${outcome} [${result.durationMs}ms]`;
1144
- };
1145
- const sleep = (ms, signal) => new Promise((resolve) => {
1146
- if (signal?.aborted) return resolve();
1147
- const timer = setTimeout(resolve, ms);
1148
- signal?.addEventListener("abort", () => {
1149
- clearTimeout(timer);
1150
- resolve();
1151
- }, { once: true });
1152
- });
1153
- /**
1154
- * Forwards new events until `signal` aborts. Resolves normally on abort; rejects with the
1155
- * underlying error for fatal API errors (bad key, wrong project).
1156
- */
1157
- const listen = async (options) => {
1158
- const { client, signal } = options;
1159
- const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
1160
- const intervalMs = options.intervalMs ?? 2e3;
1161
- const eventTypes = new Set(options.eventTypes ?? []);
1162
- let { cursor } = await client.events.list({});
1163
- options.onReady?.();
1164
- let failures = 0;
1165
- while (!signal?.aborted) try {
1166
- const page = await client.events.list({
1167
- after: cursor,
1168
- limit: 100
1169
- });
1170
- if (failures > 0) log(`${formatTime(/* @__PURE__ */ new Date())} Reconnected to CalmLens.`);
1171
- failures = 0;
1172
- for (const event of page.events) {
1173
- if (eventTypes.size > 0 && !eventTypes.has(event.type)) continue;
1174
- log(formatForwardLine(event, await forwardEvent(event, options)));
1175
- }
1176
- cursor = page.cursor;
1177
- if (!page.hasMore) await sleep(intervalMs, signal);
1178
- } catch (error) {
1179
- if (error instanceof CalmLensError && FATAL_ERROR_CODES.has(error.code)) throw error;
1180
- failures++;
1181
- const backoffMs = Math.min(MAX_BACKOFF_MS, intervalMs * 2 ** failures);
1182
- if (failures === 1) {
1183
- const message = error instanceof Error ? error.message : String(error);
1184
- log(`${formatTime(/* @__PURE__ */ new Date())} Couldn't reach CalmLens (${message}). Retrying…`);
1185
- }
1186
- await sleep(backoffMs, signal);
1187
- }
1188
- };
1189
- //#endregion
1190
- //#region src/cli/index.ts
1191
- const HELP = `CalmLens CLI
1192
-
1193
- Usage:
1194
- calmlens listen --forward-to <url> [options]
1195
- calmlens trigger [harassment|hate|criminal-activity|clean] [options]
1196
-
1197
- Run it with \`npx calmlens\`, or install it with \`npm install -g calmlens\`.
1198
-
1199
- Commands:
1200
- listen Forward your project's events to a local webhook handler with the same body
1201
- ({ id, type, createdAt, projectId, data }) and headers (x-calmlens-timestamp,
1202
- x-calmlens-signature: v1=...) as real deliveries. No public URL needed.
1203
- trigger Moderate a harmless test comment so CalmLens creates real events. Each trigger
1204
- counts as one upload.
1205
-
1206
- Options:
1207
- --forward-to <url> Local webhook URL, e.g. http://localhost:3000/api/webhooks/calmlens
1208
- --events <types> Comma-separated event types to forward (default: all)
1209
- --secret <secret> Signing secret (default: $CALMLENS_WEBHOOK_SECRET, or a generated one)
1210
- --api-key <key> Project API key (default: $CALMLENS_API_KEY)
1211
- --project-id <id> Project ID (default: $CALMLENS_PROJECT_ID)
1212
- --env-file <path> Load environment variables from a file, e.g. .env.local
1213
- --interval <seconds> Seconds between checks for new events (default: 2)
1214
- --api-url <url> API base URL (default: $CALMLENS_API_URL or https://api.calmlens.com/v1)
1215
- -h, --help Show this help
1216
- `;
1217
- const TRIGGER_KINDS = [
1218
- "harassment",
1219
- "hate",
1220
- "criminal-activity",
1221
- "clean"
1222
- ];
1223
- const fail = (message) => {
1224
- process.stderr.write(`${message}\n`);
1225
- process.exit(1);
1226
- };
1227
- const describeError = (error) => {
1228
- if (error instanceof CalmLensError) return `${error.message.replace(/^\[calmlens\] /, "")} (${error.code})`;
1229
- return error instanceof Error ? error.message : String(error);
1230
- };
1231
- const main = async () => {
1232
- const { values, positionals } = (0, node_util.parseArgs)({
1233
- allowPositionals: true,
1234
- options: {
1235
- "forward-to": { type: "string" },
1236
- events: { type: "string" },
1237
- secret: { type: "string" },
1238
- "api-key": { type: "string" },
1239
- "project-id": { type: "string" },
1240
- "env-file": { type: "string" },
1241
- interval: { type: "string" },
1242
- "api-url": { type: "string" },
1243
- help: {
1244
- type: "boolean",
1245
- short: "h"
1246
- }
1247
- }
1248
- });
1249
- const [command, argument] = positionals;
1250
- if (values.help || !command || command === "help") {
1251
- process.stdout.write(HELP);
1252
- return;
1253
- }
1254
- if (values["env-file"]) try {
1255
- process.loadEnvFile(values["env-file"]);
1256
- } catch {
1257
- fail(`Couldn't read ${values["env-file"]}.`);
1258
- }
1259
- const apiKey = values["api-key"] ?? process.env.CALMLENS_API_KEY;
1260
- const projectId = values["project-id"] ?? process.env.CALMLENS_PROJECT_ID;
1261
- if (!apiKey || !projectId) fail("Set CALMLENS_API_KEY and CALMLENS_PROJECT_ID (or pass --api-key and --project-id).\nBoth are on the Developer → API Keys page of the CalmLens portal.");
1262
- const baseUrl = values["api-url"] ?? process.env.CALMLENS_API_URL;
1263
- const client = new CalmLensClient({
1264
- apiKey,
1265
- projectId,
1266
- ...baseUrl ? { baseUrl } : {}
1267
- });
1268
- if (command === "listen") {
1269
- const forwardTo = values["forward-to"];
1270
- if (!forwardTo) fail("listen needs --forward-to <url>, e.g. http://localhost:3000/api/webhooks/calmlens");
1271
- const intervalSeconds = Number(values.interval ?? 2);
1272
- if (!Number.isFinite(intervalSeconds) || intervalSeconds < 1) fail("--interval must be at least 1 second.");
1273
- const configuredSecret = values.secret ?? process.env.CALMLENS_WEBHOOK_SECRET;
1274
- const secret = configuredSecret ?? createLocalSigningSecret();
1275
- const eventTypes = values.events?.split(",").map((type) => type.trim()).filter(Boolean);
1276
- const controller = new AbortController();
1277
- process.once("SIGINT", () => controller.abort());
1278
- process.once("SIGTERM", () => controller.abort());
1279
- try {
1280
- await listen({
1281
- client,
1282
- forwardTo,
1283
- secret,
1284
- eventTypes,
1285
- intervalMs: intervalSeconds * 1e3,
1286
- signal: controller.signal,
1287
- onReady: () => {
1288
- process.stdout.write([
1289
- `Forwarding CalmLens events for project ${projectId} to ${forwardTo}`,
1290
- configuredSecret ? "Signing with your CALMLENS_WEBHOOK_SECRET." : `Signing secret: ${secret}\n Set CALMLENS_WEBHOOK_SECRET to this value in your app, or pass --secret.`,
1291
- eventTypes?.length ? `Event types: ${eventTypes.join(", ")}` : "Event types: all",
1292
- "Upload something (or run `npx calmlens trigger`) to see events. Press Ctrl+C to stop.",
1293
- ""
1294
- ].join("\n"));
1295
- }
1296
- });
1297
- } catch (error) {
1298
- fail(error instanceof CalmLensError ? `CalmLens rejected the request: ${describeError(error)}` : `Couldn't reach CalmLens: ${describeError(error)}`);
1299
- }
1300
- return;
1301
- }
1302
- if (command === "trigger") {
1303
- const kind = argument ?? "harassment";
1304
- if (!TRIGGER_KINDS.includes(kind)) fail(`Unknown trigger "${kind}". Use one of: ${TRIGGER_KINDS.join(", ")}.`);
1305
- const text = kind === "clean" ? "Hello from the CalmLens CLI" : `<!calmlens-test ${projectId.slice(-8)} ${kind}>`;
1306
- try {
1307
- const asset = await client.assets.moderate({
1308
- name: `cli-trigger-${kind}`,
1309
- content: {
1310
- plainText: text,
1311
- type: "text/plain"
1312
- }
1313
- });
1314
- process.stdout.write(`Created asset ${asset.id}: status ${asset.status}${asset.report?.flagged ? ", flagged" : ""}.\n`);
1315
- } catch (error) {
1316
- fail(`Trigger failed: ${describeError(error)}`);
1317
- }
1318
- return;
1319
- }
1320
- fail(`Unknown command "${command}".\n\n${HELP}`);
1321
- };
1322
- main().catch((error) => fail(describeError(error)));
1323
- //#endregion