@calmlens/sdk 0.16.3 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +398 -163
- package/cjs/BrowserUpload-DZ0Vbmxi.js +10 -0
- package/cjs/BrowserUpload-uZjVFGfP.d.ts +11 -0
- package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/cjs/CalmLensAppClient-DBGmaDlG.js +89 -0
- package/cjs/CalmLensConnection-K1TAD1Z-.js +659 -0
- package/cjs/CalmLensServerClient-C-pxiz0C.js +645 -0
- package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
- package/cjs/FileIcons-Ob003MdS.js +175 -0
- package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
- package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/cjs/ModerationContextLimits-Dj1YAl3G.js +1311 -0
- package/cjs/ReactUpload-DxtFMr06.js +54 -0
- package/cjs/ReactUpload-nZahpmyg.d.ts +17 -0
- package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/cjs/TestTriggers-DcArt0Nu-CvoaONUB.js +1326 -0
- package/cjs/UploadController-BBkN4Ni6.js +618 -0
- package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/cjs/app.d.ts +7 -44
- package/cjs/app.js +11 -89
- package/cjs/elements.d.ts +88 -0
- package/cjs/elements.js +555 -0
- package/cjs/file-icons.d.ts +67 -0
- package/cjs/file-icons.js +10 -0
- package/cjs/index-CI4BzN4W.d.ts +252 -0
- package/cjs/index.d.ts +6 -4
- package/cjs/index.js +9 -4
- package/cjs/react-native.d.ts +56 -0
- package/cjs/react-native.js +495 -0
- package/cjs/react.d.ts +30 -0
- package/cjs/react.js +370 -0
- package/cjs/server.d.ts +12 -191
- package/cjs/server.js +18 -378
- package/cjs/test-inputs.d.ts +2 -2
- package/cjs/test-inputs.js +9 -264
- package/cjs/testing.d.ts +285 -190
- package/cjs/testing.js +2393 -1203
- package/cjs/ui-CgZpgpNo.d.ts +526 -0
- package/cjs/ui-KceuoVCM.js +583 -0
- package/cjs/ui.d.ts +2 -0
- package/cjs/ui.js +27 -0
- package/cjs/vue.d.ts +490 -0
- package/cjs/vue.js +419 -0
- package/esm/BrowserUpload-D7-zNmc0.js +5 -0
- package/esm/BrowserUpload-uZjVFGfP.d.ts +11 -0
- package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/esm/CalmLensAppClient-FVMwrHlH.js +84 -0
- package/esm/CalmLensConnection-iyNQHA8L.js +582 -0
- package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/esm/CalmLensServerClient-fQEYKLw4.js +586 -0
- package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
- package/esm/FileIcons-M6dzjFxd.js +128 -0
- package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
- package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/esm/ModerationContextLimits-D1x_WAtP.js +1114 -0
- package/esm/ReactUpload-D6yBCkcw.js +37 -0
- package/esm/ReactUpload-nZahpmyg.d.ts +17 -0
- package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/esm/TestTriggers-DcArt0Nu-s0AQZuWE.js +1266 -0
- package/esm/UploadController-txp5NuzH.js +589 -0
- package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/esm/app.d.ts +7 -44
- package/esm/app.js +3 -86
- package/esm/elements.d.ts +88 -0
- package/esm/elements.js +528 -0
- package/esm/file-icons.d.ts +67 -0
- package/esm/file-icons.js +1 -0
- package/esm/index-CI4BzN4W.d.ts +252 -0
- package/esm/index.d.ts +6 -4
- package/esm/index.js +1 -1
- package/esm/react-native.d.ts +56 -0
- package/esm/react-native.js +467 -0
- package/esm/react.d.ts +30 -0
- package/esm/react.js +343 -0
- package/esm/server.d.ts +12 -191
- package/esm/server.js +3 -369
- package/esm/styles.css +147 -0
- package/esm/svelte/CalmLensMedia.svelte +134 -0
- package/esm/svelte/CalmLensUploader.svelte +175 -0
- package/esm/svelte/Icon.svelte +7 -0
- package/esm/svelte/index.d.ts +37 -0
- package/esm/svelte/index.js +6 -0
- package/esm/test-inputs.d.ts +2 -2
- package/esm/test-inputs.js +1 -256
- package/esm/testing.d.ts +285 -190
- package/esm/testing.js +2329 -1122
- package/esm/ui-Ca_SpJXf.js +458 -0
- package/esm/ui-CgZpgpNo.d.ts +526 -0
- package/esm/ui.d.ts +2 -0
- package/esm/ui.js +3 -0
- package/esm/vue.d.ts +490 -0
- package/esm/vue.js +392 -0
- package/package.json +110 -11
- package/cjs/CalmLensConnection-BNUHp_AQ.js +0 -234
- package/cjs/CalmLensUtils-o171ownK.js +0 -699
- package/cjs/CalmLensVersion-DwhgxQK5.js +0 -480
- package/cjs/CalmLensWebhooks-BKrq30Yp.js +0 -201
- package/cjs/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
- package/cjs/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
- package/cjs/WebhookPayload-CJoIkGBD.d.ts +0 -11753
- package/esm/CalmLensConnection-XTqmlwzG.js +0 -193
- package/esm/CalmLensUtils-DsF8Aa_x.js +0 -574
- package/esm/CalmLensVersion-DcifRKsx.js +0 -415
- package/esm/CalmLensWebhooks-BvGyk5d3.js +0 -148
- package/esm/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
- package/esm/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
- package/esm/WebhookPayload-CJoIkGBD.d.ts +0 -11753
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { A as invalidArgument, O as CalmLensError, b as validateServerClientOptions, c as DEFAULT_CDN_BASE_URL, d as pollForDecision, g as transformSelector, h as toTextAssetOptions, j as invalidConfig, k as SDK_ERROR_CODES, l as encodePathSegment, o as isBrowserRuntime, s as isReactNativeRuntime, u as getAssetUrl, v as validateBatchAssetsOptions, x as validateSignedUrlOptions, y as validateRequestOptions } from "./ModerationContextLimits-D1x_WAtP.js";
|
|
2
|
+
import { a as getMockHooks, c as prepareSubmitAssetOptions, l as resolveConnectionOptions, o as invalidPayload, r as expectAsset, s as newIdempotencyKey, t as CalmLensConnection } from "./CalmLensConnection-iyNQHA8L.js";
|
|
3
|
+
import { n as sha256Hex, r as timingSafeEqual, t as hmacSha256Hex } from "./CryptoUtils-B_x8lMIx-DhuWGzqn.js";
|
|
4
|
+
// src/contract/JsonUtils.js
|
|
5
|
+
const stringifyJsonCanonical = (value) => JSON.stringify(value, (_key, nestedValue) => {
|
|
6
|
+
if (!nestedValue || typeof nestedValue !== "object" || Array.isArray(nestedValue)) return nestedValue;
|
|
7
|
+
return Object.fromEntries(Object.keys(nestedValue).sort().map((key) => [key, nestedValue[key]]));
|
|
8
|
+
});
|
|
9
|
+
// src/contract/UploadToken.js
|
|
10
|
+
const UPLOAD_TOKEN_PREFIX = "clut_";
|
|
11
|
+
const UPLOAD_TOKEN_MAX_TTL_SECONDS = 3600;
|
|
12
|
+
const SIGNING_CONTEXT = "calmlens-upload-token:v1:";
|
|
13
|
+
const toBase64Url = (value) => btoa(String.fromCharCode(...new TextEncoder().encode(value))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
14
|
+
/**
|
|
15
|
+
* The part of an API key a token names so the backend can find the key's hash. The same rule as
|
|
16
|
+
* the stored `keyPrefix`: the fixed `clsk_<region>_` part plus four random characters.
|
|
17
|
+
*/
|
|
18
|
+
const uploadTokenKeyPrefix = (apiKey) => apiKey.substring(0, apiKey.startsWith("clsk_") ? 13 : 8);
|
|
19
|
+
/** The signing secret: the SHA-256 of the API key, which the backend stores instead of the key. */
|
|
20
|
+
const signingSecret = (apiKey) => sha256Hex(apiKey);
|
|
21
|
+
/** Signs `claims` with `apiKey` and returns the token. */
|
|
22
|
+
const signUploadToken = async (claims, apiKey) => {
|
|
23
|
+
const body = toBase64Url(JSON.stringify(claims));
|
|
24
|
+
const signature = await hmacSha256Hex(await signingSecret(apiKey), SIGNING_CONTEXT + body);
|
|
25
|
+
return `${UPLOAD_TOKEN_PREFIX}${body}.${signature}`;
|
|
26
|
+
};
|
|
27
|
+
// src/client/CalmLensUploadTokens.ts
|
|
28
|
+
/**
|
|
29
|
+
* Signs a single-use upload token with `apiKey`, locally. `CalmLensServerClient` and
|
|
30
|
+
* `MockCalmLensServerClient` share it so both check options the same way. `@calmlens/sdk/testing`
|
|
31
|
+
* exports it as `signUploadToken`, whose fixed `now` (milliseconds since the epoch) and `nonce`
|
|
32
|
+
* give the same token every time, for comparing with known tokens in tests.
|
|
33
|
+
*/
|
|
34
|
+
const signUploadTokenWithApiKey = async (apiKey, options, { now = Date.now(), nonce = crypto.randomUUID() } = {}) => {
|
|
35
|
+
if (!options || typeof options !== "object") throw invalidArgument("Options are required");
|
|
36
|
+
const { uploadPresetId, externalId, expiresInSeconds = 600 } = options;
|
|
37
|
+
encodePathSegment(uploadPresetId, "Upload preset ID");
|
|
38
|
+
if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 3600) throw invalidArgument(`expiresInSeconds must be a whole number from 1 to ${UPLOAD_TOKEN_MAX_TTL_SECONDS}`);
|
|
39
|
+
if (externalId !== void 0 && (typeof externalId !== "string" || !externalId)) throw invalidArgument("externalId must be a non-empty string");
|
|
40
|
+
const expiresAt = now + expiresInSeconds * 1e3;
|
|
41
|
+
return {
|
|
42
|
+
token: await signUploadToken({
|
|
43
|
+
v: 1,
|
|
44
|
+
u: uploadPresetId,
|
|
45
|
+
k: uploadTokenKeyPrefix(apiKey),
|
|
46
|
+
e: expiresAt,
|
|
47
|
+
n: nonce,
|
|
48
|
+
...externalId === void 0 ? {} : { x: externalId }
|
|
49
|
+
}, apiKey),
|
|
50
|
+
expiresAt
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
// src/client/CalmLensWebhooks.ts
|
|
54
|
+
const WEBHOOK_DELIVERY_HEADER = "x-calmlens-delivery";
|
|
55
|
+
const WEBHOOK_TIMESTAMP_HEADER = "x-calmlens-timestamp";
|
|
56
|
+
const WEBHOOK_SIGNATURE_HEADER = "x-calmlens-signature";
|
|
57
|
+
/** Deliveries whose timestamp is further than this from the receiver's clock are rejected. */
|
|
58
|
+
const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
59
|
+
const readHeader = (headers, name) => {
|
|
60
|
+
if (!headers) return void 0;
|
|
61
|
+
if (typeof headers.get === "function") return headers.get(name) ?? void 0;
|
|
62
|
+
const record = headers;
|
|
63
|
+
const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === name);
|
|
64
|
+
const value = key === void 0 ? void 0 : record[key];
|
|
65
|
+
return Array.isArray(value) ? value.join(",") : value ?? void 0;
|
|
66
|
+
};
|
|
67
|
+
/** The raw body as text. Parsed objects are refused: re-serializing JSON changes the signed bytes. */
|
|
68
|
+
const payloadToText = (payload) => {
|
|
69
|
+
if (typeof payload === "string") return payload;
|
|
70
|
+
if (payload instanceof ArrayBuffer || ArrayBuffer.isView(payload)) {
|
|
71
|
+
const view = ArrayBuffer.isView(payload) ? new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) : new Uint8Array(payload);
|
|
72
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(view);
|
|
73
|
+
}
|
|
74
|
+
throw invalidArgument("Webhook payload must be the raw request body as a string, Uint8Array/Buffer or ArrayBuffer, not parsed JSON");
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Verifies a webhook delivery: `x-calmlens-signature` must contain a `v1=` HMAC-SHA256 of
|
|
78
|
+
* `"<x-calmlens-timestamp>.<raw body>"`, and the timestamp must be within `toleranceSeconds`
|
|
79
|
+
* (default 300) of now. Returns `false` for anything missing, malformed or stale, and throws
|
|
80
|
+
* `sdk/invalid-argument` when `payload` is not a raw body (for example, parsed JSON).
|
|
81
|
+
*/
|
|
82
|
+
async function verifyWebhookSignature(options) {
|
|
83
|
+
const { payload, secret, toleranceSeconds = 300, now, headers } = options;
|
|
84
|
+
const body = payloadToText(payload);
|
|
85
|
+
const signature = options.signature ?? readHeader(headers, "x-calmlens-signature");
|
|
86
|
+
const timestamp = options.timestamp ?? readHeader(headers, "x-calmlens-timestamp");
|
|
87
|
+
if (!secret || !signature || timestamp === null || timestamp === void 0) return false;
|
|
88
|
+
const timestampText = String(timestamp).trim();
|
|
89
|
+
if (!/^\d+$/.test(timestampText)) return false;
|
|
90
|
+
const nowMs = now === void 0 ? Date.now() : now instanceof Date ? now.getTime() : now;
|
|
91
|
+
if (Math.abs(Math.floor(nowMs / 1e3) - Number(timestampText)) > toleranceSeconds) return false;
|
|
92
|
+
const candidates = signature.split(",").map((part) => part.trim()).filter((part) => part.startsWith("v1=")).map((part) => part.slice(3).toLowerCase());
|
|
93
|
+
if (candidates.length === 0) return false;
|
|
94
|
+
const expected = await hmacSha256Hex(secret, `${timestampText}.${body}`);
|
|
95
|
+
let matched = false;
|
|
96
|
+
for (const candidate of candidates) if (timingSafeEqual(expected, candidate)) matched = true;
|
|
97
|
+
return matched;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Verifies a webhook delivery and parses its body. Throws `sdk/invalid-webhook-signature`
|
|
101
|
+
* when the signature or timestamp does not verify, and `sdk/invalid-webhook-payload` when a
|
|
102
|
+
* verified body is not a webhook event. Unknown event types, fact values and fields remain available.
|
|
103
|
+
*
|
|
104
|
+
* Event types and fact keys added to CalmLens after this SDK version are returned as they are,
|
|
105
|
+
* so keep a default branch when you switch on `type`.
|
|
106
|
+
*/
|
|
107
|
+
async function parseWebhookEvent(options) {
|
|
108
|
+
if (!await verifyWebhookSignature(options)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_SIGNATURE, { message: "Webhook signature or timestamp did not verify" });
|
|
109
|
+
let event;
|
|
110
|
+
try {
|
|
111
|
+
event = JSON.parse(payloadToText(options.payload));
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, {
|
|
114
|
+
message: "Webhook body is not JSON",
|
|
115
|
+
cause
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!isWebhookEvent(event)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_WEBHOOK_PAYLOAD, { message: "Webhook body is not a CalmLens event" });
|
|
119
|
+
return event;
|
|
120
|
+
}
|
|
121
|
+
const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
122
|
+
const isOptional = (value, type, nullable) => value === void 0 || nullable && value === null || typeof value === type;
|
|
123
|
+
/**
|
|
124
|
+
* Whether `value` is a webhook body, by the rules of shared/WebhookPayload's
|
|
125
|
+
* RECEIVED_WEBHOOK_EVENT_PAYLOAD_SCHEMA: the fields CalmLens always sends, and the types of the
|
|
126
|
+
* other known fields when present. Each fact needs a string `key` and a `value` (any JSON
|
|
127
|
+
* value, `null` included). Unknown fields and fact values are accepted.
|
|
128
|
+
*/
|
|
129
|
+
const isWebhookEvent = (value) => {
|
|
130
|
+
if (!isObject(value) || typeof value.id !== "string" || typeof value.type !== "string" || typeof value.projectId !== "string" || typeof value.createdAt !== "number") return false;
|
|
131
|
+
const { severity, title, description, assetId, asset, facts } = value;
|
|
132
|
+
return isOptional(severity, "string", false) && isOptional(title, "string", false) && isOptional(description, "string", true) && isOptional(assetId, "string", true) && (asset === void 0 || asset === null || isObject(asset) && typeof asset.id === "string") && (facts === void 0 || Array.isArray(facts) && facts.every((fact) => isObject(fact) && typeof fact.key === "string" && "value" in fact && isOptional(fact.url, "string", false)));
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Whether a webhook event is a test send from the portal or the CLI rather than a real event: it
|
|
136
|
+
* carries the registered `{ key: "test", value: true }` fact.
|
|
137
|
+
*/
|
|
138
|
+
const isTestWebhookEvent = (event) => Array.isArray(event?.facts) && event.facts.some((fact) => fact?.key === "test" && fact.value === true);
|
|
139
|
+
const getEnsureLensCacheKey = (options) => stringifyJsonCanonical({
|
|
140
|
+
identifier: options.identifier,
|
|
141
|
+
name: options.name,
|
|
142
|
+
description: options.description ?? null,
|
|
143
|
+
type: options.type,
|
|
144
|
+
active: options.active,
|
|
145
|
+
transforms: options.transforms ?? null,
|
|
146
|
+
version: options.version
|
|
147
|
+
});
|
|
148
|
+
const requireId = (value, name) => encodePathSegment(value, name);
|
|
149
|
+
/**
|
|
150
|
+
* Iterators stop rather than loop: a page that says more follow must not hand back a cursor this
|
|
151
|
+
* iteration has already sent, which would repeat the same pages forever.
|
|
152
|
+
*/
|
|
153
|
+
const assertNewCursor = (sent, cursor) => {
|
|
154
|
+
if (sent.has(cursor)) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, {
|
|
155
|
+
message: "The API returned a cursor it had already returned, with more pages to follow",
|
|
156
|
+
details: { cursor }
|
|
157
|
+
});
|
|
158
|
+
sent.add(cursor);
|
|
159
|
+
};
|
|
160
|
+
const inspectSymbol = Symbol.for("nodejs.util.inspect.custom");
|
|
161
|
+
/**
|
|
162
|
+
* The CalmLens API client for your server, authenticated with a project API key. Create one per
|
|
163
|
+
* project and reuse it; it holds no connections. Every method takes one options object and
|
|
164
|
+
* throws `CalmLensError` on failure. Browsers and apps upload with `CalmLensAppClient` instead.
|
|
165
|
+
*/
|
|
166
|
+
var CalmLensServerClient = class CalmLensServerClient {
|
|
167
|
+
#apiKey;
|
|
168
|
+
#settings;
|
|
169
|
+
#lenses;
|
|
170
|
+
#connection;
|
|
171
|
+
/**
|
|
172
|
+
* Verifies that a webhook delivery came from CalmLens: checks the `v1=` HMAC-SHA256 in
|
|
173
|
+
* `x-calmlens-signature` over `"<x-calmlens-timestamp>.<raw body>"` and rejects timestamps
|
|
174
|
+
* more than `toleranceSeconds` (default 300) from now, which stops replayed deliveries.
|
|
175
|
+
* Also exported as `verifyWebhookSignature`; `parseWebhookEvent` verifies and parses.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* const payload = await request.text();
|
|
180
|
+
* const isValid = await CalmLensServerClient.verifyWebhookSignature({
|
|
181
|
+
* payload,
|
|
182
|
+
* headers: request.headers,
|
|
183
|
+
* secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
184
|
+
* });
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
static async verifyWebhookSignature(options) {
|
|
188
|
+
return verifyWebhookSignature(options);
|
|
189
|
+
}
|
|
190
|
+
constructor(options, lenses) {
|
|
191
|
+
validateServerClientOptions(options);
|
|
192
|
+
if ((isBrowserRuntime() || isReactNativeRuntime()) && !options.dangerouslyAllowBrowser) throw invalidConfig("CalmLensServerClient is running in a browser or app, where anyone could read the API key. Call CalmLens from your server, upload from the browser or app with CalmLensAppClient, or set dangerouslyAllowBrowser: true for trusted internal tools.");
|
|
193
|
+
const { apiKey, ...rest } = options;
|
|
194
|
+
this.#apiKey = apiKey;
|
|
195
|
+
this.#settings = {
|
|
196
|
+
...resolveConnectionOptions(rest),
|
|
197
|
+
cdnBaseUrl: (rest.cdnBaseUrl ?? getMockHooks(rest)?.defaultCdnBaseUrl ?? DEFAULT_CDN_BASE_URL).replace(/\/+$/, "")
|
|
198
|
+
};
|
|
199
|
+
this.#lenses = lenses ?? {
|
|
200
|
+
ensuredLenses: /* @__PURE__ */ new Map(),
|
|
201
|
+
pendingEnsureLenses: /* @__PURE__ */ new Map()
|
|
202
|
+
};
|
|
203
|
+
this.#connection = new CalmLensConnection(this.#settings, apiKey);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A client with different cancellation, deadlines or retries for the calls made through it,
|
|
207
|
+
* sharing this client's credentials and lens cache. This client is unchanged.
|
|
208
|
+
*/
|
|
209
|
+
withRequestOptions(options) {
|
|
210
|
+
validateRequestOptions(options);
|
|
211
|
+
return new CalmLensServerClient({
|
|
212
|
+
...this.#settings,
|
|
213
|
+
...options,
|
|
214
|
+
apiKey: this.#apiKey
|
|
215
|
+
}, this.#lenses);
|
|
216
|
+
}
|
|
217
|
+
/** Keeps the API key out of `console.log`, `util.inspect` and `JSON.stringify`. */
|
|
218
|
+
[inspectSymbol]() {
|
|
219
|
+
const { projectId, baseUrl } = this.#settings;
|
|
220
|
+
return `CalmLensServerClient { projectId: ${JSON.stringify(projectId)}, baseUrl: ${JSON.stringify(baseUrl)} }`;
|
|
221
|
+
}
|
|
222
|
+
toJSON() {
|
|
223
|
+
const { projectId, baseUrl, cdnBaseUrl } = this.#settings;
|
|
224
|
+
return {
|
|
225
|
+
projectId,
|
|
226
|
+
baseUrl,
|
|
227
|
+
cdnBaseUrl
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
get projectPath() {
|
|
231
|
+
return `/projects/${encodeURIComponent(this.#settings.projectId)}`;
|
|
232
|
+
}
|
|
233
|
+
request(method, path, options = {}) {
|
|
234
|
+
return this.#connection.json(method, path, options);
|
|
235
|
+
}
|
|
236
|
+
async submitAsset(options, queue) {
|
|
237
|
+
const body = prepareSubmitAssetOptions(options, { deferUploadChecks: this.#connection.deferUploadChecks });
|
|
238
|
+
const uploadsPath = `${this.projectPath}/assets`;
|
|
239
|
+
if ("content" in body || "part" in body) return expectAsset(await this.#connection.submitRawUpload(uploadsPath, body, queue));
|
|
240
|
+
const { idempotencyKey, ...fields } = body;
|
|
241
|
+
const data = await this.#connection.json("POST", uploadsPath, {
|
|
242
|
+
query: { queue },
|
|
243
|
+
body: fields,
|
|
244
|
+
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
245
|
+
});
|
|
246
|
+
return expectAsset(data);
|
|
247
|
+
}
|
|
248
|
+
get cdn() {
|
|
249
|
+
return {
|
|
250
|
+
/**
|
|
251
|
+
* Builds a CDN URL for asset content or a preview, without a request. Private assets
|
|
252
|
+
* need a signed URL (`assets.createSignedUrl`) or an authenticated request; public
|
|
253
|
+
* age-restricted assets additionally require `viewerClaims.ageConfirmed: true`.
|
|
254
|
+
*/
|
|
255
|
+
getAssetUrl: ({ assetIdentifier, ...options }) => {
|
|
256
|
+
const { projectId, cdnBaseUrl } = this.#settings;
|
|
257
|
+
return getAssetUrl({
|
|
258
|
+
projectId,
|
|
259
|
+
cdnBaseUrl,
|
|
260
|
+
assetIdentifier,
|
|
261
|
+
...options
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* Fetches asset content or a preview from the CDN with the client's API key, and returns
|
|
266
|
+
* the `Response` as soon as headers arrive, so the body streams. Non-2xx responses are
|
|
267
|
+
* returned, not thrown; `timeoutMs` covers only the wait for headers.
|
|
268
|
+
*/
|
|
269
|
+
serve: async ({ assetIdentifier, init, ...urlOptions }) => {
|
|
270
|
+
const url = this.cdn.getAssetUrl({
|
|
271
|
+
assetIdentifier,
|
|
272
|
+
...urlOptions
|
|
273
|
+
});
|
|
274
|
+
const method = init?.method?.toUpperCase() ?? "GET";
|
|
275
|
+
const { response } = await this.#connection.transport.send({
|
|
276
|
+
method,
|
|
277
|
+
url,
|
|
278
|
+
init,
|
|
279
|
+
timeoutMs: this.#connection.timeoutMs,
|
|
280
|
+
deadline: "headers",
|
|
281
|
+
fetch: this.#settings.cdnFetch
|
|
282
|
+
}, { allowHttpErrors: true });
|
|
283
|
+
return response;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
get text() {
|
|
288
|
+
return {
|
|
289
|
+
/**
|
|
290
|
+
* Moderates text and waits for the decision. Sends `text` as `text/plain` content named
|
|
291
|
+
* `text` unless you pass `name`.
|
|
292
|
+
*/
|
|
293
|
+
classify: async (options) => this.buildAssets().classify(toTextAssetOptions(options)) };
|
|
294
|
+
}
|
|
295
|
+
/** Assets: uploads, moderation and management. */
|
|
296
|
+
get assets() {
|
|
297
|
+
return this.buildAssets();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Signs upload tokens on your server, so a browser or app can upload through an upload preset
|
|
301
|
+
* that requires one. Signing happens locally with your API key: it makes no request.
|
|
302
|
+
*/
|
|
303
|
+
get uploadTokens() {
|
|
304
|
+
return {
|
|
305
|
+
/**
|
|
306
|
+
* Signs a single-use token for one upload through `uploadPresetId`. Give `token` to the
|
|
307
|
+
* browser or app, which passes it to `CalmLensAppClient` as `token`.
|
|
308
|
+
*/
|
|
309
|
+
sign: async (options) => {
|
|
310
|
+
return signUploadTokenWithApiKey(this.#apiKey, options, this.#connection.hooks?.uploadTokenTime());
|
|
311
|
+
} };
|
|
312
|
+
}
|
|
313
|
+
buildAssets() {
|
|
314
|
+
return {
|
|
315
|
+
/** Searches accessible assets by keyword or a natural-language description. */
|
|
316
|
+
search: async (options) => this.request("GET", `${this.projectPath}/assets/search`, { query: options }),
|
|
317
|
+
/** Lists one page of the project's assets, with optional filter and ordering. */
|
|
318
|
+
list: async (options = {}) => this.request("GET", `${this.projectPath}/assets`, { query: options }),
|
|
319
|
+
/**
|
|
320
|
+
* Yields every asset matching `options`, fetching pages as needed. The cursor keeps the position
|
|
321
|
+
* stable when other assets are inserted or deleted.
|
|
322
|
+
*/
|
|
323
|
+
iterate: (options = {}) => this.iteratePages((cursor) => this.buildAssets().list({
|
|
324
|
+
...options,
|
|
325
|
+
cursor
|
|
326
|
+
})),
|
|
327
|
+
/** Retrieves an asset with its status and classification results. */
|
|
328
|
+
retrieve: async ({ assetId }) => this.request("GET", `/assets/${requireId(assetId, "Asset ID")}`),
|
|
329
|
+
/** Updates an asset's editable fields. */
|
|
330
|
+
update: async ({ assetId, ...body }) => this.request("PATCH", `/assets/${requireId(assetId, "Asset ID")}`, { body }),
|
|
331
|
+
/** Lists one page of the assets extracted from a website or multimedia document. */
|
|
332
|
+
listChildren: async ({ assetId, ...options } = {}) => this.request("GET", `/assets/${requireId(assetId, "Asset ID")}/children`, { query: options }),
|
|
333
|
+
/** Yields every child of an asset, fetching pages as needed. */
|
|
334
|
+
iterateChildren: (options) => {
|
|
335
|
+
requireId(options?.assetId, "Asset ID");
|
|
336
|
+
return this.iteratePages((cursor) => this.buildAssets().listChildren({
|
|
337
|
+
...options,
|
|
338
|
+
cursor
|
|
339
|
+
}));
|
|
340
|
+
},
|
|
341
|
+
/**
|
|
342
|
+
* Uploads an asset and queues it for moderation, returning immediately with processing
|
|
343
|
+
* status `processing` (or `uploading` for the first multipart part). Retried safely with an
|
|
344
|
+
* `Idempotency-Key`.
|
|
345
|
+
*/
|
|
346
|
+
create: async (options) => this.submitAsset(options, true),
|
|
347
|
+
/**
|
|
348
|
+
* Uploads an asset and waits for its moderation decision. Audio and video still finish
|
|
349
|
+
* in the background. Retried safely with an `Idempotency-Key`.
|
|
350
|
+
*/
|
|
351
|
+
classify: async (options) => {
|
|
352
|
+
if (options && "content" in options && "plainText" in options.content && (typeof options.content.plainText !== "string" || !options.content.plainText.trim())) throw invalidPayload("Text to classify must be a non-empty string");
|
|
353
|
+
return this.submitAsset(options, false);
|
|
354
|
+
},
|
|
355
|
+
/**
|
|
356
|
+
* Uploads the next part of a multipart upload. The upload's queue mode, chosen when it
|
|
357
|
+
* started, applies when the final part arrives; pass `queue` to override it.
|
|
358
|
+
*/
|
|
359
|
+
uploadPart: async ({ assetId, ...options }) => {
|
|
360
|
+
const encodedAssetId = requireId(assetId, "Asset ID");
|
|
361
|
+
return expectAsset(await this.#connection.uploadPart(`/assets/${encodedAssetId}/part`, options));
|
|
362
|
+
},
|
|
363
|
+
/**
|
|
364
|
+
* Creates a short-lived signed CDN URL, so browsers can load private content without
|
|
365
|
+
* an API key.
|
|
366
|
+
*/
|
|
367
|
+
createSignedUrl: async ({ assetId, ...options }) => {
|
|
368
|
+
const encodedAssetId = requireId(assetId, "Asset ID");
|
|
369
|
+
validateSignedUrlOptions(options);
|
|
370
|
+
return this.request("POST", `/assets/${encodedAssetId}/signed-url`, { body: {
|
|
371
|
+
viewerClaims: options.viewerClaims,
|
|
372
|
+
resource: options.resource,
|
|
373
|
+
expiresInSeconds: options.expiresInSeconds ?? 900,
|
|
374
|
+
lens: options.lens,
|
|
375
|
+
transform: transformSelector(options.transform)
|
|
376
|
+
} });
|
|
377
|
+
},
|
|
378
|
+
/** Moves an asset into a folder, or to the project root with `folderId: null`. */
|
|
379
|
+
move: async ({ assetId, folderId }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/move`, { body: { folderId } }),
|
|
380
|
+
/**
|
|
381
|
+
* Processes an asset that failed (classification status `failed`) again. It returns to
|
|
382
|
+
* `processing` and is
|
|
383
|
+
* moderated like a new upload; its content must still be stored.
|
|
384
|
+
*/
|
|
385
|
+
retry: async ({ assetId }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/retry`),
|
|
386
|
+
/**
|
|
387
|
+
* Records a reviewer's decision, replacing the policy's: the asset's moderation status
|
|
388
|
+
* becomes `decision`, the note joins its review history and the matching `asset.<status>`
|
|
389
|
+
* event is sent. An asset still pending moderation can only be blocked or purged. Purging
|
|
390
|
+
* needs `confirmPurge: true` and permanently deletes the stored content.
|
|
391
|
+
*/
|
|
392
|
+
decide: async ({ assetId, ...body }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/decision`, { body }),
|
|
393
|
+
/**
|
|
394
|
+
* Completes the policy evidence behind a decided asset's moderation decision: the report's
|
|
395
|
+
* `classification.moderation.evidence` becomes `full` and every action stays as
|
|
396
|
+
* decided. With `rerun: true` the asset is moderated again with the project's current policy
|
|
397
|
+
* and returns in `processing`; use it when the policy has changed since the decision
|
|
398
|
+
* (`asset/policy-changed`).
|
|
399
|
+
*/
|
|
400
|
+
explain: async ({ assetId, rerun }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/explain`, { body: rerun ? { rerun } : {} }),
|
|
401
|
+
/**
|
|
402
|
+
* Waits until an asset's `classification.moderation.status` is no longer `pending` (`allowed`,
|
|
403
|
+
* `limited`, `age-restricted`, `blocked` or `purged`), or its `classification.status` is `failed`, and returns that asset, checking every few seconds. Rejects with `sdk/timeout` if there is no
|
|
404
|
+
* decision within `timeoutMs` (default 60000). Webhooks report decisions without polling.
|
|
405
|
+
*/
|
|
406
|
+
waitForDecision: async ({ assetId, ...options }) => {
|
|
407
|
+
requireId(assetId, "Asset ID");
|
|
408
|
+
const client = options.signal ? this.withRequestOptions({ signal: options.signal }) : this;
|
|
409
|
+
return pollForDecision(() => client.assets.retrieve({ assetId }), options, this.#connection.sleep, this.#connection.hooks?.now);
|
|
410
|
+
},
|
|
411
|
+
/**
|
|
412
|
+
* Removes an asset's stored content (source, preview and retained extracted text) while
|
|
413
|
+
* keeping the asset record, metadata and classification results. The returned asset has
|
|
414
|
+
* `content.status` set to `removed` and `content.removalReason` to `requested`; the CDN no longer serves it.
|
|
415
|
+
*/
|
|
416
|
+
deleteContent: async ({ assetId }) => this.request("DELETE", `/assets/${requireId(assetId, "Asset ID")}/content`),
|
|
417
|
+
/** Deletes an asset record together with its stored content. */
|
|
418
|
+
delete: async ({ assetId }) => {
|
|
419
|
+
await this.request("DELETE", `/assets/${requireId(assetId, "Asset ID")}`);
|
|
420
|
+
},
|
|
421
|
+
/**
|
|
422
|
+
* Deletes up to 500 assets (`operation: "delete"`) or removes their stored content
|
|
423
|
+
* (`"remove-content"`) in one request. Every asset must exist in the project and the key
|
|
424
|
+
* needs `assets:delete` for its folder, or nothing is queued. Resolves with
|
|
425
|
+
* `{ queued: true }` once queued; the work runs in the background and records
|
|
426
|
+
* `asset.deleted` or `asset.content-removed` for each asset. Assets under a safety hold
|
|
427
|
+
* are skipped. Not retried: it carries no `Idempotency-Key`.
|
|
428
|
+
*/
|
|
429
|
+
batch: async ({ assetIds, operation }) => {
|
|
430
|
+
validateBatchAssetsOptions({
|
|
431
|
+
assetIds,
|
|
432
|
+
operation
|
|
433
|
+
});
|
|
434
|
+
return this.request("POST", `${this.projectPath}/assets/batch`, { body: {
|
|
435
|
+
assetIds,
|
|
436
|
+
operation
|
|
437
|
+
} });
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
get events() {
|
|
442
|
+
return {
|
|
443
|
+
/**
|
|
444
|
+
* Lists project events after a cursor, oldest first. Call without `cursor` to get a cursor
|
|
445
|
+
* for "now", then poll with each response's `nextCursor`. Webhook deliveries carry the same
|
|
446
|
+
* events reshaped as `{ id, type, createdAt, projectId, data }`.
|
|
447
|
+
*/
|
|
448
|
+
list: async (options = {}) => this.request("GET", `${this.projectPath}/events`, { query: { ...options } }),
|
|
449
|
+
/**
|
|
450
|
+
* Yields pages of events after `cursor` until no more are waiting. Save each page's
|
|
451
|
+
* `nextCursor` to resume later. Without `cursor`, yields one empty page whose `nextCursor`
|
|
452
|
+
* is "now".
|
|
453
|
+
*/
|
|
454
|
+
pages: (options = {}) => this.iterateEventPages(options)
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async *iterateEventPages(options) {
|
|
458
|
+
let cursor = options.cursor;
|
|
459
|
+
const sent = new Set(cursor === void 0 ? [] : [cursor]);
|
|
460
|
+
for (;;) {
|
|
461
|
+
const page = await this.events.list({
|
|
462
|
+
...options,
|
|
463
|
+
cursor
|
|
464
|
+
});
|
|
465
|
+
if (page.hasMore) assertNewCursor(sent, page.nextCursor);
|
|
466
|
+
yield page;
|
|
467
|
+
if (!page.hasMore) return;
|
|
468
|
+
cursor = page.nextCursor;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async *iteratePages(listPage) {
|
|
472
|
+
let cursor;
|
|
473
|
+
const sent = /* @__PURE__ */ new Set();
|
|
474
|
+
for (;;) {
|
|
475
|
+
const page = await listPage(cursor);
|
|
476
|
+
const more = !!page.nextCursor && page.hasMore && page.items.length > 0;
|
|
477
|
+
if (more) assertNewCursor(sent, page.nextCursor);
|
|
478
|
+
yield* page.items;
|
|
479
|
+
if (!more) return;
|
|
480
|
+
cursor = page.nextCursor;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
get folders() {
|
|
484
|
+
return {
|
|
485
|
+
/**
|
|
486
|
+
* Lists one page of child folders, sorted by name unless `ordering` says otherwise. Pass
|
|
487
|
+
* `nextCursor` as `cursor` for the next page; fetch a branch when it is opened.
|
|
488
|
+
*/
|
|
489
|
+
list: async (options = {}) => this.request("GET", `${this.projectPath}/folders`, { query: options }),
|
|
490
|
+
retrieve: async ({ folderId }) => this.request("GET", `/folders/${requireId(folderId, "Folder ID")}`),
|
|
491
|
+
/** Creates a folder at the project root or inside `parentId`. */
|
|
492
|
+
create: async (options) => this.request("POST", `${this.projectPath}/folders`, { body: options }),
|
|
493
|
+
update: async ({ folderId, ...body }) => this.request("PATCH", `/folders/${requireId(folderId, "Folder ID")}`, { body }),
|
|
494
|
+
/** Deletes an empty folder; throws `folder/not-empty` otherwise. */
|
|
495
|
+
delete: async ({ folderId }) => {
|
|
496
|
+
await this.request("DELETE", `/folders/${requireId(folderId, "Folder ID")}`);
|
|
497
|
+
},
|
|
498
|
+
/** Moves a folder under `parentId`, or to the project root with `parentId: null`. */
|
|
499
|
+
move: async ({ folderId, parentId }) => this.request("POST", `/folders/${requireId(folderId, "Folder ID")}/move`, { body: { parentId } })
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Hash lists hold PDQ hashes of images the project never wants again. Image uploads are
|
|
504
|
+
* compared with every list: `flag` lists record matches, `block` lists also block close ones.
|
|
505
|
+
*/
|
|
506
|
+
get hashLists() {
|
|
507
|
+
return {
|
|
508
|
+
/** Lists one page of the project's hash lists, newest first. */
|
|
509
|
+
list: async (options = {}) => this.request("GET", `${this.projectPath}/hash-lists`, { query: options }),
|
|
510
|
+
retrieve: async ({ hashListId }) => this.request("GET", `/hash-lists/${requireId(hashListId, "Hash list ID")}`),
|
|
511
|
+
create: async (options) => this.request("POST", `${this.projectPath}/hash-lists`, { body: options }),
|
|
512
|
+
/** Renames a list or changes its action; the new action applies to later uploads. */
|
|
513
|
+
update: async ({ hashListId, ...body }) => this.request("PATCH", `/hash-lists/${requireId(hashListId, "Hash list ID")}`, { body }),
|
|
514
|
+
/** Deletes the list and every entry in it. */
|
|
515
|
+
delete: async ({ hashListId }) => {
|
|
516
|
+
await this.request("DELETE", `/hash-lists/${requireId(hashListId, "Hash list ID")}`);
|
|
517
|
+
},
|
|
518
|
+
/** Lists one page of a list's entries, newest first. */
|
|
519
|
+
listEntries: async ({ hashListId, ...query }) => this.request("GET", `/hash-lists/${requireId(hashListId, "Hash list ID")}/entries`, { query }),
|
|
520
|
+
/** Adds a hash, or an image asset's hash; a hash already in the list returns its entry. */
|
|
521
|
+
addEntry: async ({ hashListId, ...body }) => this.request("POST", `/hash-lists/${requireId(hashListId, "Hash list ID")}/entries`, { body }),
|
|
522
|
+
removeEntry: async ({ entryId }) => {
|
|
523
|
+
await this.request("DELETE", `/hash-list-entries/${requireId(entryId, "Hash list entry ID")}`);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Create a named lens at any positive version, or update it with a greater version.
|
|
529
|
+
* Supplying the same definition and version is idempotent.
|
|
530
|
+
*/
|
|
531
|
+
async ensureLens(options) {
|
|
532
|
+
if (!options?.identifier) throw invalidArgument("Lens identifier is required");
|
|
533
|
+
const [lens] = await this.ensureLenses({ lenses: [options] });
|
|
534
|
+
return lens;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Create or update several named lenses. New definitions are sent in one API request, or in
|
|
538
|
+
* consecutive requests of at most {@link ENSURE_LENSES_MAX_PER_REQUEST} definitions, in order.
|
|
539
|
+
* Each lens follows the same versioning and idempotency rules as ensureLens().
|
|
540
|
+
*/
|
|
541
|
+
async ensureLenses({ lenses }) {
|
|
542
|
+
if (!Array.isArray(lenses) || lenses.length === 0) throw invalidArgument("At least one lens is required");
|
|
543
|
+
if (lenses.some((option) => !option?.identifier)) throw invalidArgument("Lens identifier is required");
|
|
544
|
+
const { ensuredLenses, pendingEnsureLenses } = this.#lenses;
|
|
545
|
+
const missingOptions = [];
|
|
546
|
+
const missingKeys = /* @__PURE__ */ new Set();
|
|
547
|
+
for (const option of lenses) {
|
|
548
|
+
const cacheKey = getEnsureLensCacheKey(option);
|
|
549
|
+
if (!ensuredLenses.has(cacheKey) && !missingKeys.has(cacheKey)) {
|
|
550
|
+
missingKeys.add(cacheKey);
|
|
551
|
+
missingOptions.push(option);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (missingOptions.length > 0) {
|
|
555
|
+
const batchCacheKey = JSON.stringify([...missingKeys].sort());
|
|
556
|
+
let pending = pendingEnsureLenses.get(batchCacheKey);
|
|
557
|
+
if (!pending) {
|
|
558
|
+
pending = this.submitEnsureLenses(missingOptions);
|
|
559
|
+
pendingEnsureLenses.set(batchCacheKey, pending);
|
|
560
|
+
pending.then(() => pendingEnsureLenses.delete(batchCacheKey), () => pendingEnsureLenses.delete(batchCacheKey));
|
|
561
|
+
}
|
|
562
|
+
await pending;
|
|
563
|
+
}
|
|
564
|
+
return lenses.map((option) => {
|
|
565
|
+
const lens = ensuredLenses.get(getEnsureLensCacheKey(option));
|
|
566
|
+
if (!lens) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `CalmLens did not return lens "${option.identifier}"` });
|
|
567
|
+
return lens;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async submitEnsureLenses(options) {
|
|
571
|
+
for (let start = 0; start < options.length; start += 100) await this.submitEnsureLensesChunk(options.slice(start, start + 100));
|
|
572
|
+
}
|
|
573
|
+
async submitEnsureLensesChunk(options) {
|
|
574
|
+
const result = await this.request("PUT", `${this.projectPath}/lenses`, { body: { lenses: options } });
|
|
575
|
+
const returned = Array.isArray(result?.lenses) ? result.lenses : [];
|
|
576
|
+
if (returned.length !== options.length) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `Expected ${options.length} lenses but CalmLens returned ${returned.length}` });
|
|
577
|
+
const { ensuredLenses } = this.#lenses;
|
|
578
|
+
for (const [index, lens] of returned.entries()) {
|
|
579
|
+
const optionsForLens = options[index];
|
|
580
|
+
if (lens?.identifier !== optionsForLens.identifier) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `Expected lens "${optionsForLens.identifier}" at position ${index}` });
|
|
581
|
+
for (const [cacheKey, cachedLens] of ensuredLenses) if (cachedLens.identifier === optionsForLens.identifier) ensuredLenses.delete(cacheKey);
|
|
582
|
+
ensuredLenses.set(getEnsureLensCacheKey(optionsForLens), lens);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
export { WEBHOOK_TIMESTAMP_HEADER as a, verifyWebhookSignature as c, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS as i, signUploadTokenWithApiKey as l, WEBHOOK_DELIVERY_HEADER as n, isTestWebhookEvent as o, WEBHOOK_SIGNATURE_HEADER as r, parseWebhookEvent as s, CalmLensServerClient as t, stringifyJsonCanonical as u };
|