@calmlens/sdk 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ const require_CalmLensError = require("./CalmLensError-CKwMKV0S.js");
2
+ const MAX_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 20971520)();
3
+ const MIN_MULTIPART_IMAGE_UPLOAD_BYTES = /* @__PURE__ */ (() => 10485760)();
4
+ const REJECTED_UPLOAD_MIME_TYPE_SET = /* @__PURE__ */ new Set([
5
+ "application/octet-stream",
6
+ "multipart/form-data",
7
+ "multipart/mixed",
8
+ "multipart/alternative",
9
+ "multipart/related",
10
+ "multipart/signed",
11
+ "multipart/encrypted"
12
+ ]);
13
+ const normalizeUploadMimeType = (mimeType) => mimeType.toLowerCase().split(";")[0]?.trim() || mimeType.toLowerCase().trim();
14
+ const isRejectedUploadMimeType = (mimeType) => REJECTED_UPLOAD_MIME_TYPE_SET.has(normalizeUploadMimeType(mimeType));
15
+ 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.`;
16
+ const DOMAIN = "calmlens.com";
17
+ //#endregion
18
+ //#region ../shared/src/AssetPayload.ts
19
+ const uint8ArrayInputToNumbers = (value) => {
20
+ if (Array.isArray(value)) return value;
21
+ return Array.from(value);
22
+ };
23
+ const bufferInputToBytes = (value) => {
24
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
25
+ return Uint8Array.from(value);
26
+ };
27
+ 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.";
28
+ const MULTIPART_PART_TOO_SMALL_ERROR = `Multipart upload parts must be at least 5MiB except the final part.`;
29
+ const getRawBase64 = (value) => {
30
+ const markerIndex = value.indexOf(";base64,");
31
+ if (markerIndex === -1) return value;
32
+ return value.slice(markerIndex + 8);
33
+ };
34
+ const validateBase64PayloadString = (payload, options) => {
35
+ const raw = getRawBase64(payload.base64).replace(/\s/g, "");
36
+ if (options?.multipart && raw.length % 4 !== 0) return {
37
+ ok: false,
38
+ error: BASE64_PART_BOUNDARY_ERROR
39
+ };
40
+ return { ok: true };
41
+ };
42
+ const isReadableStream = (value) => typeof value === "object" && value !== null && "getReader" in value && typeof value.getReader === "function";
43
+ const assetToData = (payload) => {
44
+ if ("plainText" in payload && payload.plainText !== void 0) return new TextEncoder().encode(payload.plainText);
45
+ if ("data" in payload && payload.data) return payload.data;
46
+ if ("buffer" in payload) {
47
+ if (payload.buffer instanceof ArrayBuffer) return payload.buffer;
48
+ return bufferInputToBytes(payload.buffer);
49
+ }
50
+ if (payload.uint8Array !== void 0) return Uint8Array.from(uint8ArrayInputToNumbers(payload.uint8Array));
51
+ if (payload.base64 === void 0) throw new Error("Invalid asset payload");
52
+ const raw = getRawBase64(payload.base64);
53
+ if (typeof Buffer !== "undefined") return Uint8Array.from(Buffer.from(raw, "base64"));
54
+ const binary = atob(raw);
55
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
56
+ };
57
+ const assetDataToUint8Array = async (data) => {
58
+ if (data instanceof Response) return new Uint8Array(await data.arrayBuffer());
59
+ if (isReadableStream(data)) return new Uint8Array(await new Response(data).arrayBuffer());
60
+ if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
61
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
62
+ return data;
63
+ };
64
+ const getAssetPayloadByteLength = (payload) => {
65
+ if ("plainText" in payload && payload.plainText !== void 0) return new TextEncoder().encode(payload.plainText).byteLength;
66
+ if ("data" in payload && payload.data) {
67
+ if (payload.data instanceof Blob) return payload.data.size;
68
+ if (payload.data instanceof Response) {
69
+ const contentLength = payload.data.headers.get("content-length");
70
+ return contentLength ? Number(contentLength) : void 0;
71
+ }
72
+ return;
73
+ }
74
+ if ("buffer" in payload && payload.buffer) return payload.buffer.byteLength;
75
+ if (payload.uint8Array !== void 0) {
76
+ if (Array.isArray(payload.uint8Array)) return payload.uint8Array.length;
77
+ return payload.uint8Array.byteLength;
78
+ }
79
+ if (payload.base64 === void 0) return;
80
+ const raw = getRawBase64(payload.base64).replace(/\s/g, "");
81
+ const padding = raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0;
82
+ return Math.max(0, Math.floor(raw.length * 3 / 4) - padding);
83
+ };
84
+ const validateAssetPayload = (payload, options) => {
85
+ if ("base64" in payload && payload.base64 !== void 0) {
86
+ const validation = validateBase64PayloadString(payload, options);
87
+ if (!validation.ok) return {
88
+ ok: false,
89
+ error: validation.error
90
+ };
91
+ }
92
+ const byteLength = getAssetPayloadByteLength(payload);
93
+ if (options?.multipart && !options.isFinal && byteLength !== void 0 && byteLength < 5242880) return {
94
+ ok: false,
95
+ error: MULTIPART_PART_TOO_SMALL_ERROR
96
+ };
97
+ return { ok: true };
98
+ };
99
+ //#endregion
100
+ //#region src/client/CalmLensUtils.ts
101
+ const assertHttpUrl = (name, value) => {
102
+ if (value === void 0) return;
103
+ let url;
104
+ try {
105
+ url = new URL(value);
106
+ } catch {}
107
+ if (!url || url.protocol !== "https:" && url.protocol !== "http:") throw require_CalmLensError.invalidConfig(`${name} must be an absolute http(s) URL`);
108
+ };
109
+ const assertInteger = (name, value, minimum) => {
110
+ if (value !== void 0 && (!Number.isSafeInteger(value) || value < minimum)) throw require_CalmLensError.invalidConfig(minimum === 0 ? `${name} must be a non-negative integer` : `${name} must be a positive integer`);
111
+ };
112
+ const configPathSegment = (value, name) => {
113
+ try {
114
+ encodePathSegment(value, name);
115
+ } catch (error) {
116
+ throw require_CalmLensError.invalidConfig(error.message.replace(/^\[calmlens\] /, ""));
117
+ }
118
+ };
119
+ /** Checks the options both clients share: where requests go and how they're sent. */
120
+ const validateConnectionOptions = (options) => {
121
+ if ("requestBackend" in options) throw require_CalmLensError.invalidConfig("requestBackend is no longer supported; pass a fetch implementation as fetch");
122
+ validateRequestOptions(options);
123
+ assertInteger("maxResponseBytes", options.maxResponseBytes, 1);
124
+ for (const name of ["fetch", "uploadFetch"]) if (options[name] !== void 0 && typeof options[name] !== "function") throw require_CalmLensError.invalidConfig(`${name} must be a function`);
125
+ assertHttpUrl("baseUrl", options.baseUrl);
126
+ };
127
+ /**
128
+ * Checks server client options before the client sets anything up. `CalmLensServerClient` and
129
+ * `MockCalmLensServerClient` share it so both reject the same configuration.
130
+ */
131
+ const validateServerClientOptions = (options) => {
132
+ if (!options || typeof options !== "object") throw require_CalmLensError.invalidConfig("Client options are required");
133
+ if (!options.apiKey || typeof options.apiKey !== "string") throw require_CalmLensError.invalidConfig("API key is required");
134
+ if (!options.projectId) throw require_CalmLensError.invalidConfig("Project ID is required");
135
+ configPathSegment(options.projectId, "Project ID");
136
+ validateConnectionOptions(options);
137
+ if (options.cdnFetch !== void 0 && typeof options.cdnFetch !== "function") throw require_CalmLensError.invalidConfig("cdnFetch must be a function");
138
+ assertHttpUrl("cdnBaseUrl", options.cdnBaseUrl);
139
+ };
140
+ /** Checks app client options before the client sets anything up. */
141
+ const validateAppClientOptions = (options) => {
142
+ if (!options || typeof options !== "object") throw require_CalmLensError.invalidConfig("Client options are required");
143
+ if ("apiKey" in options && options.apiKey !== void 0) throw require_CalmLensError.invalidConfig("CalmLensAppClient never takes an API key, which anyone using the browser or app could read. Upload through uploadPresetId, and keep the API key in CalmLensServerClient on your server.");
144
+ if (!options.uploadPresetId) throw require_CalmLensError.invalidConfig("Upload preset ID is required");
145
+ configPathSegment(options.uploadPresetId, "Upload preset ID");
146
+ if (options.token !== void 0 && typeof options.token !== "string" && typeof options.token !== "function") throw require_CalmLensError.invalidConfig("token must be a string or a function that returns one");
147
+ validateConnectionOptions(options);
148
+ };
149
+ /** Checks the options `withRequestOptions` accepts. */
150
+ const validateRequestOptions = (options) => {
151
+ assertInteger("timeoutMs", options.timeoutMs, 1);
152
+ assertInteger("uploadTimeoutMs", options.uploadTimeoutMs, 1);
153
+ assertInteger("maxRetries", options.maxRetries, 0);
154
+ };
155
+ const toTextAssetOptions = ({ text, name, ...options }) => {
156
+ if (typeof text !== "string" || !text.trim()) throw new require_CalmLensError.CalmLensError(require_CalmLensError.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Text to moderate must be a non-empty string" });
157
+ return {
158
+ ...options,
159
+ name: name ?? "text",
160
+ content: {
161
+ plainText: text,
162
+ type: "text/plain"
163
+ }
164
+ };
165
+ };
166
+ /**
167
+ * A random UUID v4. React Native has no `crypto` global, so this falls back to
168
+ * `crypto.getRandomValues` and then to `Math.random`; the SDK only uses it for idempotency keys,
169
+ * which need to be unique rather than secret.
170
+ */
171
+ const randomUuid = () => {
172
+ const crypto = globalThis.crypto;
173
+ if (typeof crypto?.randomUUID === "function") return crypto.randomUUID();
174
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
175
+ if (typeof crypto?.getRandomValues === "function") crypto.getRandomValues(bytes);
176
+ else for (let index = 0; index < bytes.length; index++) bytes[index] = Math.random() * 256;
177
+ bytes[6] = bytes[6] & 15 | 64;
178
+ bytes[8] = bytes[8] & 63 | 128;
179
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
180
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
181
+ };
182
+ /**
183
+ * Encodes an identifier as one URL path segment. `.` and `..` are rejected because URL
184
+ * resolution would treat them as relative segments and send the request to another route.
185
+ */
186
+ const encodePathSegment = (value, name) => {
187
+ if (typeof value !== "string" || !value) throw require_CalmLensError.invalidArgument(`${name} is required`);
188
+ if (value === "." || value === "..") throw require_CalmLensError.invalidArgument(`${name} cannot be "." or ".."`);
189
+ return encodeURIComponent(value);
190
+ };
191
+ const TRANSFORM_TYPE_PATTERN = /^[A-Za-z0-9_]+$/;
192
+ /** Percent-encodes everything except RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`). */
193
+ const encodeTransformComponent = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
194
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
195
+ const appendTransformOptions = (pairs, key, value) => {
196
+ if (value === void 0 || value === null) return;
197
+ if (isPlainObject(value)) {
198
+ for (const child of Object.keys(value).sort()) appendTransformOptions(pairs, `${key}[${child}]`, value[child]);
199
+ return;
200
+ }
201
+ let text;
202
+ if (typeof value === "string") text = value;
203
+ else if (typeof value === "boolean") text = String(value);
204
+ else if (typeof value === "number" && Number.isFinite(value)) text = String(value);
205
+ else throw require_CalmLensError.invalidArgument(`Transform option "${key}" must be a string, number, boolean or object`);
206
+ pairs.push(`${encodeTransformComponent(key)}=${encodeTransformComponent(text)}`);
207
+ };
208
+ /**
209
+ * Serializes structured transforms as a CDN selector, such as
210
+ * `imageTransform[format=png,width=1024]`. Option keys are sorted so equal transforms share a
211
+ * URL; nested keys (`border[color]`) and values are percent-encoded.
212
+ */
213
+ const serializeTransform = (transform) => {
214
+ const steps = Array.isArray(transform) ? transform : [transform];
215
+ if (steps.length === 0) throw require_CalmLensError.invalidArgument("Transform needs at least one step");
216
+ return steps.map((step) => {
217
+ if (!isPlainObject(step)) throw require_CalmLensError.invalidArgument("Transform steps must be objects");
218
+ if (typeof step.type !== "string" || !TRANSFORM_TYPE_PATTERN.test(step.type)) throw require_CalmLensError.invalidArgument("Transform type must contain only letters, digits and underscores");
219
+ if (step.options !== void 0 && step.options !== null && !isPlainObject(step.options)) throw require_CalmLensError.invalidArgument("Transform options must be an object");
220
+ const pairs = [];
221
+ for (const key of Object.keys(step.options ?? {}).sort()) appendTransformOptions(pairs, key, step.options[key]);
222
+ return `${step.type}[${pairs.join(",")}]`;
223
+ }).join(",");
224
+ };
225
+ /** Returns the selector string the API and CDN accept for a string or structured transform. */
226
+ const transformSelector = (transform) => transform === void 0 || typeof transform === "string" ? transform : serializeTransform(transform);
227
+ /** Appends a CDN lens (`/@name`) or direct transform selector to an asset URL. */
228
+ const appendCdnSelector = (url, options) => {
229
+ if (options.lens && options.transform) throw require_CalmLensError.invalidArgument("Specify either a lens or a transform, not both");
230
+ if (options.lens) return `${url}/@${encodePathSegment(options.lens, "Lens")}`;
231
+ if (!options.transform) return url;
232
+ if (typeof options.transform !== "string") return `${url}/${serializeTransform(options.transform)}`;
233
+ const transform = options.transform.replace(/^\/+/, "");
234
+ if (transform.includes("..") || transform.includes("?") || transform.includes("#")) throw require_CalmLensError.invalidArgument("Transform cannot contain \"..\", \"?\" or \"#\"");
235
+ return `${url}/${encodeURI(transform).replace(/%5B/g, "[").replace(/%5D/g, "]")}`;
236
+ };
237
+ const appendQueryValue = (parts, key, value) => {
238
+ if (value === void 0 || value === null) return;
239
+ if (Array.isArray(value)) {
240
+ for (const [index, item] of value.entries()) appendQueryValue(parts, `${key}[${index}]`, item);
241
+ return;
242
+ }
243
+ if (typeof value === "object") {
244
+ for (const [field, item] of Object.entries(value)) appendQueryValue(parts, `${key}[${field}]`, item);
245
+ return;
246
+ }
247
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
248
+ };
249
+ /**
250
+ * Serialises a query object the way the API parses it (qs bracket notation), for example
251
+ * `ordering[0][field]=createdAt&ordering[0][direction]=DESC`. Every key and value is
252
+ * percent-encoded and missing values are omitted.
253
+ */
254
+ const stringifyQuery = (query) => {
255
+ const parts = [];
256
+ for (const [key, value] of Object.entries(query)) appendQueryValue(parts, key, value);
257
+ return parts.join("&");
258
+ };
259
+ //#endregion
260
+ Object.defineProperty(exports, "DOMAIN", {
261
+ enumerable: true,
262
+ get: function() {
263
+ return DOMAIN;
264
+ }
265
+ });
266
+ Object.defineProperty(exports, "MAX_IMAGE_UPLOAD_BYTES", {
267
+ enumerable: true,
268
+ get: function() {
269
+ return MAX_IMAGE_UPLOAD_BYTES;
270
+ }
271
+ });
272
+ Object.defineProperty(exports, "MIN_MULTIPART_IMAGE_UPLOAD_BYTES", {
273
+ enumerable: true,
274
+ get: function() {
275
+ return MIN_MULTIPART_IMAGE_UPLOAD_BYTES;
276
+ }
277
+ });
278
+ Object.defineProperty(exports, "appendCdnSelector", {
279
+ enumerable: true,
280
+ get: function() {
281
+ return appendCdnSelector;
282
+ }
283
+ });
284
+ Object.defineProperty(exports, "assetDataToUint8Array", {
285
+ enumerable: true,
286
+ get: function() {
287
+ return assetDataToUint8Array;
288
+ }
289
+ });
290
+ Object.defineProperty(exports, "assetToData", {
291
+ enumerable: true,
292
+ get: function() {
293
+ return assetToData;
294
+ }
295
+ });
296
+ Object.defineProperty(exports, "encodePathSegment", {
297
+ enumerable: true,
298
+ get: function() {
299
+ return encodePathSegment;
300
+ }
301
+ });
302
+ Object.defineProperty(exports, "getAssetPayloadByteLength", {
303
+ enumerable: true,
304
+ get: function() {
305
+ return getAssetPayloadByteLength;
306
+ }
307
+ });
308
+ Object.defineProperty(exports, "getRejectedUploadMimeTypeError", {
309
+ enumerable: true,
310
+ get: function() {
311
+ return getRejectedUploadMimeTypeError;
312
+ }
313
+ });
314
+ Object.defineProperty(exports, "isRejectedUploadMimeType", {
315
+ enumerable: true,
316
+ get: function() {
317
+ return isRejectedUploadMimeType;
318
+ }
319
+ });
320
+ Object.defineProperty(exports, "randomUuid", {
321
+ enumerable: true,
322
+ get: function() {
323
+ return randomUuid;
324
+ }
325
+ });
326
+ Object.defineProperty(exports, "stringifyQuery", {
327
+ enumerable: true,
328
+ get: function() {
329
+ return stringifyQuery;
330
+ }
331
+ });
332
+ Object.defineProperty(exports, "toTextAssetOptions", {
333
+ enumerable: true,
334
+ get: function() {
335
+ return toTextAssetOptions;
336
+ }
337
+ });
338
+ Object.defineProperty(exports, "transformSelector", {
339
+ enumerable: true,
340
+ get: function() {
341
+ return transformSelector;
342
+ }
343
+ });
344
+ Object.defineProperty(exports, "validateAppClientOptions", {
345
+ enumerable: true,
346
+ get: function() {
347
+ return validateAppClientOptions;
348
+ }
349
+ });
350
+ Object.defineProperty(exports, "validateAssetPayload", {
351
+ enumerable: true,
352
+ get: function() {
353
+ return validateAssetPayload;
354
+ }
355
+ });
356
+ Object.defineProperty(exports, "validateRequestOptions", {
357
+ enumerable: true,
358
+ get: function() {
359
+ return validateRequestOptions;
360
+ }
361
+ });
362
+ Object.defineProperty(exports, "validateServerClientOptions", {
363
+ enumerable: true,
364
+ get: function() {
365
+ return validateServerClientOptions;
366
+ }
367
+ });
@@ -0,0 +1,201 @@
1
+ const require_CalmLensError = require("./CalmLensError-CKwMKV0S.js");
2
+ const require_CalmLensUtils = require("./CalmLensUtils-Dg3s4loH.js");
3
+ //#region ../shared/src/JsonUtils.ts
4
+ const stringifyJsonCanonical = (value) => JSON.stringify(value, (_key, nestedValue) => {
5
+ if (!nestedValue || typeof nestedValue !== "object" || Array.isArray(nestedValue)) return nestedValue;
6
+ return Object.fromEntries(Object.keys(nestedValue).sort().map((key) => [key, nestedValue[key]]));
7
+ });
8
+ //#endregion
9
+ //#region ../shared/src/CryptoUtils.ts
10
+ const encoder = new TextEncoder();
11
+ /** Lowercase hex of `bytes`, two characters per byte. */
12
+ const bytesToHex = (bytes) => Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
13
+ /** SHA-256 of the UTF-8 encoding of `value`, as lowercase hex. */
14
+ const sha256Hex = async (value) => bytesToHex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
15
+ /** HMAC-SHA256 of `message` keyed with `secret` (both UTF-8 encoded), as lowercase hex. */
16
+ const hmacSha256Hex = async (secret, message) => {
17
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
18
+ name: "HMAC",
19
+ hash: "SHA-256"
20
+ }, false, ["sign"]);
21
+ return bytesToHex(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
22
+ };
23
+ /**
24
+ * Compares two strings without leaking, through timing, where they first differ. Strings of
25
+ * different lengths are unequal immediately (the length itself is not secret here).
26
+ */
27
+ const timingSafeEqual = (a, b) => {
28
+ if (a.length !== b.length) return false;
29
+ let difference = 0;
30
+ for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
31
+ return difference === 0;
32
+ };
33
+ //#endregion
34
+ //#region ../shared/src/UploadToken.ts
35
+ const UPLOAD_TOKEN_PREFIX = "clut_";
36
+ const UPLOAD_TOKEN_MAX_TTL_SECONDS = 3600;
37
+ const SIGNING_CONTEXT = "calmlens-upload-token:v1:";
38
+ const toBase64Url = (value) => btoa(String.fromCharCode(...new TextEncoder().encode(value))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
39
+ /**
40
+ * The part of an API key a token names so the backend can find the key's hash. The same rule as
41
+ * the stored `keyPrefix`: the fixed `clsk_<region>_` part plus four random characters.
42
+ */
43
+ const uploadTokenKeyPrefix = (apiKey) => apiKey.substring(0, apiKey.startsWith("clsk_") ? 13 : 8);
44
+ /** The signing secret: the SHA-256 of the API key, which the backend stores instead of the key. */
45
+ const signingSecret = (apiKey) => sha256Hex(apiKey);
46
+ /** Signs `claims` with `apiKey` and returns the token. */
47
+ const signUploadToken = async (claims, apiKey) => {
48
+ const body = toBase64Url(JSON.stringify(claims));
49
+ const signature = await hmacSha256Hex(await signingSecret(apiKey), SIGNING_CONTEXT + body);
50
+ return `${UPLOAD_TOKEN_PREFIX}${body}.${signature}`;
51
+ };
52
+ //#endregion
53
+ //#region src/client/CalmLensUploadTokens.ts
54
+ /**
55
+ * Signs a single-use upload token with `apiKey`, locally. `CalmLensServerClient` and
56
+ * `MockCalmLensServerClient` share it so both check options the same way.
57
+ */
58
+ const signUploadTokenWithApiKey = async (apiKey, options, { now = Date.now(), nonce = crypto.randomUUID() } = {}) => {
59
+ if (!options || typeof options !== "object") throw require_CalmLensError.invalidArgument("Options are required");
60
+ const { uploadPresetId, externalId, expiresInSeconds = 600 } = options;
61
+ require_CalmLensUtils.encodePathSegment(uploadPresetId, "Upload preset ID");
62
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 3600) throw require_CalmLensError.invalidArgument(`expiresInSeconds must be a whole number from 1 to ${UPLOAD_TOKEN_MAX_TTL_SECONDS}`);
63
+ if (externalId !== void 0 && (typeof externalId !== "string" || !externalId)) throw require_CalmLensError.invalidArgument("externalId must be a non-empty string");
64
+ const expiresAt = now + expiresInSeconds * 1e3;
65
+ return {
66
+ token: await signUploadToken({
67
+ v: 1,
68
+ u: uploadPresetId,
69
+ k: uploadTokenKeyPrefix(apiKey),
70
+ e: expiresAt,
71
+ n: nonce,
72
+ ...externalId === void 0 ? {} : { x: externalId }
73
+ }, apiKey),
74
+ expiresAt
75
+ };
76
+ };
77
+ //#endregion
78
+ //#region src/client/CalmLensWebhooks.ts
79
+ const WEBHOOK_DELIVERY_HEADER = "x-calmlens-delivery";
80
+ const WEBHOOK_TIMESTAMP_HEADER = "x-calmlens-timestamp";
81
+ const WEBHOOK_SIGNATURE_HEADER = "x-calmlens-signature";
82
+ /** Deliveries whose timestamp is further than this from the receiver's clock are rejected. */
83
+ const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300;
84
+ const readHeader = (headers, name) => {
85
+ if (!headers) return void 0;
86
+ if (typeof headers.get === "function") return headers.get(name) ?? void 0;
87
+ const record = headers;
88
+ const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
89
+ const value = key === void 0 ? void 0 : record[key];
90
+ return Array.isArray(value) ? value.join(",") : value ?? void 0;
91
+ };
92
+ /** The raw body as text. Parsed objects are refused: re-serializing JSON changes the signed bytes. */
93
+ const payloadToText = (payload) => {
94
+ if (typeof payload === "string") return payload;
95
+ if (payload instanceof ArrayBuffer || ArrayBuffer.isView(payload)) {
96
+ const view = ArrayBuffer.isView(payload) ? new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) : new Uint8Array(payload);
97
+ return new TextDecoder("utf-8", { fatal: false }).decode(view);
98
+ }
99
+ throw require_CalmLensError.invalidArgument("Webhook payload must be the raw request body as a string, Uint8Array/Buffer or ArrayBuffer, not parsed JSON");
100
+ };
101
+ /**
102
+ * Verifies a webhook delivery: `x-calmlens-signature` must contain a `v1=` HMAC-SHA256 of
103
+ * `"<x-calmlens-timestamp>.<raw body>"`, and the timestamp must be within `toleranceSeconds`
104
+ * (default 300) of now. Returns `false` for anything missing, malformed or stale, and throws
105
+ * `sdk/invalid-argument` when `payload` is not a raw body (for example, parsed JSON).
106
+ */
107
+ async function verifyWebhookSignature(options) {
108
+ const { payload, secret, toleranceSeconds = 300, now, headers } = options;
109
+ const body = payloadToText(payload);
110
+ const signature = options.signature ?? readHeader(headers, "x-calmlens-signature");
111
+ const timestamp = options.timestamp ?? readHeader(headers, "x-calmlens-timestamp");
112
+ if (!secret || !signature || timestamp === null || timestamp === void 0) return false;
113
+ const timestampText = String(timestamp).trim();
114
+ if (!/^\d+$/.test(timestampText)) return false;
115
+ const nowMs = now === void 0 ? Date.now() : now instanceof Date ? now.getTime() : now;
116
+ if (Math.abs(Math.floor(nowMs / 1e3) - Number(timestampText)) > toleranceSeconds) return false;
117
+ const candidates = signature.split(",").map((part) => part.trim()).filter((part) => part.startsWith("v1=")).map((part) => part.slice(3).toLowerCase());
118
+ if (candidates.length === 0) return false;
119
+ const expected = await hmacSha256Hex(secret, `${timestampText}.${body}`);
120
+ let matched = false;
121
+ for (const candidate of candidates) if (timingSafeEqual(expected, candidate)) matched = true;
122
+ return matched;
123
+ }
124
+ /**
125
+ * Verifies a webhook delivery and parses its body. Throws `sdk/invalid-webhook-signature`
126
+ * when the signature or timestamp does not verify, and `sdk/invalid-webhook-payload` when a
127
+ * verified body is not a webhook event. Narrow the result on `type` for typed `data.facts`.
128
+ *
129
+ * Event types and fact keys added to CalmLens after this SDK version are returned as they are,
130
+ * so keep a default branch when you switch on `type`.
131
+ */
132
+ async function parseWebhookEvent(options) {
133
+ if (!await verifyWebhookSignature(options)) throw new require_CalmLensError.CalmLensError(require_CalmLensError.SDK_ERROR_CODES.INVALID_WEBHOOK_SIGNATURE, { message: "Webhook signature or timestamp did not verify" });
134
+ let event;
135
+ try {
136
+ event = JSON.parse(payloadToText(options.payload));
137
+ } catch (cause) {
138
+ throw new require_CalmLensError.CalmLensError(require_CalmLensError.SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, {
139
+ message: "Webhook body is not JSON",
140
+ cause
141
+ });
142
+ }
143
+ const candidate = event;
144
+ if (!candidate || typeof candidate !== "object" || typeof candidate.id !== "string" || typeof candidate.type !== "string" || typeof candidate.projectId !== "string" || !candidate.data || typeof candidate.data !== "object") throw new require_CalmLensError.CalmLensError(require_CalmLensError.SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, { message: "Webhook body is not a CalmLens event" });
145
+ return event;
146
+ }
147
+ //#endregion
148
+ Object.defineProperty(exports, "WEBHOOK_DELIVERY_HEADER", {
149
+ enumerable: true,
150
+ get: function() {
151
+ return WEBHOOK_DELIVERY_HEADER;
152
+ }
153
+ });
154
+ Object.defineProperty(exports, "WEBHOOK_SIGNATURE_HEADER", {
155
+ enumerable: true,
156
+ get: function() {
157
+ return WEBHOOK_SIGNATURE_HEADER;
158
+ }
159
+ });
160
+ Object.defineProperty(exports, "WEBHOOK_SIGNATURE_TOLERANCE_SECONDS", {
161
+ enumerable: true,
162
+ get: function() {
163
+ return WEBHOOK_SIGNATURE_TOLERANCE_SECONDS;
164
+ }
165
+ });
166
+ Object.defineProperty(exports, "WEBHOOK_TIMESTAMP_HEADER", {
167
+ enumerable: true,
168
+ get: function() {
169
+ return WEBHOOK_TIMESTAMP_HEADER;
170
+ }
171
+ });
172
+ Object.defineProperty(exports, "hmacSha256Hex", {
173
+ enumerable: true,
174
+ get: function() {
175
+ return hmacSha256Hex;
176
+ }
177
+ });
178
+ Object.defineProperty(exports, "parseWebhookEvent", {
179
+ enumerable: true,
180
+ get: function() {
181
+ return parseWebhookEvent;
182
+ }
183
+ });
184
+ Object.defineProperty(exports, "signUploadTokenWithApiKey", {
185
+ enumerable: true,
186
+ get: function() {
187
+ return signUploadTokenWithApiKey;
188
+ }
189
+ });
190
+ Object.defineProperty(exports, "stringifyJsonCanonical", {
191
+ enumerable: true,
192
+ get: function() {
193
+ return stringifyJsonCanonical;
194
+ }
195
+ });
196
+ Object.defineProperty(exports, "verifyWebhookSignature", {
197
+ enumerable: true,
198
+ get: function() {
199
+ return verifyWebhookSignature;
200
+ }
201
+ });