@calmlens/sdk 0.0.0-stage → 0.5.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/README.md +554 -2
- package/bin/calmlens.js +1323 -0
- package/cjs/CalmLensTypes-DG_kHVqY.d.ts +3521 -0
- package/cjs/CalmLensUtils-0QaxbI3A.js +551 -0
- package/cjs/index.d.ts +2741 -0
- package/cjs/index.js +1206 -0
- package/cjs/package.json +3 -0
- package/cjs/testing.d.ts +149 -0
- package/cjs/testing.js +702 -0
- package/esm/CalmLensTypes-DG_kHVqY.d.ts +3521 -0
- package/esm/CalmLensUtils-BQEgxGWk.js +410 -0
- package/esm/index.d.ts +2741 -0
- package/esm/index.js +1196 -0
- package/esm/package.json +3 -0
- package/esm/testing.d.ts +149 -0
- package/esm/testing.js +696 -0
- package/package.json +88 -4
package/bin/calmlens.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
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
|