@calmlens/sdk 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -2
- package/cjs/CalmLensTypes-BpmT60j-.d.ts +3522 -0
- package/cjs/CalmLensUtils-DA1_j5-P.js +578 -0
- package/cjs/index.d.ts +2751 -0
- package/cjs/index.js +1252 -0
- package/cjs/package.json +3 -0
- package/cjs/testing.d.ts +190 -0
- package/cjs/testing.js +814 -0
- package/esm/CalmLensTypes-BpmT60j-.d.ts +3522 -0
- package/esm/CalmLensUtils-UthMaLx_.js +431 -0
- package/esm/index.d.ts +2751 -0
- package/esm/index.js +1240 -0
- package/esm/package.json +3 -0
- package/esm/testing.d.ts +190 -0
- package/esm/testing.js +808 -0
- package/package.json +87 -4
package/README.md
CHANGED
|
@@ -1,3 +1,655 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @calmlens/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Official JavaScript/TypeScript SDK for CalmLens API
|
|
4
|
+
|
|
5
|
+
CalmLens provides content moderation and classification for text, images, documents and websites, plus optional storage and CDN delivery. This SDK makes it easy to integrate CalmLens into your JavaScript/TypeScript applications.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @calmlens/sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js 22 or later (the minimum its dependencies support), or another runtime with `fetch`, Web Crypto and `AbortSignal.any`, such as Bun or Deno.
|
|
14
|
+
|
|
15
|
+
Every public SDK operation accepts one options object. This keeps call sites stable as optional fields are added.
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
Create an API key under **Developer → API Keys** in the CalmLens portal. The same page shows your **Project ID**. Keep both on your server.
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { CalmLensClient } from "@calmlens/sdk";
|
|
23
|
+
|
|
24
|
+
const client = new CalmLensClient({
|
|
25
|
+
apiKey: process.env.CALMLENS_API_KEY!,
|
|
26
|
+
projectId: process.env.CALMLENS_PROJECT_ID!,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Text (comments, captions, bios): waits for the decision
|
|
30
|
+
const comment = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
|
|
31
|
+
|
|
32
|
+
if (comment.status === "approved") {
|
|
33
|
+
// publish it; if comment.report?.flagged is true, also queue it for review
|
|
34
|
+
} else {
|
|
35
|
+
// "rejected": don't publish it
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Images and documents: queued, resolved by webhook or assets.retrieve()
|
|
39
|
+
const image = await client.assets.create({
|
|
40
|
+
name: "photo.jpg",
|
|
41
|
+
content: { data: file, type: "image/jpeg" },
|
|
42
|
+
externalId: "post-7",
|
|
43
|
+
});
|
|
44
|
+
console.log(image.status); // "pending"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| `status` | `report.flagged` | Meaning |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `approved` | `false` | Safe to show. |
|
|
50
|
+
| `approved` | `true` | Show it, but your policy asked to be alerted. |
|
|
51
|
+
| `rejected` | `true` | Your policy hid or purged it. Don't show it. |
|
|
52
|
+
| `pending` | — | Still being classified. |
|
|
53
|
+
| `error` | — | Processing failed. |
|
|
54
|
+
|
|
55
|
+
See [How CalmLens works](https://calmlens.com/docs/how-it-works) and [Testing your integration](https://calmlens.com/docs/guides/testing) for test inputs that trigger rejections safely.
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### CalmLensClient
|
|
60
|
+
|
|
61
|
+
The main client class for interacting with the CalmLens API.
|
|
62
|
+
|
|
63
|
+
#### Constructor
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
new CalmLensClient(options: CalmLensClientOptions)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Options:**
|
|
70
|
+
- `apiKey` (string, required): Your CalmLens API key
|
|
71
|
+
- `projectId` (string, required): Your CalmLens project ID
|
|
72
|
+
- `baseUrl` (string, optional): API base URL (defaults to `https://api.calmlens.com/v1`)
|
|
73
|
+
- `cdnBaseUrl` (string, optional): CDN base URL (defaults to `https://cdn.calmlens.com/v1`)
|
|
74
|
+
- `timeoutMs` (positive integer, optional): Deadline for each request, including reading the response body (defaults to `120000`)
|
|
75
|
+
- `signal` (AbortSignal, optional): Cancels every request made by this client
|
|
76
|
+
- `uploadFetch`, `cdnFetch` (optional): `fetch` implementations for content uploads and CDN requests
|
|
77
|
+
- `requestBackend` (optional): An `api-def` request backend for the JSON API, for example `new FetchRequestBackend(customFetch)`
|
|
78
|
+
|
|
79
|
+
Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError`.
|
|
80
|
+
|
|
81
|
+
#### Methods
|
|
82
|
+
|
|
83
|
+
##### assets.create(options)
|
|
84
|
+
|
|
85
|
+
Queue an asset for processing and return the created asset immediately.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
async assets.create(options: SubmitAssetOptions): Promise<Asset>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Options:**
|
|
92
|
+
- `name` (string, required): Name of the asset
|
|
93
|
+
- `content` (optional): Binary or plain-text content and its concrete MIME type, e.g. `{ plainText: "hello", type: "text/plain" }` or `{ data: file, type: file.type }`
|
|
94
|
+
- `url` (string, optional): External URL to fetch instead of sending content
|
|
95
|
+
- `part` (optional): First part of a sequential multipart upload
|
|
96
|
+
- `description` (string, optional): Description of the asset
|
|
97
|
+
- `processingOptions.keepContentAfterProcessing` (boolean, optional): Retain content after processing
|
|
98
|
+
- `visibility` (string, optional): "public" or "private"
|
|
99
|
+
|
|
100
|
+
**Example:**
|
|
101
|
+
```typescript
|
|
102
|
+
// Submit from file content
|
|
103
|
+
const asset = await client.assets.create({
|
|
104
|
+
name: "document.pdf",
|
|
105
|
+
content: {
|
|
106
|
+
base64: "JVBERi0xLjQKJcfs...",
|
|
107
|
+
type: "application/pdf",
|
|
108
|
+
},
|
|
109
|
+
description: "Important document",
|
|
110
|
+
visibility: "private"
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Submit from URL
|
|
114
|
+
const asset = await client.assets.create({
|
|
115
|
+
name: "website-content",
|
|
116
|
+
url: "https://example.com/page",
|
|
117
|
+
description: "Web page to moderate"
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
##### assets.moderate(options) and text.moderate(options)
|
|
122
|
+
|
|
123
|
+
Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.moderate({ text, name?, externalId?, ... })` is a shortcut that sends `text` as `text/plain` content named `text` by default.
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
async assets.moderate(options: SubmitAssetOptions): Promise<Asset>
|
|
127
|
+
async text.moderate(options: ModerateTextOptions): Promise<Asset>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Both resolve to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const asset = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
|
|
134
|
+
asset.id; // string
|
|
135
|
+
asset.status; // "approved" | "rejected" | "error"; "pending" for audio and video, which always run in the background
|
|
136
|
+
asset.type; // "plaintext-document" for text
|
|
137
|
+
asset.report?.flagged; // boolean
|
|
138
|
+
asset.report?.score; // number, the overall score
|
|
139
|
+
asset.report?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
|
|
140
|
+
asset.moderationAction; // the policy action applied, if any
|
|
141
|
+
asset.createdAt; // milliseconds since the Unix epoch
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Always present: `id`, `projectId`, `name`, `type`, `status`, `createdAt` and `updatedAt`. Every other field may be `null` or missing: `report` is absent when processing failed (`status: "error"`) or has not finished, `moderationAction` when no policy action applied, `externalId`, `description`, `metadata`, `folderId`, `visibility` and `processingOptions` when you did not set them, and `sizeInBytes`, `fileFormat`, `imageData`, `documentData`, `previewImage` and `extractedText` when they do not apply to the content. Inside a report, a category's `score` and `likelihood` exist only when its `status` is `evaluated` or `partial` (see [Moderation Reports](#moderation-reports)).
|
|
145
|
+
|
|
146
|
+
##### assets.retrieve(options)
|
|
147
|
+
|
|
148
|
+
Retrieve details of a specific asset.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
async assets.retrieve(options: { assetId: string }): Promise<Asset>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Example:**
|
|
155
|
+
```typescript
|
|
156
|
+
const asset = await client.assets.retrieve({ assetId: "asset-uuid-here" });
|
|
157
|
+
console.log(asset.status);
|
|
158
|
+
console.log(asset.report); // Classification results
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
##### ensureLens(options)
|
|
162
|
+
|
|
163
|
+
Create a project-scoped named lens, or update it safely. A lens can start at any positive version. Repeating the exact same version and definition is idempotent; any other update must use a version greater than the current one. A stale or conflicting version throws `lens/version-conflict`.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const lens = await client.ensureLens({
|
|
167
|
+
identifier: "picmagic-normal",
|
|
168
|
+
name: "PicMagic normal",
|
|
169
|
+
type: "image",
|
|
170
|
+
active: true,
|
|
171
|
+
version: 1,
|
|
172
|
+
transforms: [
|
|
173
|
+
{
|
|
174
|
+
type: "imageTransform",
|
|
175
|
+
options: { fit: "scale-down", width: 1024, format: "png" },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const image = await client.cdn.serve({
|
|
181
|
+
assetIdentifier: assetId,
|
|
182
|
+
lens: lens.identifier,
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
For several lenses, use `ensureLenses()` to perform all ensures in one request. Each lens keeps the same independent versioning rules.
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
const [normal, uhd] = await client.ensureLenses({ lenses: [normalLens, uhdLens] });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The CDN uses named lenses with an `@` prefix in URLs, such as `/content/@picmagic-normal`. The SDK adds it automatically when you supply `lens`. Use `transform` for one-off direct transforms, for example `client.cdn.serve({ assetIdentifier: assetId, transform: "imageTransform[format=png,width=1024]" })`. Supply either `lens` or `transform`, not both. The resolved lens version is part of CalmLens's CDN cache and ETag identity, so changing a lens with the next version invalidates prior derived output.
|
|
193
|
+
|
|
194
|
+
##### assets.list(options)
|
|
195
|
+
|
|
196
|
+
List assets in your project with pagination.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
async assets.list(options: {
|
|
200
|
+
pageIndex?: number;
|
|
201
|
+
pageSize?: number;
|
|
202
|
+
ordering?: Array<{ field: string; direction: "ASC" | "DESC" }>;
|
|
203
|
+
filter?: string | null;
|
|
204
|
+
}): Promise<{
|
|
205
|
+
items: Asset[];
|
|
206
|
+
total: number;
|
|
207
|
+
hasMore: boolean;
|
|
208
|
+
pageIndex: number;
|
|
209
|
+
pageSize: number;
|
|
210
|
+
}>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Example:**
|
|
214
|
+
```typescript
|
|
215
|
+
const result = await client.assets.list({
|
|
216
|
+
pageIndex: 0,
|
|
217
|
+
pageSize: 20
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log(`Found ${result.total} assets`);
|
|
221
|
+
result.items.forEach(asset => {
|
|
222
|
+
console.log(`${asset.name}: ${asset.status}`);
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
##### assets.listChildren(options)
|
|
227
|
+
|
|
228
|
+
List assets extracted from a website or multimedia document.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
const children = await client.assets.listChildren({
|
|
232
|
+
assetId: parentAssetId,
|
|
233
|
+
pageIndex: 0,
|
|
234
|
+
pageSize: 20,
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
##### assets.createSignedUrl(options)
|
|
239
|
+
|
|
240
|
+
Create a short-lived URL for private CDN content or previews.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
const signed = await client.assets.createSignedUrl({
|
|
244
|
+
assetId,
|
|
245
|
+
resource: "preview",
|
|
246
|
+
lens: "thumbnail",
|
|
247
|
+
expiresInSeconds: 900,
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
##### assets.move(options)
|
|
252
|
+
|
|
253
|
+
Move an asset into a folder, or to the project root by passing `null`.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
// Move asset to a folder
|
|
257
|
+
await client.assets.move({
|
|
258
|
+
assetId: "asset-uuid",
|
|
259
|
+
folderId: "folder-uuid",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Move asset to project root
|
|
263
|
+
await client.assets.move({
|
|
264
|
+
assetId: "asset-uuid",
|
|
265
|
+
folderId: null,
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
##### assets.retry(options)
|
|
270
|
+
|
|
271
|
+
Process an asset that failed (status `error`) again. It returns to `pending` and is moderated like a
|
|
272
|
+
new upload, so handle the result the same way (webhook or polling). Its content must still be stored:
|
|
273
|
+
otherwise this throws `asset/content-unavailable`, and you need to upload it again.
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
const asset = await client.assets.retry({ assetId: "asset-uuid" });
|
|
277
|
+
// asset.status === "pending"
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
##### folders.list()
|
|
281
|
+
|
|
282
|
+
List all folders in the current project.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
const folders = await client.folders.list();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
##### folders.retrieve(options)
|
|
289
|
+
|
|
290
|
+
Retrieve details of a specific folder by ID.
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
const folder = await client.folders.retrieve({ folderId: "folder-uuid" });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
##### folders.create(options)
|
|
297
|
+
|
|
298
|
+
Create a folder at the project root or nested under another folder.
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// Create root folder
|
|
302
|
+
const marketing = await client.folders.create({ name: "Marketing" });
|
|
303
|
+
|
|
304
|
+
// Create nested subfolder
|
|
305
|
+
const launch = await client.folders.create({
|
|
306
|
+
name: "Launch 2026",
|
|
307
|
+
parentId: marketing.id,
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
##### folders.update(options)
|
|
312
|
+
|
|
313
|
+
Rename an existing folder.
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
const updated = await client.folders.update({
|
|
317
|
+
folderId: "folder-uuid",
|
|
318
|
+
name: "New Name",
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
##### folders.move(options)
|
|
323
|
+
|
|
324
|
+
Move a folder under another folder, or to the project root by passing `parentId: null`. Moving a folder into itself or a descendant throws `folder/parent-cycle`.
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// Move folder to another parent
|
|
328
|
+
await client.folders.move({
|
|
329
|
+
folderId: child.id,
|
|
330
|
+
parentId: anotherParent.id,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Move folder to project root
|
|
334
|
+
await client.folders.move({
|
|
335
|
+
folderId: child.id,
|
|
336
|
+
parentId: null,
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
##### folders.delete(options)
|
|
341
|
+
|
|
342
|
+
Delete an empty folder. If the folder still contains assets or child folders, this throws `folder/not-empty`.
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
await client.folders.delete({ folderId: "folder-uuid" });
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
##### cdn.getAssetUrl(options) and cdn.serve(options)
|
|
349
|
+
|
|
350
|
+
`getAssetUrl` constructs a CDN URL without fetching it. `serve` fetches the resource and sends the client's API key.
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
const url = client.cdn.getAssetUrl({
|
|
354
|
+
assetIdentifier: assetId,
|
|
355
|
+
resource: "content",
|
|
356
|
+
lens: "thumbnail",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const response = await client.cdn.serve({
|
|
360
|
+
assetIdentifier: assetId,
|
|
361
|
+
resource: "content",
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Private CDN content cannot be read directly with API keys. Use a short-lived signed URL for application clients.
|
|
366
|
+
|
|
367
|
+
##### Multipart upload flow
|
|
368
|
+
|
|
369
|
+
Use `create` with `part` to start multipart uploads, and `uploadPart` for subsequent chunks. `partNumber` defaults to `1` and `isFinal` defaults to `false` when starting an upload.
|
|
370
|
+
|
|
371
|
+
Image upload sizes use distinct direct and multipart paths:
|
|
372
|
+
|
|
373
|
+
- Direct image uploads must be smaller than `10MB`.
|
|
374
|
+
- Multipart image uploads must total between `10MB` and `20MB`, inclusive. CalmLens accumulates the uploaded parts and rejects a final image outside that range; callers do not need to provide a total-size field.
|
|
375
|
+
- Retained multipart image originals remain in private backing storage. Public or authorized delivery and transformations use CalmLens CDN URLs.
|
|
376
|
+
|
|
377
|
+
Each non-final multipart part must be at least `5MiB`; only the final part may be smaller. If you send base64, split the original bytes first, then base64-encode each part. Do not split one full base64 string into substrings.
|
|
378
|
+
|
|
379
|
+
Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
|
|
380
|
+
|
|
381
|
+
Use a concrete asset MIME type such as `image/png`, `application/pdf`, or `text/plain`. The SDK rejects ambiguous/container request MIME types such as `application/octet-stream` and `multipart/*`.
|
|
382
|
+
|
|
383
|
+
**Example (queued finalization):**
|
|
384
|
+
```typescript
|
|
385
|
+
const uploading = await client.assets.create({
|
|
386
|
+
name: "large-image.png",
|
|
387
|
+
part: {
|
|
388
|
+
uint8Array: firstPartBytes,
|
|
389
|
+
type: "image/png"
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await client.assets.uploadPart({
|
|
394
|
+
assetId: uploading.id,
|
|
395
|
+
part: {
|
|
396
|
+
base64: secondPartBase64,
|
|
397
|
+
partNumber: 2,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const completed = await client.assets.uploadPart({
|
|
402
|
+
assetId: uploading.id,
|
|
403
|
+
part: {
|
|
404
|
+
base64: finalPartBase64,
|
|
405
|
+
partNumber: 3,
|
|
406
|
+
isFinal: true,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**Example (single-call synchronous analysis):**
|
|
412
|
+
```typescript
|
|
413
|
+
const analyzed = await client.assets.moderate({
|
|
414
|
+
name: "large-image.png",
|
|
415
|
+
part: {
|
|
416
|
+
base64: finalPartBase64,
|
|
417
|
+
type: "image/png",
|
|
418
|
+
isFinal: true
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
console.log(analyzed.status);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
#### Static Methods
|
|
425
|
+
|
|
426
|
+
##### verifyWebhookSignature(options)
|
|
427
|
+
|
|
428
|
+
Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function.
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
static async verifyWebhookSignature(options: {
|
|
432
|
+
payload: string | object; // raw request body (pass the string, not parsed JSON)
|
|
433
|
+
signature: string | null | undefined; // x-calmlens-signature header
|
|
434
|
+
timestamp: string | number | null | undefined; // x-calmlens-timestamp header
|
|
435
|
+
secret: string;
|
|
436
|
+
toleranceSeconds?: number; // default 300
|
|
437
|
+
now?: Date | number; // default Date.now(), for tests
|
|
438
|
+
}): Promise<boolean>
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
It returns `true` only when the `x-calmlens-signature` header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now.
|
|
442
|
+
|
|
443
|
+
**Example:**
|
|
444
|
+
```typescript
|
|
445
|
+
// In your webhook handler (Fetch API Request)
|
|
446
|
+
const payload = await request.text();
|
|
447
|
+
const isValid = await CalmLensClient.verifyWebhookSignature({
|
|
448
|
+
payload,
|
|
449
|
+
signature: request.headers.get('x-calmlens-signature'),
|
|
450
|
+
timestamp: request.headers.get('x-calmlens-timestamp'),
|
|
451
|
+
secret: WEBHOOK_SECRET,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!isValid) {
|
|
455
|
+
return new Response('Unauthorized', { status: 401 });
|
|
456
|
+
}
|
|
457
|
+
const event: WebhookEventPayload = JSON.parse(payload);
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Asset Types
|
|
461
|
+
|
|
462
|
+
Assets can be of the following types:
|
|
463
|
+
- `image` - Images (JPEG, PNG, GIF, WebP, etc.)
|
|
464
|
+
- `video` - Video files (MP4, WebM, etc.)
|
|
465
|
+
- `audio` - Audio files (MP3, WAV, etc.)
|
|
466
|
+
- `plaintext-document` - Plain-text formats such as TXT, JSON, CSV, and YAML
|
|
467
|
+
- `multimedia-document` - Documents such as PDF, DOCX, DOC, RTF, and ODT
|
|
468
|
+
- `website` - Web pages and URLs
|
|
469
|
+
- `file` - Other supported binary files
|
|
470
|
+
|
|
471
|
+
## Asset Status
|
|
472
|
+
|
|
473
|
+
Assets progress through these statuses:
|
|
474
|
+
- `uploading` - Multipart upload is accepting more parts
|
|
475
|
+
- `pending` - Asset is being processed
|
|
476
|
+
- `approved` - Processing completed and delivery is allowed; an `alert` action can still leave `report.flagged` set to `true`
|
|
477
|
+
- `rejected` - A hide, purge, child-asset, or manual rejection decision was applied
|
|
478
|
+
- `error` - Processing failed
|
|
479
|
+
|
|
480
|
+
## Moderation Reports
|
|
481
|
+
|
|
482
|
+
Completed reports always contain the six parent categories: `sexual`, `violence`, `self-harm`, `harassment`, `hate`, and `criminal-activity`. Category-specific detail is nested under `qualifiers`, for example `violence.qualifiers.graphic`. Reports have no `subcategories` field or dedicated minor-safety placeholder. Retired generic qualifier IDs are rejected.
|
|
483
|
+
|
|
484
|
+
Always narrow on a category's `status` before reading `score`. An `evaluated` or `partial` result includes `score` and `likelihood`; a `not-evaluated` result includes a reason and represents unknown coverage, not a safe zero score.
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const violence = asset.report?.categories.violence;
|
|
488
|
+
if (violence?.status === 'evaluated' || violence?.status === 'partial') {
|
|
489
|
+
console.log(violence.score);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
See the [moderation report guide](https://calmlens.com/docs/guides/moderation-reports) for aggregation, provider coverage, OCR merging, and policy actions. General Sexual Content moderation does not estimate age or provide dedicated CSAM detection.
|
|
494
|
+
|
|
495
|
+
## Asset Visibility
|
|
496
|
+
|
|
497
|
+
- `public` - Retained content can be requested directly from the CDN
|
|
498
|
+
- `private` - Retained content requires a signed URL or an authenticated owner session
|
|
499
|
+
|
|
500
|
+
## Error Handling
|
|
501
|
+
|
|
502
|
+
The SDK throws errors for common issues:
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
try {
|
|
506
|
+
const asset = await client.assets.create({
|
|
507
|
+
name: "test.jpg",
|
|
508
|
+
// Missing required content, URL, or multipart part
|
|
509
|
+
});
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error(error.message); // "Either file content, URL, or multipart part is required"
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Public API failures are normalized to `CalmLensError`. Use its stable `code` property for control flow and `details` for diagnostics.
|
|
516
|
+
|
|
517
|
+
## Testing
|
|
518
|
+
|
|
519
|
+
`@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations, and mock API key helpers for local unit testing without network requests.
|
|
520
|
+
|
|
521
|
+
The main `@calmlens/sdk` entry point does not load or export testing helpers to keep production bundles minimal. Keep `/testing` imports in test files.
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
import { MockCalmLensClient, createMockApiKey } from "@calmlens/sdk/testing";
|
|
525
|
+
|
|
526
|
+
const client = new MockCalmLensClient({
|
|
527
|
+
apiKey: createMockApiKey(),
|
|
528
|
+
projectId: crypto.randomUUID(),
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`MockCalmLensClient` accepts the same options as `CalmLensClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, ...) and validates them the same way, plus `mockOptions`. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`).
|
|
533
|
+
|
|
534
|
+
Every call is recorded in `client.calls` as `{ operation, options }`, where `operation` is the method path (`"text.moderate"`, `"assets.retrieve"`, `"events.list"`, ...) and `options` is the object you passed. `client.callsTo(operation)` filters them. To test failure handling, `client.failNextRequest()` makes the next call throw the same `CalmLensError` the real client throws when the network fails (`sdk/transport-error`); pass `"timeout"` for `sdk/timeout`, or any `Error` to throw it as is, and `{ operation }` to fail only that method. Each queued failure is used once.
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
const client = new MockCalmLensClient({ apiKey: createMockApiKey(), projectId, timeoutMs: 5_000 });
|
|
538
|
+
|
|
539
|
+
client.failNextRequest("timeout", { operation: "text.moderate" });
|
|
540
|
+
await expect(postComment(client, "hello")).rejects.toMatchObject({ code: "sdk/timeout" });
|
|
541
|
+
|
|
542
|
+
await postComment(client, "hello");
|
|
543
|
+
expect(client.callsTo("text.moderate")[0]?.options).toMatchObject({ text: "hello" });
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
|
|
547
|
+
|
|
548
|
+
## Webhooks
|
|
549
|
+
|
|
550
|
+
A `webhook` connector POSTs each event to your URL as JSON:
|
|
551
|
+
|
|
552
|
+
```json
|
|
553
|
+
{
|
|
554
|
+
"id": "…",
|
|
555
|
+
"type": "asset.flagged",
|
|
556
|
+
"createdAt": 1760000000000,
|
|
557
|
+
"projectId": "…",
|
|
558
|
+
"data": {
|
|
559
|
+
"severity": "high",
|
|
560
|
+
"title": "…",
|
|
561
|
+
"description": null,
|
|
562
|
+
"assetId": "…",
|
|
563
|
+
"asset": null,
|
|
564
|
+
"facts": [{ "key": "asset-name", "value": "photo.jpg" }]
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Each delivery has these headers (the names are exported as constants):
|
|
570
|
+
|
|
571
|
+
| Header | Constant | Value |
|
|
572
|
+
|---|---|---|
|
|
573
|
+
| `x-calmlens-delivery` | `WEBHOOK_DELIVERY_HEADER` | Delivery ID (the same across retries) |
|
|
574
|
+
| `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
|
|
575
|
+
| `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
import {
|
|
579
|
+
verifyWebhookSignature,
|
|
580
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
581
|
+
WEBHOOK_TIMESTAMP_HEADER,
|
|
582
|
+
} from "@calmlens/sdk";
|
|
583
|
+
|
|
584
|
+
const payload = await request.text();
|
|
585
|
+
const valid = await verifyWebhookSignature({
|
|
586
|
+
payload,
|
|
587
|
+
signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER),
|
|
588
|
+
timestamp: request.headers.get(WEBHOOK_TIMESTAMP_HEADER),
|
|
589
|
+
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Verify the exact raw request body with `verifyWebhookSignature` before parsing it; re-serializing parsed JSON can change the signed bytes. Deliveries more than 5 minutes old (or ahead of your clock) are rejected to prevent replays. The body type is exported as `WebhookEventPayload`; narrow it on `type` to get that event type's typed `data.facts` (`EventType` and `EventFacts<Type>` are exported too, and [the webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists every type).
|
|
594
|
+
|
|
595
|
+
## Command-line tool
|
|
596
|
+
|
|
597
|
+
Install the standalone [CalmLens CLI](https://calmlens.com/docs/guides/local-development) through Homebrew, a platform download or npm. Webhook connectors must use public HTTPS URLs, so during development use it to forward events to a local handler instead. Each event is sent with the same body, headers and signature as a real delivery:
|
|
598
|
+
|
|
599
|
+
```bash
|
|
600
|
+
npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens --env-file .env.local
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
The CLI reads `CALMLENS_API_KEY`, `CALMLENS_PROJECT_ID` and `CALMLENS_WEBHOOK_SECRET` from the environment or `--env-file`; without a secret it prints a generated one for your app. `npx calmlens trigger [harassment|hate|criminal-activity|clean]` moderates a harmless test input so events appear (one upload each). Run `npx calmlens --help` for all options. To install the command globally, use `npm install -g calmlens`. The CLI is released separately from the SDK.
|
|
604
|
+
|
|
605
|
+
The same feed is available in code with [`events.list`](#events-feed).
|
|
606
|
+
|
|
607
|
+
## Events feed
|
|
608
|
+
|
|
609
|
+
`client.events.list({ after?, limit? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
{
|
|
613
|
+
events: TypedEvent[]; // { id, projectId, type, severity, title, description?, assetId?, asset?, facts, createdAt, updatedAt }
|
|
614
|
+
cursor: string; // pass back as `after` to continue after the last event returned
|
|
615
|
+
hasMore: boolean; // true when more events are already waiting after `cursor`
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
- Without `after` (or with an empty string), it returns **no events** and a `cursor` for the current time, so you only see events created after you start reading. It does not return history.
|
|
620
|
+
- With `after`, it returns up to `limit` events (default 50) created after that cursor. When there are none, `cursor` is the `after` you passed.
|
|
621
|
+
- Keep passing the latest `cursor` as `after` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
|
|
622
|
+
- An unknown or malformed cursor fails with the `CalmLensError` code `events/invalid-cursor`.
|
|
623
|
+
|
|
624
|
+
```typescript
|
|
625
|
+
let { cursor } = await client.events.list(); // no events, cursor = "now"
|
|
626
|
+
|
|
627
|
+
async function drainEvents() {
|
|
628
|
+
let hasMore = true;
|
|
629
|
+
while (hasMore) {
|
|
630
|
+
const page = await client.events.list({ after: cursor, limit: 100 });
|
|
631
|
+
for (const event of page.events) {
|
|
632
|
+
// narrow on event.type to read its typed facts
|
|
633
|
+
console.log(event.type, event.facts);
|
|
634
|
+
}
|
|
635
|
+
cursor = page.cursor;
|
|
636
|
+
hasMore = page.hasMore;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
setInterval(drainEvents, 5_000);
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Events carry `facts: { key, value, url? }[]`; webhook deliveries carry the same events reshaped as `{ id, type, createdAt, projectId, data }`.
|
|
644
|
+
|
|
645
|
+
## Request budgets and errors
|
|
646
|
+
|
|
647
|
+
Set `timeoutMs` (default 120000) or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client. Multipart state is shared between scoped clients. Deadlines include response-body reads; authenticated default REST, upload and CDN requests reject redirects. Custom non-Fetch backends own their redirect/cancellation policies.
|
|
648
|
+
|
|
649
|
+
`CalmLensError` exposes `code`, optional HTTP `status`, `details` and native `cause`. Every failure to complete a request (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) is `sdk/transport-error`, with the underlying error as `cause`; deadlines are `sdk/timeout`, cancellation `sdk/cancelled`, successful responses whose body is not the expected JSON `sdk/invalid-response`, and HTTP failures without an API error body `sdk/http-error`. `cdn.serve` returns the `Response` once headers arrive, so errors while you read its body come from your `fetch`. API error codes/messages are preserved. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
|
|
650
|
+
|
|
651
|
+
`text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification. The old asset analyze method is removed.
|
|
652
|
+
|
|
653
|
+
## Releasing
|
|
654
|
+
|
|
655
|
+
Stable releases are published automatically from `main` when packed SDK contents differ from npm `latest` and an SDK Changeset is present. Contributors must run `bun run changeset` to choose the release type and write release notes. CI updates and commits this package's `CHANGELOG.md` automatically. See the repository's [.changeset release guide](../../.changeset/README.md).
|