@calmlens/sdk 0.18.0 → 0.18.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 +35 -1023
- package/bin/calmlens.js +1 -1
- package/cjs/{BrowserUpload-CQwXEGFv.js → BrowserUpload-DRdjobfF.js} +1 -1
- package/cjs/{CalmLensAppClient-CHQGu-Do.js → CalmLensAppClient-CkyH_fXH.js} +2 -2
- package/cjs/{CalmLensConnection-B8Bu2j3y.js → CalmLensConnection-CX1A0Q4N.js} +1 -1
- package/cjs/{CalmLensServerClient-C5o2A8MW.js → CalmLensServerClient-C1djppvN.js} +2 -2
- package/cjs/{ModerationContextLimits-DelWfmiJ.js → ModerationContextLimits-nJtd4Tag.js} +1 -1
- package/cjs/{ReactUpload-Cfg5JYUq.js → ReactUpload-DmBmtsbJ.js} +1 -1
- package/cjs/{UploadController-BGO1eyzb.js → UploadController-0YTjjzXh.js} +2 -2
- package/cjs/app.js +2 -2
- package/cjs/elements.js +3 -3
- package/cjs/index.js +1 -1
- package/cjs/react-native.js +3 -3
- package/cjs/react.js +4 -4
- package/cjs/server.js +2 -2
- package/cjs/testing.js +5 -5
- package/cjs/{ui-CC1GOoMg.js → ui-BG2kKWUq.js} +2 -2
- package/cjs/ui.js +2 -2
- package/cjs/vue.js +3 -3
- package/esm/{BrowserUpload-D2xBWqhm.js → BrowserUpload-DRHcNM9a.js} +1 -1
- package/esm/{CalmLensAppClient-DuFas_dW.js → CalmLensAppClient-COK1Cxfp.js} +2 -2
- package/esm/{CalmLensConnection-MxwwLXdl.js → CalmLensConnection-BLw8vC-6.js} +1 -1
- package/esm/{CalmLensServerClient-D80_mKpW.js → CalmLensServerClient-BwRJKhn7.js} +2 -2
- package/esm/{ModerationContextLimits-BaX2QnU8.js → ModerationContextLimits-Cv1qXWB9.js} +1 -1
- package/esm/{ReactUpload-CL2XC8WS.js → ReactUpload-DooBhDOR.js} +1 -1
- package/esm/{UploadController-qjoYGCwx.js → UploadController-BDIL9GKO.js} +2 -2
- package/esm/app.js +2 -2
- package/esm/elements.js +3 -3
- package/esm/index.js +1 -1
- package/esm/react-native.js +3 -3
- package/esm/react.js +4 -4
- package/esm/server.js +2 -2
- package/esm/testing.js +5 -5
- package/esm/{ui-CCY3yXFn.js → ui-DdoweZQx.js} +2 -2
- package/esm/ui.js +2 -2
- package/esm/vue.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,18 +12,14 @@ The same package is also published as `@calmlens/sdk`, at the same version. Impo
|
|
|
12
12
|
|
|
13
13
|
The package has two clients, each with its own import:
|
|
14
14
|
|
|
15
|
-
| Import | Client | Runs | Authenticates with
|
|
16
|
-
| ----------------- | ---------------------- | ------------------------------------------------------------------ |
|
|
17
|
-
| `calmlens/server` | `CalmLensServerClient` | On your server: Node.js 22 or later, Bun, Deno, Cloudflare Workers | A project API key
|
|
18
|
-
| `calmlens/app` | `CalmLensAppClient` | In browsers and React Native apps | An
|
|
15
|
+
| Import | Client | Runs | Authenticates with |
|
|
16
|
+
| ----------------- | ---------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------- |
|
|
17
|
+
| `calmlens/server` | `CalmLensServerClient` | On your server: Node.js 22 or later, Bun, Deno, Cloudflare Workers | A project API key |
|
|
18
|
+
| `calmlens/app` | `CalmLensAppClient` | In browsers and React Native apps | An upload preset, with an optional upload token from your server |
|
|
19
19
|
|
|
20
|
-
`
|
|
20
|
+
API keys are secret, so `CalmLensServerClient` refuses to start in a web page or app. Upload from browsers and apps with `CalmLensAppClient`, and serve private content to them with signed URLs. The package ships ES modules and CommonJS with type declarations and loads no other packages at runtime.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Every public SDK operation accepts one options object. This keeps call sites stable as optional fields are added.
|
|
25
|
-
|
|
26
|
-
## Quick Start
|
|
22
|
+
## Quick start
|
|
27
23
|
|
|
28
24
|
Create an API key under **Developer → API Keys** in the CalmLens portal. The same page shows your **Project ID**. Keep both on your server.
|
|
29
25
|
|
|
@@ -54,1025 +50,41 @@ const image = await client.assets.create({
|
|
|
54
50
|
console.log(image.classification.status, image.classification.moderation.status); // "processing" "pending"
|
|
55
51
|
```
|
|
56
52
|
|
|
57
|
-
| `classification.status` | `classification.moderation.status` | Meaning
|
|
58
|
-
| ------------------------- | ---------------------------------- |
|
|
59
|
-
| `completed` | `allowed` | Safe to show.
|
|
60
|
-
| `completed` | `
|
|
61
|
-
| `completed` | `
|
|
62
|
-
| `completed` | `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
|
|
66
|
-
The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision in
|
|
67
|
-
`classification.moderation.status`.
|
|
68
|
-
|
|
69
|
-
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 each moderation status safely.
|
|
70
|
-
|
|
71
|
-
## API Reference
|
|
72
|
-
|
|
73
|
-
### CalmLensServerClient
|
|
74
|
-
|
|
75
|
-
The client for your server, with every API method. Import it from `calmlens/server`.
|
|
76
|
-
|
|
77
|
-
#### Constructor
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
new CalmLensServerClient(options: CalmLensServerClientOptions)
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
**Options:**
|
|
84
|
-
- `apiKey` (string, required): Your CalmLens API key
|
|
85
|
-
- `projectId` (string, required): Your CalmLens project ID
|
|
86
|
-
- `baseUrl` (string, optional): API base URL (defaults to `https://api.calmlens.com/v1`)
|
|
87
|
-
- `cdnBaseUrl` (string, optional): CDN base URL (defaults to `https://cdn.calmlens.com/v1`)
|
|
88
|
-
- `fetch` (optional): The `fetch` implementation for every request, for example one with a proxy or instrumentation. Defaults to the global `fetch`
|
|
89
|
-
- `uploadFetch`, `cdnFetch` (optional): Override `fetch` for content uploads or for `cdn.serve` only
|
|
90
|
-
- `timeoutMs` (positive integer, optional): Deadline for each API request attempt, including reading the response body (defaults to `120000`). For `cdn.serve` it ends when response headers arrive
|
|
91
|
-
- `uploadTimeoutMs` (positive integer, optional): Deadline for each binary upload attempt. Unset by default, so large files are never cut off; text content uses `timeoutMs`
|
|
92
|
-
- `maxRetries` (integer, optional): Retries for failed requests that are safe to repeat (defaults to `2`, `0` disables them). See [Retries and idempotency](#retries-and-idempotency)
|
|
93
|
-
- `maxResponseBytes` (positive integer, optional): Largest JSON response the client reads (defaults to 32 MiB)
|
|
94
|
-
- `signal` (AbortSignal, optional): Cancels every request made by this client
|
|
95
|
-
- `dangerouslyAllowBrowser` (boolean, optional): Lets the client run in a web page or app. Only for trusted internal tools: anyone who can open the page can read the API key
|
|
96
|
-
|
|
97
|
-
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` with the code `sdk/invalid-config`. The client keeps its API key out of `console.log`, `util.inspect` and `JSON.stringify`.
|
|
98
|
-
|
|
99
|
-
#### Methods
|
|
100
|
-
|
|
101
|
-
##### assets.create(options)
|
|
102
|
-
|
|
103
|
-
Queue an asset for processing and return the created asset immediately.
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
async assets.create(options: SubmitAssetOptions): Promise<Asset>
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**Options:**
|
|
110
|
-
- `name` (string, required): Name of the asset
|
|
111
|
-
- `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 }`
|
|
112
|
-
- `url` (string, optional): External URL to fetch instead of sending content
|
|
113
|
-
- `part` (optional): First part of a sequential multipart upload
|
|
114
|
-
- `description` (string, optional): Description of the asset
|
|
115
|
-
- `processingOptions.keepContentAfterProcessing` (boolean, optional): Retain content after processing
|
|
116
|
-
- `processingOptions.storeSensitiveMetadata` (boolean, optional): Overrides the project's `storeSensitiveMetadata` setting for this upload. When false, embedded image metadata (EXIF, XMP and IPTC) is removed before the image is stored, and `content.fileMetadata` leaves out the creator and location. When true, the image is stored as uploaded.
|
|
117
|
-
- `processingOptions.features` (object, optional): Optional analyses to run, each turned on with `enabled: true`: `ocr`, and in beta `personDetection` and `faceDetection` (with `ageEstimation`). See [People and faces](https://calmlens.com/docs/guides/people-and-faces).
|
|
118
|
-
- `visibility` (string, optional): "public" or "private"
|
|
119
|
-
|
|
120
|
-
**Example:**
|
|
121
|
-
```typescript
|
|
122
|
-
// Submit from file content
|
|
123
|
-
const asset = await client.assets.create({
|
|
124
|
-
name: "document.pdf",
|
|
125
|
-
content: {
|
|
126
|
-
base64: "JVBERi0xLjQKJcfs...",
|
|
127
|
-
type: "application/pdf",
|
|
128
|
-
},
|
|
129
|
-
description: "Important document",
|
|
130
|
-
visibility: "private",
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Submit from URL
|
|
134
|
-
const asset = await client.assets.create({
|
|
135
|
-
name: "website-content",
|
|
136
|
-
url: "https://example.com/page",
|
|
137
|
-
description: "Web page to classify",
|
|
138
|
-
});
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
##### assets.classify(options) and text.classify(options)
|
|
142
|
-
|
|
143
|
-
Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.classify({ text, name?, externalId?, ... })` is a shortcut that sends `text` as `text/plain` content named `text` by default.
|
|
144
|
-
|
|
145
|
-
```typescript
|
|
146
|
-
async assets.classify(options: SubmitAssetOptions): Promise<Asset>
|
|
147
|
-
async text.classify(options: ClassifyTextOptions): Promise<Asset>
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Both resolve to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:
|
|
151
|
-
|
|
152
|
-
```typescript
|
|
153
|
-
const asset = await client.text.classify({ text: "Great photo!", externalId: "comment-42" });
|
|
154
|
-
asset.id; // string
|
|
155
|
-
asset.classification.status; // "pending" | "processing" | "completed" | "failed"; audio and video keep processing in the background
|
|
156
|
-
asset.classification.failureReason; // "file-too-large" | "duration-limit" | "processing-error" | ... | null; set only when processing failed
|
|
157
|
-
asset.classification.moderation.status; // "pending" | "allowed" | "limited" | "age-restricted" | "blocked" | "purged"
|
|
158
|
-
asset.classification.moderation?.score; // number, the overall score
|
|
159
|
-
asset.classification.moderation.raw?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
|
|
160
|
-
asset.content.status; // "temporary" | "stored" | "removed"
|
|
161
|
-
asset.content.removalReason; // "policy" | "retention" | "requested" | "parent-removed" | "disposed" | null
|
|
162
|
-
asset.type; // "plaintext-document" for text
|
|
163
|
-
asset.createdAt; // milliseconds since the Unix epoch
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Always present: `id`, `projectId`, `name`, `type`, `createdAt`, `updatedAt`, `upload`, `classification` and `content`. Classification contains its progress, completion time and results grouped by capability. `classification.moderation.raw` is absent until findings are available; optional detections appear only when requested. Moderation decisions and reviewer details sit under `classification.moderation`. Content is removed after classification unless `processingOptions.keepContentAfterProcessing` is enabled. Check each category’s evaluation `status` before reading its score.
|
|
167
|
-
|
|
168
|
-
###### Replies in a conversation
|
|
169
|
-
|
|
170
|
-
For a reply or comment in a thread, pass who wrote it and the earlier posts, oldest first. CalmLens moderates only the content; the earlier posts help it tell who the content addresses and whether it quotes, reports, counters or mocks something. Author IDs are your own opaque identifiers. The SDK sends only the posts CalmLens uses: the most recent 5 that fit in 2,000 characters (the most recent post is always sent).
|
|
171
|
-
|
|
172
|
-
```typescript
|
|
173
|
-
const reply = await client.text.classify({
|
|
174
|
-
text: "Same to you",
|
|
175
|
-
authorId: "user-2",
|
|
176
|
-
context: {
|
|
177
|
-
items: [
|
|
178
|
-
{ text: "Has anyone tried the new update?", authorId: "user-1" },
|
|
179
|
-
{ text: "You would say that", authorId: "user-3" },
|
|
180
|
-
],
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
reply.context; // the posts CalmLens used, or null once the content is removed
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
The same options work on `assets.create()` and `assets.classify()` for images, documents, audio and video. See [Threaded moderation](https://calmlens.com/docs/guides/threaded-moderation).
|
|
187
|
-
|
|
188
|
-
##### assets.retrieve(options)
|
|
189
|
-
|
|
190
|
-
Retrieve details of a specific asset.
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
async assets.retrieve(options: { assetId: string }): Promise<Asset>
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
**Example:**
|
|
197
|
-
```typescript
|
|
198
|
-
const asset = await client.assets.retrieve({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
|
|
199
|
-
console.log(asset.classification.moderation.status);
|
|
200
|
-
console.log(asset.classification.moderation); // Classification results
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
##### ensureLens(options)
|
|
204
|
-
|
|
205
|
-
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`. It sends the definition through `PUT /v1/projects/{projectId}/lenses`, the request `ensureLenses()` uses, and skips the request when the client already ensured the same definition.
|
|
206
|
-
|
|
207
|
-
```typescript
|
|
208
|
-
const lens = await client.ensureLens({
|
|
209
|
-
identifier: "picmagic-normal",
|
|
210
|
-
name: "PicMagic normal",
|
|
211
|
-
type: "image",
|
|
212
|
-
active: true,
|
|
213
|
-
version: 1,
|
|
214
|
-
transforms: [
|
|
215
|
-
{
|
|
216
|
-
type: "imageTransform",
|
|
217
|
-
options: { fit: "scale-down", width: 1024, format: "png" },
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const image = await client.cdn.serve({
|
|
223
|
-
assetIdentifier: assetId,
|
|
224
|
-
lens: lens.identifier,
|
|
225
|
-
});
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
For several lenses, use `ensureLenses()` to ensure them together: new definitions go in one request, or in consecutive requests of up to 100 each, in order. Each lens keeps the same independent versioning rules.
|
|
229
|
-
|
|
230
|
-
```typescript
|
|
231
|
-
const [normal, uhd] = await client.ensureLenses({ lenses: [normalLens, uhdLens] });
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
The CDN uses named lenses with an `@` prefix in URLs, such as `/rendered/@picmagic-normal`. The SDK adds it automatically when you supply `lens`. Use `transform` for one-off direct transforms, either as structured steps, for example `client.cdn.serve({ assetIdentifier: assetId, transform: { type: "imageTransform", options: { format: "png", width: 1024 } } })`, or as a selector string with one path segment per step, such as `"width=1024,fit=cover/blur=12"`. Pass an array of steps to chain transforms. `assets.createSignedUrl` accepts the same forms. Supply a `lens`, a `transform` or both; the transform runs after the lens. 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.
|
|
235
|
-
|
|
236
|
-
##### assets.list(options)
|
|
237
|
-
|
|
238
|
-
List assets in your project with pagination.
|
|
239
|
-
|
|
240
|
-
```typescript
|
|
241
|
-
async assets.list(options: {
|
|
242
|
-
cursor?: string;
|
|
243
|
-
pageSize?: number;
|
|
244
|
-
ordering?: Array<{ field: "createdAt" | "id"; direction: "asc" | "desc" }>;
|
|
245
|
-
filter?: string | null;
|
|
246
|
-
}): Promise<{
|
|
247
|
-
items: Asset[];
|
|
248
|
-
hasMore: boolean;
|
|
249
|
-
nextCursor: string | null;
|
|
250
|
-
pageSize: number;
|
|
251
|
-
}>
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
**Example:**
|
|
255
|
-
```typescript
|
|
256
|
-
const result = await client.assets.list({
|
|
257
|
-
pageSize: 20,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
console.log(`Received ${result.items.length} assets`);
|
|
261
|
-
result.items.forEach((asset) => {
|
|
262
|
-
console.log(`${asset.name}: ${asset.classification.moderation.status}`);
|
|
263
|
-
});
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
To read every matching asset, iterate instead of paging by hand. Pages are fetched as you go using continuation cursors. Inserting or deleting other assets does not shift the cursor position. To page manually, pass `nextCursor` as `cursor` with the same filters and ordering until it is null. Lists are not snapshots; assets may still be deleted or change filter membership during traversal.
|
|
267
|
-
|
|
268
|
-
```typescript
|
|
269
|
-
for await (const asset of client.assets.iterate({
|
|
270
|
-
filter: "classification.moderation.status==blocked",
|
|
271
|
-
pageSize: 100,
|
|
272
|
-
})) {
|
|
273
|
-
console.log(asset.id);
|
|
274
|
-
}
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
##### assets.listChildren(options)
|
|
278
|
-
|
|
279
|
-
List assets extracted from a website or multimedia document.
|
|
280
|
-
|
|
281
|
-
```typescript
|
|
282
|
-
const children = await client.assets.listChildren({
|
|
283
|
-
assetId: parentAssetId,
|
|
284
|
-
pageSize: 20,
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
for await (const child of client.assets.iterateChildren({ assetId: parentAssetId })) {
|
|
288
|
-
console.log(child.name);
|
|
289
|
-
}
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
##### assets.createSignedUrl(options)
|
|
293
|
-
|
|
294
|
-
Create a short-lived URL for a private asset's `rendered` (default) or `original` resource.
|
|
295
|
-
|
|
296
|
-
```typescript
|
|
297
|
-
const signed = await client.assets.createSignedUrl({
|
|
298
|
-
assetId,
|
|
299
|
-
resource: "rendered",
|
|
300
|
-
lens: "thumbnail",
|
|
301
|
-
expiresInSeconds: 900,
|
|
302
|
-
});
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
##### assets.move(options)
|
|
306
|
-
|
|
307
|
-
Move an asset into a folder, or to the project root by passing `null`.
|
|
308
|
-
|
|
309
|
-
```typescript
|
|
310
|
-
// Move asset to a folder
|
|
311
|
-
await client.assets.move({
|
|
312
|
-
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
313
|
-
folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// Move asset to project root
|
|
317
|
-
await client.assets.move({
|
|
318
|
-
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
319
|
-
folderId: null,
|
|
320
|
-
});
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
##### assets.retry(options)
|
|
324
|
-
|
|
325
|
-
Process an asset whose processing failed (`classification.status` `failed`) again. It returns to
|
|
326
|
-
`processing` and is moderated like a new upload, so handle the result the same way (webhook or polling). Its content must still be stored:
|
|
327
|
-
otherwise this throws `asset/content-unavailable`, and you need to upload it again.
|
|
328
|
-
|
|
329
|
-
```typescript
|
|
330
|
-
const asset = await client.assets.retry({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
|
|
331
|
-
// asset.classification.status === "processing"
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
##### assets.explain(options)
|
|
335
|
-
|
|
336
|
-
Complete the policy evidence behind a decided asset's moderation decision. To decide quickly,
|
|
337
|
-
moderation can skip questions whose answers could not change any action, so a report with
|
|
338
|
-
`classification.moderation.explanationComplete` `decision` can credit a different rule, or show a
|
|
339
|
-
different policy score, than a complete one. Explaining answers those questions and returns the asset
|
|
340
|
-
with `moderation.explanationComplete` `full`; every action stays as decided. It throws `asset/not-decided` for an
|
|
341
|
-
asset without a decision, and `asset/policy-changed` when the project's policy has changed since
|
|
342
|
-
the decision: pass `rerun: true` to moderate the asset again with the current policy instead. A
|
|
343
|
-
re-run returns the asset in `processing` and can reach a different decision; it throws
|
|
344
|
-
`asset/reviewed` for an asset a reviewer has decided.
|
|
345
|
-
|
|
346
|
-
```typescript
|
|
347
|
-
const asset = await client.assets.explain({ assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c" });
|
|
348
|
-
// asset.classification.moderation.explanationComplete === true
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
##### assets.decide(options)
|
|
352
|
-
|
|
353
|
-
Record a reviewer's decision on an asset, replacing the policy's. The asset's
|
|
354
|
-
`classification.moderation.status` becomes `decision` (`allowed`, `limited`, `age-restricted`,
|
|
355
|
-
`blocked` or `purged`), the decision and its `note` (required, up to 1,499 characters) join
|
|
356
|
-
`classification.moderation.manualReviews`, and the matching `asset.<status>` event is sent with a
|
|
357
|
-
`decided-by: reviewer` fact. Purging needs `confirmPurge: true` and the `assets:delete`
|
|
358
|
-
permission, and permanently deletes the stored content.
|
|
359
|
-
|
|
360
|
-
It throws `asset/processing` while the asset is still uploading or processing,
|
|
361
|
-
`asset/decision-not-allowed` when an asset pending moderation is allowed, limited or
|
|
362
|
-
age-restricted (it can only be blocked or purged), `asset/decision-unchanged` when the asset
|
|
363
|
-
already has that status and `asset/content-purged` when purged content is anything but blocked.
|
|
364
|
-
|
|
365
|
-
```typescript
|
|
366
|
-
const asset = await client.assets.decide({
|
|
367
|
-
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
368
|
-
decision: "blocked",
|
|
369
|
-
note: "Spam reported by three members",
|
|
370
|
-
});
|
|
371
|
-
// asset.classification.moderation.status === "blocked"
|
|
372
|
-
|
|
373
|
-
await client.assets.decide({
|
|
374
|
-
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
375
|
-
decision: "purged",
|
|
376
|
-
note: "Illegal content",
|
|
377
|
-
confirmPurge: true,
|
|
378
|
-
});
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
##### assets.waitForDecision(options)
|
|
382
|
-
|
|
383
|
-
Wait for an asset's moderation decision and get the decided asset: it returns once `classification.moderation.status`
|
|
384
|
-
is no longer `pending`, or `classification.status` is `failed`. The SDK checks the asset every few seconds and rejects with `sdk/timeout` if there's no decision within
|
|
385
|
-
`timeoutMs` (default 60000). Pass `signal` to stop waiting early (`sdk/cancelled`). Use it after
|
|
386
|
-
`assets.create`, or with an asset ID you stored earlier. Webhooks report decisions without polling.
|
|
387
|
-
|
|
388
|
-
```typescript
|
|
389
|
-
const queued = await client.assets.create({
|
|
390
|
-
name: "photo.jpg",
|
|
391
|
-
content: { blob, type: "image/jpeg" },
|
|
392
|
-
});
|
|
393
|
-
const decided = await client.assets.waitForDecision({ assetId: queued.id, timeoutMs: 30_000 });
|
|
394
|
-
const { status } = decided.classification.moderation;
|
|
395
|
-
if (status !== "allowed" && status !== "limited") hidePhoto(decided.id);
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
##### folders.list(options)
|
|
399
|
-
|
|
400
|
-
Fetch one page of immediate child folders, sorted by name. Omit `parentId` for the project root.
|
|
401
|
-
The response contains `items`, the visible `ancestors` of the listed folders, `hasMore` and
|
|
402
|
-
`nextCursor`; pass `nextCursor` as `cursor` to load the next page. `pageSize` is 1 to 100
|
|
403
|
-
(default 50), and `ordering: [{ field: "createdAt", direction: "desc" }]` sorts by creation
|
|
404
|
-
time instead (`name` or `createdAt`, one rule).
|
|
405
|
-
|
|
406
|
-
```typescript
|
|
407
|
-
const page = await client.folders.list({ parentId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P", pageSize: 100 });
|
|
408
|
-
if (page.hasMore) {
|
|
409
|
-
const next = await client.folders.list({
|
|
410
|
-
parentId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
411
|
-
cursor: page.nextCursor!,
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
##### folders.retrieve(options)
|
|
417
|
-
|
|
418
|
-
Retrieve details of a specific folder by ID.
|
|
419
|
-
|
|
420
|
-
```typescript
|
|
421
|
-
const folder = await client.folders.retrieve({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
##### folders.create(options)
|
|
425
|
-
|
|
426
|
-
Create a folder at the project root or nested under another folder.
|
|
427
|
-
|
|
428
|
-
```typescript
|
|
429
|
-
// Create root folder
|
|
430
|
-
const marketing = await client.folders.create({ name: "Marketing" });
|
|
431
|
-
|
|
432
|
-
// Create nested subfolder
|
|
433
|
-
const launch = await client.folders.create({
|
|
434
|
-
name: "Launch 2026",
|
|
435
|
-
parentId: marketing.id,
|
|
436
|
-
});
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
##### folders.update(options)
|
|
440
|
-
|
|
441
|
-
Rename an existing folder.
|
|
442
|
-
|
|
443
|
-
```typescript
|
|
444
|
-
const updated = await client.folders.update({
|
|
445
|
-
folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
446
|
-
name: "New Name",
|
|
447
|
-
});
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
##### folders.move(options)
|
|
451
|
-
|
|
452
|
-
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`.
|
|
453
|
-
|
|
454
|
-
```typescript
|
|
455
|
-
// Move folder to another parent
|
|
456
|
-
await client.folders.move({
|
|
457
|
-
folderId: child.id,
|
|
458
|
-
parentId: anotherParent.id,
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// Move folder to project root
|
|
462
|
-
await client.folders.move({
|
|
463
|
-
folderId: child.id,
|
|
464
|
-
parentId: null,
|
|
465
|
-
});
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
##### folders.delete(options)
|
|
469
|
-
|
|
470
|
-
Delete an empty folder. If the folder still contains assets or child folders, this throws `folder/not-empty`.
|
|
471
|
-
|
|
472
|
-
```typescript
|
|
473
|
-
await client.folders.delete({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
##### cdn.getAssetUrl(options) and cdn.serve(options)
|
|
477
|
-
|
|
478
|
-
`getAssetUrl` constructs a CDN URL without fetching it. `serve` fetches the resource and sends the client's API key.
|
|
479
|
-
|
|
480
|
-
```typescript
|
|
481
|
-
const url = client.cdn.getAssetUrl({
|
|
482
|
-
assetIdentifier: assetId,
|
|
483
|
-
resource: "rendered",
|
|
484
|
-
lens: "thumbnail",
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
// A public age-restricted asset, for a viewer your app has already age-checked
|
|
488
|
-
const restrictedUrl = client.cdn.getAssetUrl({
|
|
489
|
-
assetIdentifier: assetId,
|
|
490
|
-
viewerClaims: { ageConfirmed: true },
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
const response = await client.cdn.serve({
|
|
494
|
-
assetIdentifier: assetId,
|
|
495
|
-
resource: "original",
|
|
496
|
-
});
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
`serve` returns the `Response` as soon as headers arrive, so you can stream the body (for example `return new Response(response.body, response)`); `timeoutMs` covers only the wait for headers. Non-2xx responses are returned rather than thrown. Pass `init` for extra `fetch` options such as `method: "HEAD"`.
|
|
500
|
-
|
|
501
|
-
`serve` reads private assets with the client's API key when it has the **Download assets** (`assets:download`) permission. Give browsers and apps a short-lived signed URL instead.
|
|
502
|
-
|
|
503
|
-
A public asset whose moderation status is `age-restricted` is served from its public URL only when the URL carries
|
|
504
|
-
a JSON `viewerClaims` parameter containing `{ "ageConfirmed": true }`; otherwise delivery is refused, even with a signed URL or API key. Set it only
|
|
505
|
-
where your app has already decided the viewer may see age-restricted content.
|
|
506
|
-
|
|
507
|
-
`resource` is `rendered` (default, for display; takes lenses and transforms), `original` (the exact uploaded file) or `placeholder`. The placeholder is a small blurred PNG made from the
|
|
508
|
-
asset's ThumbHash, which the CDN serves publicly for every asset whatever its visibility or moderation status, for example in place of an
|
|
509
|
-
age-restricted image. It takes no `lens` or `transform`; passing one throws `sdk/invalid-argument`.
|
|
510
|
-
|
|
511
|
-
##### Building CDN URLs in the browser
|
|
512
|
-
|
|
513
|
-
`getAssetUrl`, exported from `calmlens/app` and `calmlens`, builds the same URLs as `cdn.getAssetUrl` from
|
|
514
|
-
the project ID alone. It takes no API key and sends no request.
|
|
515
|
-
|
|
516
|
-
```typescript
|
|
517
|
-
import { getAssetUrl } from "calmlens/app";
|
|
518
|
-
|
|
519
|
-
const placeholder = getAssetUrl({ projectId, assetIdentifier: assetId, resource: "placeholder" });
|
|
520
|
-
const thumbnail = getAssetUrl({ projectId, assetIdentifier: assetId, lens: "thumbnail" });
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
It takes `projectId`, `assetIdentifier`, `resource`, `lens`, `transform`, `viewerClaims` and `cdnBaseUrl` (default
|
|
524
|
-
`https://cdn.calmlens.com/v1`).
|
|
525
|
-
|
|
526
|
-
##### Multipart upload flow
|
|
527
|
-
|
|
528
|
-
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.
|
|
529
|
-
|
|
530
|
-
The queue mode is chosen when the upload starts and saved by CalmLens: an upload started with `create` is queued when its final part arrives, one started with `classify` waits for the decision. Later parts can come from another process that only knows the asset ID. Pass `queue` to `uploadPart` to override the saved choice for the final part. Pass `totalSizeInBytes` when you start the upload if you know the file's size, so CalmLens rejects an upload that could never finish before you send the rest.
|
|
531
|
-
|
|
532
|
-
Image upload sizes use distinct direct and multipart paths:
|
|
533
|
-
|
|
534
|
-
- Direct image uploads must be smaller than `10MB`.
|
|
535
|
-
- 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.
|
|
536
|
-
- Retained multipart image originals remain in private backing storage. Public or authorized delivery and transformations use CalmLens CDN URLs.
|
|
537
|
-
|
|
538
|
-
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.
|
|
539
|
-
|
|
540
|
-
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. To upload a file from disk in Node.js without reading it into memory, pass `{ data: await openAsBlob(path), type }` (`openAsBlob` from `node:fs`); unlike a stream, a `Blob` can be sent again if a retry is needed. 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.
|
|
541
|
-
|
|
542
|
-
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/*`.
|
|
543
|
-
|
|
544
|
-
**Example (queued finalization):**
|
|
545
|
-
```typescript
|
|
546
|
-
const uploading = await client.assets.create({
|
|
547
|
-
name: "large-image.png",
|
|
548
|
-
part: {
|
|
549
|
-
uint8Array: firstPartBytes,
|
|
550
|
-
type: "image/png",
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
await client.assets.uploadPart({
|
|
555
|
-
assetId: uploading.id,
|
|
556
|
-
part: {
|
|
557
|
-
base64: secondPartBase64,
|
|
558
|
-
partNumber: 2,
|
|
559
|
-
},
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
const completed = await client.assets.uploadPart({
|
|
563
|
-
assetId: uploading.id,
|
|
564
|
-
part: {
|
|
565
|
-
base64: finalPartBase64,
|
|
566
|
-
partNumber: 3,
|
|
567
|
-
isFinal: true,
|
|
568
|
-
},
|
|
569
|
-
});
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
**Example (single-call synchronous analysis):**
|
|
573
|
-
```typescript
|
|
574
|
-
const analyzed = await client.assets.classify({
|
|
575
|
-
name: "large-image.png",
|
|
576
|
-
part: {
|
|
577
|
-
base64: finalPartBase64,
|
|
578
|
-
type: "image/png",
|
|
579
|
-
isFinal: true,
|
|
580
|
-
},
|
|
581
|
-
});
|
|
582
|
-
console.log(analyzed.classification.moderation.status);
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
#### Static Methods
|
|
586
|
-
|
|
587
|
-
##### verifyWebhookSignature(options)
|
|
588
|
-
|
|
589
|
-
Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function; see [Webhooks](#webhooks) for `parseWebhookEvent`, which verifies and parses in one step.
|
|
590
|
-
|
|
591
|
-
```typescript
|
|
592
|
-
static async verifyWebhookSignature(options: {
|
|
593
|
-
payload: string | Uint8Array | ArrayBuffer; // the raw request body (a Buffer works too)
|
|
594
|
-
signature?: string | null; // x-calmlens-signature header
|
|
595
|
-
timestamp?: string | number | null; // x-calmlens-timestamp header
|
|
596
|
-
headers?: Headers | Record<string, string | string[] | undefined>; // read when signature/timestamp are omitted
|
|
597
|
-
secret: string;
|
|
598
|
-
toleranceSeconds?: number; // default WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300)
|
|
599
|
-
now?: Date | number; // default Date.now(), for tests
|
|
600
|
-
}): Promise<boolean>
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
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. Passing parsed JSON as `payload` throws `sdk/invalid-argument`: re-serializing it can change the signed bytes.
|
|
604
|
-
|
|
605
|
-
## Browser and app uploads with upload presets
|
|
606
|
-
|
|
607
|
-
`CalmLensAppClient` uploads from a browser or app through an upload preset, without an API key. Create presets in the portal under Developer → Upload presets.
|
|
608
|
-
|
|
609
|
-
```ts
|
|
610
|
-
import { CalmLensAppClient } from "calmlens/app";
|
|
611
|
-
|
|
612
|
-
// In the browser: no API key
|
|
613
|
-
const client = new CalmLensAppClient({
|
|
614
|
-
uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
|
|
615
|
-
// Only for presets that require a token: called once per new upload
|
|
616
|
-
token: () =>
|
|
617
|
-
fetch("/api/upload-token", { method: "POST" })
|
|
618
|
-
.then((r) => r.json())
|
|
619
|
-
.then((b) => b.token),
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
const asset = await client.assets.classify({
|
|
623
|
-
name: file.name,
|
|
624
|
-
content: { data: file, type: file.type },
|
|
625
|
-
});
|
|
626
|
-
// asset.status (the moderation status) is present when the preset shows decisions to uploaders
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
**Options:** `uploadPresetId` (required), `token` (a string, or a function that returns one or a promise of one), plus `baseUrl`, `fetch`, `uploadFetch`, `timeoutMs`, `uploadTimeoutMs`, `maxRetries`, `maxResponseBytes` and `signal` as for `CalmLensServerClient`. Passing `apiKey` throws `sdk/invalid-config`.
|
|
630
|
-
|
|
631
|
-
The client has `assets.create`, `assets.classify`, `assets.uploadPart` and `assets.retrieve`, for its own uploads, and each returns an `UploadedAsset`. It fetches a new token for each upload and reuses that token for the upload's parts and status checks.
|
|
632
|
-
|
|
633
|
-
The same client works in React Native and Expo apps. Mobile apps send no `Origin` header, so use a preset that requires a token and leave its allowed sites empty. Pass the picked file's bytes, for example `content: { buffer: await new File(uri).arrayBuffer(), type }` with `expo-file-system`. See the [Calmsnap example](https://github.com/CalmLens/sdks/tree/main/examples/mobile-uploads-expo).
|
|
634
|
-
|
|
635
|
-
On your server, sign a single-use token for each upload with `CalmLensServerClient`. Signing is local and makes no request:
|
|
636
|
-
|
|
637
|
-
```ts
|
|
638
|
-
const { token, expiresAt } = await client.uploadTokens.sign({
|
|
639
|
-
uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
|
|
640
|
-
externalId: "post-42", // optional
|
|
641
|
-
expiresInSeconds: 600, // optional, up to 3600
|
|
642
|
-
});
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
Server uploads can also pass `uploadPresetId` to `assets.create` or `assets.classify` to apply a preset's folder, visibility, file types and size limit. See [Upload presets](https://calmlens.com/docs/guides/upload-presets).
|
|
646
|
-
|
|
647
|
-
## Upload widget and media component
|
|
648
|
-
|
|
649
|
-
Ready-made front-end components for each UI library. They share one core, so they behave the same everywhere:
|
|
650
|
-
|
|
651
|
-
| Import | Components |
|
|
652
|
-
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
653
|
-
| `calmlens/react` | `CalmLensUploader`, `CalmLensMedia`, `useCalmLensUpload` (also works in Preact through `preact/compat`) |
|
|
654
|
-
| `calmlens/vue` | `CalmLensUploader`, `CalmLensMedia`, `useCalmLensUpload` |
|
|
655
|
-
| `calmlens/svelte` | `CalmLensUploader`, `CalmLensMedia` (Svelte 5) |
|
|
656
|
-
| `calmlens/elements` | `<calmlens-uploader>`, `<calmlens-media>` for plain HTML, Angular, Solid, Lit and anything else that renders custom elements |
|
|
657
|
-
| `calmlens/ui` | The framework-free core: `createUploadController`, `getMediaState`, messages and styles |
|
|
53
|
+
| `classification.status` | `classification.moderation.status` | Meaning |
|
|
54
|
+
| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
55
|
+
| `completed` | `allowed` | Safe to show. |
|
|
56
|
+
| `completed` | `limited` | Delivered like `allowed`; reduce its reach, such as keeping it out of recommendations. |
|
|
57
|
+
| `completed` | `age-restricted` | Delivered only with `viewerClaims.ageConfirmed=true`. Show it only to age-checked viewers. |
|
|
58
|
+
| `completed` | `blocked` | Your policy blocked it; the CDN doesn't deliver it. Don't show it. |
|
|
59
|
+
| `completed` | `purged` | Your policy blocked it and deleted its content. Don't show it. |
|
|
60
|
+
| `pending` or `processing` | `pending` | Still being received or classified. |
|
|
61
|
+
| `failed` | `pending` | Processing failed; see `assets.retry`. |
|
|
658
62
|
|
|
659
|
-
|
|
63
|
+
The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision. Treat a status you don't recognise as not allowed. See [How CalmLens works](https://calmlens.com/docs/how-it-works).
|
|
660
64
|
|
|
661
|
-
|
|
65
|
+
## What you can do
|
|
662
66
|
|
|
663
|
-
|
|
664
|
-
|
|
67
|
+
- [Moderate text](https://calmlens.com/docs/sdk/javascript#text-moderation), including [replies in a conversation](https://calmlens.com/docs/guides/threaded-moderation), and [classify images, documents, audio, video and websites](https://calmlens.com/docs/guides/classifying-assets).
|
|
68
|
+
- [Read classification results](https://calmlens.com/docs/guides/classification-results) by category and qualifier, and [ask for the complete policy evidence](https://calmlens.com/docs/guides/classification-results#complete-policy-evidence) behind a decision.
|
|
69
|
+
- Send [multipart uploads](https://calmlens.com/docs/sdk/javascript/reference#multipart-uploads) for large files, from any [content payload](https://calmlens.com/docs/sdk/javascript/reference#content-payloads), with [safe retries and idempotency keys](https://calmlens.com/docs/sdk/javascript/reference#retries-and-idempotency).
|
|
70
|
+
- [Upload from browsers and apps](https://calmlens.com/docs/sdk/javascript/reference#app-client) through [upload presets](https://calmlens.com/docs/guides/upload-presets) and locally signed [upload tokens](https://calmlens.com/docs/sdk/javascript/reference#upload-tokens).
|
|
71
|
+
- Drop in the [upload widget and media component](https://calmlens.com/docs/widgets) for React, React Native, Vue, Svelte or custom elements.
|
|
72
|
+
- [Deliver assets from the CDN](https://calmlens.com/docs/guides/delivering-assets) with [lenses and transforms](https://calmlens.com/docs/guides/lenses), [signed URLs](https://calmlens.com/docs/guides/delivering-assets#signed-urls) and [viewer claims](https://calmlens.com/docs/api/cdn#viewer-claims).
|
|
73
|
+
- Record [reviewer decisions](https://calmlens.com/docs/guides/human-review), [retry failed processing](https://calmlens.com/docs/guides/asset-lifecycle#recover-from-processing-failure), and [organise assets in folders](https://calmlens.com/docs/sdk/javascript/reference#folders).
|
|
74
|
+
- [Verify and parse webhooks](https://calmlens.com/docs/sdk/javascript#webhooks), or poll the [events feed](https://calmlens.com/docs/sdk/javascript/reference#events-feed).
|
|
75
|
+
- [Handle errors](https://calmlens.com/docs/sdk/javascript/reference#errors) by stable code, and set [timeouts and request budgets](https://calmlens.com/docs/sdk/javascript/reference#request-budgets).
|
|
76
|
+
- Forward events to a local handler with the bundled [`calmlens` CLI](https://calmlens.com/docs/guides/local-development): `npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens`.
|
|
665
77
|
|
|
666
|
-
|
|
667
|
-
uploadPresetId="preset_4Lk8Qw2Xm9Rt5Vb3Nc7P"
|
|
668
|
-
tokenUrl="/api/upload-token" // POSTs { name, type, size }; answer { token }
|
|
669
|
-
accept="image/*"
|
|
670
|
-
maxFileSize={10 * 1024 * 1024}
|
|
671
|
-
multiple
|
|
672
|
-
onUploaded={({ assetId, status, file }) => savePhoto(assetId)}
|
|
673
|
-
/>;
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
Options: `uploadPresetId`, `token` (a string, or a function of the file returning one), `tokenUrl`, `accept`, `maxFileSize`, `multiple`, `maxFiles`, `concurrency`, `decisionTimeoutMs`, `baseUrl`, `disabled`, `capture` and `messages`. Callbacks: `onUploaded` (not called for blocked or purged files), `onRejected`, `onFailed` and `onRemoved`; Vue emits `uploaded`, `rejected`, `failed` and `removed`, and the web component dispatches `calmlens-uploaded` and friends with the same `detail`.
|
|
677
|
-
|
|
678
|
-
To upload through your own API instead of a preset, pass `upload(file, { signal, onProgress })`, which returns `{ assetId, status? }`, and optionally `checkStatus(assetId)` for pending uploads. The uploader still shows progress, decisions and errors; throw a `CalmLensError` to show its code's message. `createProgressFetch(onProgress)` gives you a `fetch` that reports sent bytes, to pass as `uploadFetch` to your client.
|
|
679
|
-
|
|
680
|
-
**The media component** shows an asset from the CDN's `rendered` resource in a single `<img>`, so the CDN picks the best format the browser accepts. It adds `srcset` widths when you pass `widths` and `sizes`, shows the blurred placeholder while the image loads, and fades the image in. Video and audio play the `original` file, with a rendered poster for video. Age-restricted assets stay behind the placeholder and a notice with a reveal button until you pass `viewerClaims: { ageConfirmed: true }`. Blocked and purged assets, and images that fail to load, show an unavailable notice.
|
|
681
|
-
|
|
682
|
-
```tsx
|
|
683
|
-
import { CalmLensMedia } from "calmlens/react";
|
|
684
|
-
|
|
685
|
-
<CalmLensMedia
|
|
686
|
-
projectId="proj_8fK2mQx7Lp4NvB3tRz9Y"
|
|
687
|
-
assetId={post.assetId}
|
|
688
|
-
status={post.status}
|
|
689
|
-
viewerClaims={{ ageConfirmed: viewer.ageConfirmed }}
|
|
690
|
-
onReveal={askForAgeCheck}
|
|
691
|
-
alt={post.alt}
|
|
692
|
-
aspectRatio={post.aspectRatio}
|
|
693
|
-
widths={[400, 800]}
|
|
694
|
-
sizes="(min-width: 768px) 50vw, 100vw"
|
|
695
|
-
/>;
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
Instead of `projectId` and `assetId`, you can pass an asset from the API as `asset`, for example `<CalmLensMedia asset={asset} alt="…" />`: it supplies the project, ID, moderation status, media type and stored preview (ThumbHash, average colour and size), and props you set win over it. On the web component, set the `asset` property.
|
|
699
|
-
|
|
700
|
-
It also takes `type` (`image`, `video` or `audio`), `resource`, `lens`, `transform`, `url` (a signed URL from your server), `placeholderHash` (the asset's `content.image.thumbhash`, drawn in the browser so no placeholder request is needed), `placeholderUrl`, `averageColor`, `fit`, `position`, `lazy`, `fetchPriority` and `controls`.
|
|
701
|
-
|
|
702
|
-
The React components render their stylesheet with them: React places it in the page's head once, on the server too. With Vue, Svelte or the web components, import `calmlens/styles.css` once (or link it). Nothing is added to the page at runtime. Theme the components with `--calmlens-accent`, `--calmlens-radius` and the other `--calmlens-*` properties, which take precedence on any element. To style the `calmlens-*` classes yourself, pass `unstyled` to the React components, or leave out the stylesheet import. All text can be replaced or translated with `messages`, keyed by message name or error code.
|
|
703
|
-
|
|
704
|
-
The [widget playground](https://github.com/CalmLens/sdks/tree/main/examples/widget-playground) runs every component against a mock API.
|
|
705
|
-
|
|
706
|
-
## Asset Types
|
|
707
|
-
|
|
708
|
-
Assets can be of the following types:
|
|
709
|
-
- `image` - Images (JPEG, PNG, GIF, WebP, etc.)
|
|
710
|
-
- `video` - Video files (MP4, WebM, etc.)
|
|
711
|
-
- `audio` - Audio files (MP3, WAV, etc.)
|
|
712
|
-
- `plaintext-document` - Plain-text formats such as TXT, JSON, CSV, and YAML
|
|
713
|
-
- `multimedia-document` - Documents such as PDF, DOCX, DOC, RTF, and ODT
|
|
714
|
-
- `website` - Web pages and URLs
|
|
715
|
-
- `file` - Other supported binary files
|
|
716
|
-
|
|
717
|
-
## Asset Status
|
|
718
|
-
|
|
719
|
-
Upload, classification and content each have a status; moderation has its own decision:
|
|
720
|
-
|
|
721
|
-
- `upload.status`: `uploading` while source content is arriving, then `completed`
|
|
722
|
-
- `classification.status`: `pending`, `processing`, `completed` or `failed`, with `classification.failureReason` saying why processing failed (`null` otherwise): `file-too-large`, `duration-limit`, `resolution-limit`, `unsupported-format`, `undecodable-content`, `multiple-tracks`, `type-mismatch`, `free-upload-limit`, `child-failed` or `processing-error`
|
|
723
|
-
- `classification.moderation.status`: `pending` until moderation finishes, then `allowed`, `limited` (delivered like `allowed`, but marked so your app can reduce its reach, such as keeping it out of recommendations, trending and notifications, ranking it lower or collapsing it), `age-restricted` (delivered only with `viewerClaims.ageConfirmed=true`), `blocked` (not delivered) or `purged` (not delivered, content deleted). The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision; each review's `decision` is one of the five decided statuses.
|
|
724
|
-
- `content.status`: `temporary` (kept only until processing finishes), `stored` or `removed`, with `content.removalReason` `policy` (purged), `retention` (`keepContentAfterProcessing` off), `requested`, `parent-removed` or `disposed`
|
|
725
|
-
|
|
726
|
-
## Moderation Reports
|
|
727
|
-
|
|
728
|
-
Completed reports always contain the eight parent categories: `sexual`, `violence`, `self-harm`, `harassment`, `hate`, `criminal-activity`, `personal-information`, and `profanity`. Category-specific detail is nested under `qualifiers`, for example `classification.moderation.raw.categories.violence.qualifiers["violence-graphic"]`. Reports have no `subcategories` field or dedicated minor-safety placeholder. Retired generic qualifier IDs are rejected.
|
|
729
|
-
|
|
730
|
-
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.
|
|
731
|
-
|
|
732
|
-
```typescript
|
|
733
|
-
const violence = asset.classification.moderation.raw?.categories.violence;
|
|
734
|
-
if (violence?.status === "evaluated" || violence?.status === "partial") {
|
|
735
|
-
console.log(violence.score);
|
|
736
|
-
}
|
|
737
|
-
```
|
|
738
|
-
|
|
739
|
-
See the [classification results guide](https://calmlens.com/docs/guides/classification-results) for aggregation, provider coverage, OCR merging, and policy actions. General Sexual Content moderation does not estimate age or provide dedicated CSAM detection.
|
|
740
|
-
|
|
741
|
-
## Asset Visibility
|
|
742
|
-
|
|
743
|
-
- `public` - Retained content can be requested directly from the CDN
|
|
744
|
-
- `private` - Retained content requires a signed URL or an authenticated owner session
|
|
745
|
-
|
|
746
|
-
## Error Handling
|
|
747
|
-
|
|
748
|
-
Every failure is a `CalmLensError`, whether the API returned an error or the request never completed. Branch on its stable `code`:
|
|
749
|
-
|
|
750
|
-
```typescript
|
|
751
|
-
import { CALMLENS_ERROR_CODES, CalmLensError } from "calmlens";
|
|
752
|
-
|
|
753
|
-
try {
|
|
754
|
-
await client.assets.retrieve({ assetId });
|
|
755
|
-
} catch (error) {
|
|
756
|
-
if (!CalmLensError.isCalmLensError(error)) throw error;
|
|
757
|
-
if (error.code === CALMLENS_ERROR_CODES.ASSET_NOT_FOUND) return undefined;
|
|
758
|
-
console.error(error.code, error.status, error.requestId, error.details);
|
|
759
|
-
throw error;
|
|
760
|
-
}
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
| Property | Meaning |
|
|
764
|
-
| ------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
765
|
-
| `code` | A stable identifier such as `asset/not-found` or `sdk/timeout`.`CALMLENS_ERROR_CODES` lists the documented ones |
|
|
766
|
-
| `status` | The HTTP status, when the API responded |
|
|
767
|
-
| `requestId` | The `X-Request-Id` response header (the error body's `requestId`). Include it when you contact support |
|
|
768
|
-
| `headers` | The response headers, when the API responded |
|
|
769
|
-
| `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`) |
|
|
770
|
-
| `details` | Structured diagnostics from the API, such as validation issues |
|
|
771
|
-
| `cause` | The underlying error, for transport failures |
|
|
772
|
-
|
|
773
|
-
`error.isNotFound()` is true for `404`, and `error.isRetryable()` for transport failures, timeouts (`sdk/timeout`, including `waitForDecision`'s deadline), `429`, `502`, `503`, `504` and `idempotency/request-in-progress`. `instanceof CalmLensError` and `CalmLensError.isCalmLensError(error)` recognise errors from any copy of the SDK, including the ES module and CommonJS builds loaded side by side.
|
|
774
|
-
|
|
775
|
-
Codes the SDK raises itself:
|
|
776
|
-
|
|
777
|
-
| Code | Meaning |
|
|
778
|
-
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
779
|
-
| `sdk/invalid-argument` | A method argument is missing or invalid, for example an empty `assetId` |
|
|
780
|
-
| `sdk/invalid-config` | The client options are invalid |
|
|
781
|
-
| `sdk/transport-error` | The request could not be completed (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) |
|
|
782
|
-
| `sdk/timeout` | A deadline passed |
|
|
783
|
-
| `sdk/cancelled` | Your `AbortSignal` cancelled the request |
|
|
784
|
-
| `sdk/invalid-response` | A successful response whose body is not the expected JSON, or a page that repeats a cursor the iterator already used |
|
|
785
|
-
| `sdk/http-error` | An HTTP failure without a CalmLens error body, for example from a proxy, or a redirect, which the SDK never follows |
|
|
786
|
-
| `sdk/response-too-large` | A response larger than `maxResponseBytes` |
|
|
787
|
-
| `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent` |
|
|
788
|
-
|
|
789
|
-
Upload content is checked before it is sent with the same codes the API uses, such as `upload/invalid-payload` and `upload/invalid-mime-type`.
|
|
790
|
-
|
|
791
|
-
## Retries and idempotency
|
|
792
|
-
|
|
793
|
-
The client retries a request when repeating it is safe: reads (`GET`), and creates and uploads, which always carry an `Idempotency-Key` header. It retries failures that happened before any response, timeouts (`sdk/timeout`, after `timeoutMs`), `429`, `502`, `503`, `504` and `idempotency/request-in-progress`, up to `maxRetries` times (default 2) with jittered exponential backoff, waiting for `Retry-After` when the API sends one (up to 60 seconds; longer waits are returned to you as the error's `retryAfter`). Other writes, such as `assets.move`, and uploads of a `ReadableStream`, which the first attempt consumed, are never retried.
|
|
794
|
-
|
|
795
|
-
`assets.create`, `assets.classify`, `text.classify` and `assets.uploadPart` generate a new key for each call and reuse it for their own retries, so a retry returns the original response instead of creating a second asset. To retry safely across processes or restarts, pass your own key:
|
|
796
|
-
|
|
797
|
-
```typescript
|
|
798
|
-
await client.text.classify({
|
|
799
|
-
text: comment.body,
|
|
800
|
-
externalId: `comment-${comment.id}`,
|
|
801
|
-
idempotencyKey: `comment-${comment.id}`,
|
|
802
|
-
});
|
|
803
|
-
```
|
|
804
|
-
|
|
805
|
-
The API keeps a response for 24 hours. Reusing a key for a different request fails with `idempotency/key-reused`.
|
|
806
|
-
|
|
807
|
-
## Forward compatibility
|
|
808
|
-
|
|
809
|
-
The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetClassificationStatus`, `AssetModerationStatus`, `AssetModerationDecision`, `AssetContentStatus`, `AssetContentRemovalReason`, `AssetType` and `AssetVisibility` include `string` for that reason: handle values you don't know (for example, treat an unknown status as "not ready"). Event types and fact keys are typed for the ones this version knows; keep a default branch when you switch on them.
|
|
78
|
+
Every public SDK operation accepts one options object, so call sites stay stable as optional fields are added. The [SDK reference](https://calmlens.com/docs/sdk/javascript/reference) lists every option and method.
|
|
810
79
|
|
|
811
80
|
## Testing
|
|
812
81
|
|
|
813
|
-
`calmlens/testing`
|
|
814
|
-
|
|
815
|
-
The `calmlens`, `/server` and `/app` entry points do not load or export testing helpers, to keep production bundles small. Keep `/testing` imports in test files.
|
|
816
|
-
|
|
817
|
-
```typescript
|
|
818
|
-
import { MockCalmLensServerClient, createMockApiKey } from "calmlens/testing";
|
|
819
|
-
|
|
820
|
-
const client = new MockCalmLensServerClient({
|
|
821
|
-
apiKey: createMockApiKey(),
|
|
822
|
-
projectId: "proj_8fK2mQx7Lp4NvB3tRz9Y",
|
|
823
|
-
});
|
|
824
|
-
```
|
|
825
|
-
|
|
826
|
-
Like the API, the mock gives each new object an ID with its kind's prefix, an underscore and 20 random base62 characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c` or `fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P`. For predictable IDs, pass `mockOptions.createId`, which receives the prefix (`asset`, `fldr`, `hl`, `hle`, `lens`, `evt` or `rev`).
|
|
827
|
-
|
|
828
|
-
`MockCalmLensServerClient` is a `CalmLensServerClient`: every method runs the real client's code (validation, request building, response and error handling, the lens cache), and its `fetch` is an in-memory API that answers each request as CalmLens does, with the API's HTTP statuses, JSON bodies and error bodies (`code`, `message`, `details`) and an `X-Request-Id`. It accepts the same options as `CalmLensServerClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, `maxRetries`, ...) and validates them the same way, plus `mockOptions`; requests never leave the process, whatever `fetch` you pass. `maxRetries` defaults to 0, so a failed request fails once; set it to test retries. Waits take no real time: retry backoff and `assets.waitForDecision` only move the mock's clock on, so `waitForDecision` fails with `sdk/timeout` straight away for an asset that stays pending. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`), and `cdn.serve` requests are answered by the in-memory CDN.
|
|
829
|
-
|
|
830
|
-
The in-memory API behaves like CalmLens where tests depend on it: `assets.list` applies `ordering`, `folderId` and `filter` expressions (comparisons joined by `;`, which binds tighter than `,`, and grouped with parentheses; anything else throws `sdk/invalid-argument` rather than returning wrong results), with cursors that resume after the last asset returned and only continue the query they came from; `assets.search` runs the API's keyword search, where every word must appear in the name, ID, external ID, description, tags, type, content format or extension, or moderation status, with the API's scores and `matchedFields`; assets created in a row get increasing `createdAt` values; multipart parts must arrive in order, every part but the last is at least 5 MiB, images total 10 MiB to 20 MiB, parts add up to any declared `totalSizeInBytes`, and the queue mode chosen at the start applies to the final part; out-of-range `pageSize`, `limit` and search queries fail with the API's `validation/too-small` or `validation/too-big`; unset asset fields are `null`, as the API returns them; a `failed` decision sets `classification.failureReason` to `processing-error`; content is removed with reason `retention` once processing is done unless `keepContentAfterProcessing` is set, an automated `purged` outcome removes it with reason `policy`, and `assets.deleteContent` and a reviewer's `purged` decision remove it with reason `requested`; a reused `idempotencyKey` replays the first response; and `cdn.serve` returns CDN-like responses with JSON error bodies: blocked, purged and failed assets and removed content get `403` `asset/not-available` whatever the key, then private assets get `403` `asset/api-key-missing-permission` unless the key has `assets:download` (keys from `createMockApiKey()` have no grants); a classified image's `placeholder` is a PNG, public for every asset whatever its visibility or status, and refused once the asset is purged or its content removed. Originals carry the CDN's `Cache-Control`, `Cache-Tag` and protection headers, and images an `ETag` and `Expires`. `client.setApiKeyGrants(grants)` changes the key's grants mid-test. `client.decide(assetId, decision)` applies a reviewer's decision (`allowed`, `limited`, `age-restricted`, `blocked` or `purged`) and adds the matching `asset.<decision>` event with a `decided-by: reviewer` fact; `assets.decide` checks decisions like the API (`asset/processing` while an upload is open or still processing, `asset/decision-not-allowed`, `asset/decision-unchanged`, `asset/content-purged`) and records its note with the reviewer `An API key`; `folders.list` and `hashLists.list` return cursor pages like the API; `assets.delete`, `assets.deleteContent` and `assets.retry` add `asset.deleted`, `asset.content-removed` and `asset.retried` events; and every upload records `asset.uploaded`, then `asset.classified` and the automated `asset.<status>` once it is decided, or `asset.failed`. `await client.enqueueEvent({ type, ... })` adds an event that `events.list` and `events.pages` then return; one about an asset carries the asset as it is.
|
|
831
|
-
|
|
832
|
-
`mockOptions.moderate` decides each asset the mock moderates. It receives the stored `asset`, its `content` (`bytes`, and `text` for a plain-text upload), the `trigger` (`upload`, `retry`, `rerun` or `complete`) and whether the API would `queued` the work, and returns a status (`pending`, `failed`, `allowed`, `limited`, `age-restricted`, `blocked` or `purged`) or a decision built with `mockDecision`. A decision fills the asset's report as CalmLens does: every category (unflagged ones evaluated with score 0), the policy's actions and `flaggedCategories`. Without `moderate`, queued uploads, retries and reruns stay `pending` and everything else is `allowed`. `client.completeProcessing(assetId, decision?)` finishes a pending asset as the pipeline would, with the decision given or the one `moderate` returns for `complete`:
|
|
833
|
-
|
|
834
|
-
```typescript
|
|
835
|
-
import { MockCalmLensServerClient, createMockApiKey, mockDecision } from "calmlens/testing";
|
|
836
|
-
|
|
837
|
-
const client = new MockCalmLensServerClient({
|
|
838
|
-
apiKey: createMockApiKey(),
|
|
839
|
-
projectId,
|
|
840
|
-
mockOptions: {
|
|
841
|
-
moderate: ({ content }) =>
|
|
842
|
-
content.text?.includes("kill")
|
|
843
|
-
? mockDecision("blocked").flag("violence", 0.92, { "violence-threat": 0.9 })
|
|
844
|
-
: "allowed",
|
|
845
|
-
},
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
const comment = await client.text.classify({ text: "I will kill you" });
|
|
849
|
-
expect(comment.classification.moderation.raw?.categories.violence).toMatchObject({ score: 0.92 });
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
A `moderate` callback decides queued uploads straight away too; return `pending` for them (`queued && trigger === "upload"`) to finish them later. With the default, a queued upload stays pending until you finish it:
|
|
853
|
-
|
|
854
|
-
```typescript
|
|
855
|
-
const upload = await client.assets.create({
|
|
856
|
-
name: "photo.png",
|
|
857
|
-
content: { base64: pngBase64, type: "image/png" },
|
|
858
|
-
}); // pending
|
|
859
|
-
await client.completeProcessing(upload.id, mockDecision("age-restricted").flag("sexual", 0.8));
|
|
860
|
-
```
|
|
861
|
-
|
|
862
|
-
Every request the client sends is recorded in `client.calls` as `{ operation, method, url, path, query, headers, body, status }`: the client method that sent it (`"assets.retrieve"`, `"events.list"`, `"cdn.serve"`, ...), the request as the API received it (`path` relative to the API or CDN base URL, `body` as parsed JSON, the text of a text upload or the bytes of a file) and the HTTP status of the answer (`undefined` when it failed without one). Methods that send nothing, such as `uploadTokens.sign` or an `ensureLens` answered from the lens cache, are not recorded. `client.callsTo(operation)` filters them; a request several methods send matches each of them, so `callsTo("text.classify")` also lists text uploads through `assets.classify`, and `callsTo("assets.waitForDecision")` the `assets.retrieve` requests it polls with. To test failure handling, `client.failNextRequest()` makes the next request fail like a network failure, so the client throws `sdk/transport-error`; pass `"timeout"` for `sdk/timeout`, an API error code such as `"upload/limit-exceeded"` for that API error with its HTTP status, a `CalmLensError` to throw it as is or another `Error` to fail with it as the cause, and `{ operation }` to fail only the next request that method sends. An API error also takes `details` and `retryAfter` (seconds, sent as `Retry-After`). Each queued failure fails one request, so a retry takes the next queued failure or reaches the API. `client.clearCalls()` forgets the requests recorded so far.
|
|
863
|
-
|
|
864
|
-
```typescript
|
|
865
|
-
const client = new MockCalmLensServerClient({
|
|
866
|
-
apiKey: createMockApiKey(),
|
|
867
|
-
projectId,
|
|
868
|
-
timeoutMs: 5_000,
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
client.failNextRequest("timeout", { operation: "text.classify" });
|
|
872
|
-
await expect(postComment(client, "hello")).rejects.toMatchObject({ code: "sdk/timeout" });
|
|
873
|
-
|
|
874
|
-
await postComment(client, "hello");
|
|
875
|
-
expect(client.callsTo("text.classify").at(-1)).toMatchObject({ body: "hello", status: 200 });
|
|
876
|
-
```
|
|
877
|
-
|
|
878
|
-
To test a webhook handler, build a signed delivery the way CalmLens sends it:
|
|
879
|
-
|
|
880
|
-
```typescript
|
|
881
|
-
import { createWebhookHeaders, toWebhookEventPayload } from "calmlens/testing";
|
|
882
|
-
|
|
883
|
-
const event = await client.enqueueEvent({ type: "asset.allowed", assetId: asset.id });
|
|
884
|
-
const body = JSON.stringify(toWebhookEventPayload(event));
|
|
885
|
-
const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
|
|
886
|
-
const response = await app.request("/api/webhooks/calmlens", { method: "POST", body, headers });
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
To compare upload tokens with known values, `signUploadToken(apiKey, options, { now, nonce })` signs with a fixed clock (milliseconds since the epoch) and nonce, checking `options` like `uploadTokens.sign`. The mock's own `uploadTokens.sign` signs at `mockOptions.now` with a random nonce.
|
|
890
|
-
|
|
891
|
-
`MockCalmLensAppClient` is the real `CalmLensAppClient` on the same in-memory API: it applies upload preset rules from `mockOptions.preset` (`requireToken`, `allowedContentTypes`, `maxFileSizeInBytes`, `showDecisions`, `visibility`, `keepContentAfterProcessing`, `folderId`) with the API's error codes, can check and continue only the uploads it made, and stores uploads in a `MockCalmLensServerClient`. Pass yours as `mockOptions.server` to check uploads from the server side and serve them from its CDN, or read the one it creates from `client.server`. To run the upload widget on it, pass `createClient: (options) => new MockCalmLensAppClient({ ...options, mockOptions })`.
|
|
892
|
-
|
|
893
|
-
For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
|
|
894
|
-
|
|
895
|
-
## Webhooks
|
|
896
|
-
|
|
897
|
-
A `webhook` connector POSTs each event to your URL as JSON:
|
|
898
|
-
|
|
899
|
-
```json
|
|
900
|
-
{
|
|
901
|
-
"id": "…",
|
|
902
|
-
"projectId": "…",
|
|
903
|
-
"type": "asset.blocked",
|
|
904
|
-
"severity": "high",
|
|
905
|
-
"title": "…",
|
|
906
|
-
"description": null,
|
|
907
|
-
"assetId": "…",
|
|
908
|
-
"asset": null,
|
|
909
|
-
"facts": [{ "key": "asset-name", "value": "photo.jpg" }],
|
|
910
|
-
"createdAt": 1760000000000
|
|
911
|
-
}
|
|
912
|
-
```
|
|
913
|
-
|
|
914
|
-
The body is the event exactly as [`events.list`](#events-feed) returns it.
|
|
915
|
-
|
|
916
|
-
Each delivery has these headers (the names are exported as constants):
|
|
917
|
-
|
|
918
|
-
| Header | Constant | Value |
|
|
919
|
-
| ---------------------- | -------------------------- | ------------------------------------------------------------------ |
|
|
920
|
-
| `x-calmlens-delivery` | `WEBHOOK_DELIVERY_HEADER` | Delivery ID (the same across retries) |
|
|
921
|
-
| `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
|
|
922
|
-
| `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
|
|
923
|
-
|
|
924
|
-
Verify the exact raw request body before parsing it; re-serializing parsed JSON can change the signed bytes. `parseWebhookEvent` verifies the delivery and returns the parsed event, or throws `sdk/invalid-webhook-signature` (bad signature or a timestamp more than `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, 5 minutes, from your clock, which stops replays) or `sdk/invalid-webhook-payload`. `verifyWebhookSignature` takes the same options and returns a boolean. Both accept the body as a string, `Uint8Array`/`Buffer` or `ArrayBuffer`, and read the two headers from `headers` (a `Headers` object or Node's `req.headers`) when you don't pass `signature` and `timestamp`.
|
|
925
|
-
|
|
926
|
-
The result is typed as an extensible `WebhookEventPayload`. Event types and fact keys may be unfamiliar, and fact values are `unknown`; validate the facts your handler uses. Extra fields remain available. `isTestWebhookEvent(event)` is `true` for a test send from the portal or the CLI, which carries the `{ key: "test", value: true }` fact. The [webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists current event types. Deliveries are retried, so make your handler idempotent by `event.id`.
|
|
927
|
-
|
|
928
|
-
**Fetch API** (Next.js route handlers, Remix, Cloudflare Workers, Bun, Deno):
|
|
929
|
-
|
|
930
|
-
```typescript
|
|
931
|
-
import { CalmLensError, parseWebhookEvent } from "calmlens/server";
|
|
932
|
-
|
|
933
|
-
export async function POST(request: Request) {
|
|
934
|
-
let event;
|
|
935
|
-
try {
|
|
936
|
-
event = await parseWebhookEvent({
|
|
937
|
-
payload: await request.text(),
|
|
938
|
-
headers: request.headers,
|
|
939
|
-
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
940
|
-
});
|
|
941
|
-
} catch (error) {
|
|
942
|
-
if (CalmLensError.isCalmLensError(error))
|
|
943
|
-
return new Response("Invalid delivery", { status: 401 });
|
|
944
|
-
throw error;
|
|
945
|
-
}
|
|
946
|
-
// Queue the work, keyed by event.id, then respond quickly.
|
|
947
|
-
return new Response(null, { status: 204 });
|
|
948
|
-
}
|
|
949
|
-
```
|
|
950
|
-
|
|
951
|
-
**Express**: use `express.raw()` for this route so `req.body` is the unparsed `Buffer`:
|
|
952
|
-
|
|
953
|
-
```typescript
|
|
954
|
-
app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), async (req, res) => {
|
|
955
|
-
try {
|
|
956
|
-
const event = await parseWebhookEvent({
|
|
957
|
-
payload: req.body,
|
|
958
|
-
headers: req.headers,
|
|
959
|
-
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
960
|
-
});
|
|
961
|
-
// ...
|
|
962
|
-
res.sendStatus(204);
|
|
963
|
-
} catch {
|
|
964
|
-
res.sendStatus(401);
|
|
965
|
-
}
|
|
966
|
-
});
|
|
967
|
-
```
|
|
968
|
-
|
|
969
|
-
**Hono**: `payload: await c.req.text()`, `headers: c.req.raw.headers`.
|
|
970
|
-
|
|
971
|
-
**Fastify**: register a raw body parser for the route, for example `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => done(null, body))`, then pass `payload: request.body` and `headers: request.headers`.
|
|
972
|
-
|
|
973
|
-
## Command-line tool
|
|
974
|
-
|
|
975
|
-
This package includes the `calmlens` [CLI](https://calmlens.com/docs/guides/local-development). Because the package is named `calmlens`, you can run it without adding a dependency:
|
|
976
|
-
|
|
977
|
-
```bash
|
|
978
|
-
npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens --env-file .env.local
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
Install it globally with `npm install -g calmlens`. In a project that depends on the SDK, `npx calmlens` runs the installed version. Standalone executables for macOS, Linux and Windows, which need no Node.js, are attached to the [CLI releases](https://github.com/CalmLens/sdks/releases?q=cli-v&expanded=true) with a `SHA256SUMS` file, and Homebrew installs them:
|
|
982
|
-
|
|
983
|
-
```bash
|
|
984
|
-
brew tap calmlens/sdks https://github.com/CalmLens/sdks
|
|
985
|
-
brew install calmlens/sdks/calmlens
|
|
986
|
-
```
|
|
987
|
-
|
|
988
|
-
Webhook connectors must use public HTTPS URLs, so during development use `listen` to forward events to a local handler instead. Each event is sent with the same body, headers and signature as a real delivery. The CLI reads `CALMLENS_API_KEY`, `CALMLENS_PROJECT_ID` and `CALMLENS_WEBHOOK_SECRET` from the environment or `--env-file`, or uses the key from `calmlens login`; 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. The CLI's version is the SDK's version.
|
|
989
|
-
|
|
990
|
-
The same feed is available in code with [`events.list`](#events-feed).
|
|
991
|
-
|
|
992
|
-
## Events feed
|
|
993
|
-
|
|
994
|
-
`client.events.list({ cursor?, pageSize? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
|
|
995
|
-
|
|
996
|
-
```typescript
|
|
997
|
-
{
|
|
998
|
-
items: TypedEvent[]; // { id, projectId, type, severity, title, description, assetId, asset, facts, createdAt }
|
|
999
|
-
nextCursor: string; // pass back as `cursor` to continue after the last event returned
|
|
1000
|
-
hasMore: boolean; // true when more events are already waiting after `nextCursor`
|
|
1001
|
-
}
|
|
1002
|
-
```
|
|
1003
|
-
|
|
1004
|
-
- Without `cursor`, it returns **no events** and a `nextCursor` for the current time, so you only see events created after you start reading. It does not return history.
|
|
1005
|
-
- With `cursor`, it returns up to `pageSize` events (1 to 100, default 50) created after that cursor. When there are none, `nextCursor` is the `cursor` you passed; it is never null.
|
|
1006
|
-
- Keep passing the latest `nextCursor` as `cursor` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
|
|
1007
|
-
- An unknown or malformed cursor fails with the `CalmLensError` code `validation/invalid-cursor`.
|
|
1008
|
-
|
|
1009
|
-
`client.events.pages({ cursor, pageSize })` follows the cursor for you: it yields each page until no more events are waiting.
|
|
1010
|
-
|
|
1011
|
-
```typescript
|
|
1012
|
-
let { nextCursor: cursor } = await client.events.list(); // no events, a cursor for "now"
|
|
1013
|
-
|
|
1014
|
-
async function drainEvents() {
|
|
1015
|
-
for await (const page of client.events.pages({ cursor, pageSize: 100 })) {
|
|
1016
|
-
for (const event of page.items) {
|
|
1017
|
-
// narrow on event.type to read its typed facts
|
|
1018
|
-
console.log(event.type, event.facts);
|
|
1019
|
-
}
|
|
1020
|
-
cursor = page.nextCursor; // save it to resume after a restart
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
setInterval(drainEvents, 5_000);
|
|
1025
|
-
```
|
|
1026
|
-
|
|
1027
|
-
Events carry `facts: { key, value, url? }[]`; `description`, `assetId` and `asset` are `null` when the event has none. Webhook deliveries carry the same events in the same shape.
|
|
1028
|
-
|
|
1029
|
-
## Request budgets
|
|
1030
|
-
|
|
1031
|
-
Set `timeoutMs` (default 120000), `uploadTimeoutMs`, `maxRetries` or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client; the scoped client shares the original's credentials and lens cache. `timeoutMs` applies to each attempt and includes reading the response body; binary uploads have no deadline unless you set `uploadTimeoutMs`, and `cdn.serve`'s deadline ends when headers arrive, so large transfers are never cut off. Requests never follow redirects, so the API key is never sent to another origin.
|
|
1032
|
-
|
|
1033
|
-
Requests send `User-Agent: calmlens-js/<version> (<runtime>/<version>)` from server runtimes; `SDK_VERSION` exports the version. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
|
|
1034
|
-
|
|
1035
|
-
`text.classify` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.classify` waits for classification.
|
|
1036
|
-
|
|
1037
|
-
## Releasing
|
|
1038
|
-
|
|
1039
|
-
Run `bun run changeset` to choose the release type and write release notes. Merging the Version SDKs pull request releases the version and updates this package's `CHANGELOG.md`. See the repository's [.changeset release guide](../.changeset/README.md).
|
|
1040
|
-
|
|
1041
|
-
Once the release commit's SDK Tests and SDK examples runs pass, `.github/workflows/release-npm.yml` publishes one build to npm with provenance, as `calmlens` and as `@calmlens/sdk`. The CLI's standalone executables and Homebrew formula are released in this repository under `cli-v<version>` by `.github/workflows/release-cli.yml`. `bun run build:cli` builds the npm entry, `bin/calmlens.js` (the package build includes it), and `bun run build:cli:binary` builds a standalone executable in `dist/`; set `CALMLENS_BUILD_TARGET`, such as `bun-linux-x64`, to cross-compile it.
|
|
1042
|
-
|
|
1043
|
-
### Test-input images
|
|
1044
|
-
|
|
1045
|
-
The optional `calmlens/test-inputs` entry generates and recognises the same
|
|
1046
|
-
project-specific colour grids used by the CalmLens portal:
|
|
1047
|
-
|
|
1048
|
-
```ts
|
|
1049
|
-
import {
|
|
1050
|
-
paintTestImage,
|
|
1051
|
-
detectTestImage,
|
|
1052
|
-
generateTestTextCode,
|
|
1053
|
-
TEST_INPUT_PRESETS,
|
|
1054
|
-
} from "calmlens/test-inputs";
|
|
1055
|
-
|
|
1056
|
-
const input = TEST_INPUT_PRESETS.mixed;
|
|
1057
|
-
const image = await paintTestImage(input, projectTestInputKey);
|
|
1058
|
-
const decoded = await detectTestImage(image, projectTestInputKey);
|
|
1059
|
-
const text = await generateTestTextCode(input, projectTestInputKey);
|
|
1060
|
-
// Encode image.rgba as a PNG using image.width and image.height.
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
Encode the returned RGBA bytes with your image library to download a PNG. To
|
|
1064
|
-
recognise a downloaded image, decode it into RGBA first. These helpers have no
|
|
1065
|
-
DOM, image-codec, or Cloudflare dependencies. Upload the PNG unchanged: resizing, cropping and lossy conversion invalidate its authenticated grid. Presets and custom instance definitions use the same format. Regenerate samples created with the older fixed-category format.
|
|
1066
|
-
The entry also exports `generateTestTextCode` and `detectTestTrigger` for text
|
|
1067
|
-
test inputs. Use your project's test input key; another project's key does not
|
|
1068
|
-
activate its test signals.
|
|
1069
|
-
|
|
1070
|
-
Image moderation considers readable text directly. Optional OCR (`processingOptions.features.ocr.enabled: true` on the wire) adds a text moderation pass and stores retained text in the image’s `content.extractedText`. No separate text child is created. Read the image’s combined `classification.moderation`; removing its content also removes the extracted text.
|
|
1071
|
-
|
|
1072
|
-
### Viewer context for CDN delivery
|
|
1073
|
-
|
|
1074
|
-
CDN methods accept optional viewer claims (`viewerClaims`, or the language's `viewer_claims` / `ViewerClaims` field). The object contains `region` and `ageConfirmed`, using the SDK's normal field naming. Region defaults to the country of the connection reaching CalmLens; age confirmation defaults to false. Proxies should forward the incoming viewer country rather than rely on their outgoing connection's country. CalmLens accepts these as customer assertions without independently verifying them.
|
|
82
|
+
`calmlens/testing` runs the real clients against an in-memory CalmLens API and CDN, with moderation outcomes, recorded requests, injected failures and signed webhook deliveries. See [JavaScript testing](https://calmlens.com/docs/sdk/javascript/testing).
|
|
1075
83
|
|
|
1076
|
-
|
|
84
|
+
## Links
|
|
1077
85
|
|
|
1078
|
-
|
|
86
|
+
- [Documentation](https://calmlens.com/docs/sdk/javascript)
|
|
87
|
+
- [SDK reference](https://calmlens.com/docs/sdk/javascript/reference)
|
|
88
|
+
- [HTTP API reference](https://calmlens.com/docs/api)
|
|
89
|
+
- [Changelog](https://github.com/CalmLens/sdks/blob/main/javascript/CHANGELOG.md)
|
|
90
|
+
- [License](https://github.com/CalmLens/sdks/blob/main/javascript/LICENSE)
|