@calmlens/sdk 0.16.3 → 0.18.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 +425 -181
- package/bin/calmlens.js +16016 -0
- package/bin/package.json +1 -0
- package/cjs/BrowserUpload-BT091eOB.d.ts +11 -0
- package/cjs/BrowserUpload-CQwXEGFv.js +10 -0
- package/cjs/CalmLensAppClient-CHQGu-Do.js +89 -0
- package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/cjs/CalmLensConnection-B8Bu2j3y.js +659 -0
- package/cjs/CalmLensServerClient-C5o2A8MW.js +645 -0
- package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
- package/cjs/FileIcons-Ob003MdS.js +175 -0
- package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
- package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/cjs/ModerationContextLimits-DelWfmiJ.js +1311 -0
- package/cjs/ReactUpload-CAyCsyG6.d.ts +17 -0
- package/cjs/ReactUpload-Cfg5JYUq.js +54 -0
- package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/cjs/TestTriggers-DTc_iMJF-BjMPq4_3.js +1327 -0
- package/cjs/UploadController-BGO1eyzb.js +618 -0
- package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/cjs/app.d.ts +7 -44
- package/cjs/app.js +11 -89
- package/cjs/elements.d.ts +88 -0
- package/cjs/elements.js +555 -0
- package/cjs/file-icons.d.ts +67 -0
- package/cjs/file-icons.js +10 -0
- package/cjs/index-CI4BzN4W.d.ts +252 -0
- package/cjs/index.d.ts +6 -4
- package/cjs/index.js +9 -4
- package/cjs/react-native.d.ts +56 -0
- package/cjs/react-native.js +495 -0
- package/cjs/react.d.ts +30 -0
- package/cjs/react.js +370 -0
- package/cjs/server.d.ts +12 -191
- package/cjs/server.js +18 -378
- package/cjs/test-inputs.d.ts +2 -2
- package/cjs/test-inputs.js +9 -264
- package/cjs/testing.d.ts +285 -190
- package/cjs/testing.js +2393 -1203
- package/cjs/ui-CC1GOoMg.js +583 -0
- package/cjs/ui-wIiMfqMB.d.ts +526 -0
- package/cjs/ui.d.ts +2 -0
- package/cjs/ui.js +27 -0
- package/cjs/vue.d.ts +490 -0
- package/cjs/vue.js +419 -0
- package/esm/BrowserUpload-BT091eOB.d.ts +11 -0
- package/esm/BrowserUpload-D2xBWqhm.js +5 -0
- package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/esm/CalmLensAppClient-DuFas_dW.js +84 -0
- package/esm/CalmLensConnection-MxwwLXdl.js +582 -0
- package/esm/CalmLensServerClient-D80_mKpW.js +586 -0
- package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
- package/esm/FileIcons-M6dzjFxd.js +128 -0
- package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
- package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/esm/ModerationContextLimits-BaX2QnU8.js +1114 -0
- package/esm/ReactUpload-CAyCsyG6.d.ts +17 -0
- package/esm/ReactUpload-CL2XC8WS.js +37 -0
- package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/esm/TestTriggers-DTc_iMJF-HfB533Gx.js +1267 -0
- package/esm/UploadController-qjoYGCwx.js +589 -0
- package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/esm/app.d.ts +7 -44
- package/esm/app.js +3 -86
- package/esm/elements.d.ts +88 -0
- package/esm/elements.js +528 -0
- package/esm/file-icons.d.ts +67 -0
- package/esm/file-icons.js +1 -0
- package/esm/index-CI4BzN4W.d.ts +252 -0
- package/esm/index.d.ts +6 -4
- package/esm/index.js +1 -1
- package/esm/react-native.d.ts +56 -0
- package/esm/react-native.js +467 -0
- package/esm/react.d.ts +30 -0
- package/esm/react.js +343 -0
- package/esm/server.d.ts +12 -191
- package/esm/server.js +3 -369
- package/esm/styles.css +147 -0
- package/esm/svelte/CalmLensMedia.svelte +134 -0
- package/esm/svelte/CalmLensUploader.svelte +175 -0
- package/esm/svelte/Icon.svelte +7 -0
- package/esm/svelte/index.d.ts +37 -0
- package/esm/svelte/index.js +6 -0
- package/esm/test-inputs.d.ts +2 -2
- package/esm/test-inputs.js +1 -256
- package/esm/testing.d.ts +285 -190
- package/esm/testing.js +2329 -1122
- package/esm/ui-CCY3yXFn.js +458 -0
- package/esm/ui-wIiMfqMB.d.ts +526 -0
- package/esm/ui.d.ts +2 -0
- package/esm/ui.js +3 -0
- package/esm/vue.d.ts +490 -0
- package/esm/vue.js +392 -0
- package/package.json +118 -13
- package/cjs/CalmLensConnection-BNUHp_AQ.js +0 -234
- package/cjs/CalmLensUtils-o171ownK.js +0 -699
- package/cjs/CalmLensVersion-DwhgxQK5.js +0 -480
- package/cjs/CalmLensWebhooks-BKrq30Yp.js +0 -201
- package/cjs/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
- package/cjs/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
- package/cjs/WebhookPayload-CJoIkGBD.d.ts +0 -11753
- package/esm/CalmLensConnection-XTqmlwzG.js +0 -193
- package/esm/CalmLensUtils-DsF8Aa_x.js +0 -574
- package/esm/CalmLensVersion-DcifRKsx.js +0 -415
- package/esm/CalmLensWebhooks-BvGyk5d3.js +0 -148
- package/esm/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
- package/esm/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
- package/esm/WebhookPayload-CJoIkGBD.d.ts +0 -11753
package/README.md
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CalmLens JavaScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
3
|
+
The official JavaScript and TypeScript SDK for [CalmLens](https://calmlens.com): content moderation and classification for text, images, documents and websites, plus optional storage and CDN delivery.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
|
-
npm install
|
|
8
|
+
npm install calmlens
|
|
11
9
|
```
|
|
12
10
|
|
|
11
|
+
The same package is also published as `@calmlens/sdk`, at the same version. Import from the name you installed: `calmlens/server` with `calmlens`, or `@calmlens/sdk/server` with `@calmlens/sdk`.
|
|
12
|
+
|
|
13
13
|
The package has two clients, each with its own import:
|
|
14
14
|
|
|
15
|
-
| Import
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
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](#browser-and-app-uploads-with-upload-presets), with an optional upload token from your server |
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
`calmlens` itself exports the shared types, `CalmLensError`, the error codes and `SDK_VERSION`. The package ships ES modules and CommonJS with type declarations and loads no other packages at runtime: importing only `verifyWebhookSignature` adds about 5 KB (minified) to a bundle, `CalmLensAppClient` about 19 KB and `CalmLensServerClient` about 28 KB.
|
|
21
21
|
|
|
22
22
|
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](#assetscreatesignedurloptions).
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ Every public SDK operation accepts one options object. This keeps call sites sta
|
|
|
28
28
|
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
29
|
|
|
30
30
|
```typescript
|
|
31
|
-
import { CalmLensServerClient } from "
|
|
31
|
+
import { CalmLensServerClient } from "calmlens/server";
|
|
32
32
|
|
|
33
33
|
const client = new CalmLensServerClient({
|
|
34
34
|
apiKey: process.env.CALMLENS_API_KEY!,
|
|
@@ -36,12 +36,13 @@ const client = new CalmLensServerClient({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
// Text (comments, captions, bios): waits for the decision
|
|
39
|
-
const comment = await client.text.
|
|
39
|
+
const comment = await client.text.classify({ text: "Great photo!", externalId: "comment-42" });
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const { status } = comment.classification.moderation;
|
|
42
|
+
if (status === "allowed" || status === "limited") {
|
|
43
|
+
// publish it; keep "limited" out of recommendations, trending and notifications
|
|
43
44
|
} else {
|
|
44
|
-
// "
|
|
45
|
+
// "age-restricted": show it only behind your own age check; "blocked" or "purged": don't publish it
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Images and documents: queued, resolved by webhook or assets.retrieve()
|
|
@@ -50,27 +51,28 @@ const image = await client.assets.create({
|
|
|
50
51
|
content: { data: file, type: "image/jpeg" },
|
|
51
52
|
externalId: "post-7",
|
|
52
53
|
});
|
|
53
|
-
console.log(image.
|
|
54
|
+
console.log(image.classification.status, image.classification.moderation.status); // "processing" "pending"
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
| `
|
|
57
|
-
|
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
57
|
+
| `classification.status` | `classification.moderation.status` | Meaning |
|
|
58
|
+
| ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `completed` | `allowed` | Safe to show. |
|
|
60
|
+
| `completed` | `age-restricted` | Delivery requires `viewerClaims.ageConfirmed=true`, including signed URLs and API keys. Show it only to viewers your app has age-checked. |
|
|
61
|
+
| `completed` | `blocked` | Your policy blocked it; the CDN doesn't deliver it. Don't show it. |
|
|
62
|
+
| `completed` | `purged` | Your policy blocked it and deleted its content. Don't show it. |
|
|
63
|
+
| `pending` or `processing` | `pending` | Still being received or classified; `upload.status` is `uploading` while parts are arriving. |
|
|
64
|
+
| `failed` | `pending` | Processing failed; see `assets.retry`. |
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
`moderation.status
|
|
66
|
+
The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision in
|
|
67
|
+
`classification.moderation.status`.
|
|
66
68
|
|
|
67
|
-
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
|
|
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.
|
|
68
70
|
|
|
69
71
|
## API Reference
|
|
70
72
|
|
|
71
73
|
### CalmLensServerClient
|
|
72
74
|
|
|
73
|
-
The client for your server, with every API method. Import it from
|
|
75
|
+
The client for your server, with every API method. Import it from `calmlens/server`.
|
|
74
76
|
|
|
75
77
|
#### Constructor
|
|
76
78
|
|
|
@@ -111,7 +113,8 @@ async assets.create(options: SubmitAssetOptions): Promise<Asset>
|
|
|
111
113
|
- `part` (optional): First part of a sequential multipart upload
|
|
112
114
|
- `description` (string, optional): Description of the asset
|
|
113
115
|
- `processingOptions.keepContentAfterProcessing` (boolean, optional): Retain content after processing
|
|
114
|
-
- `processingOptions.
|
|
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).
|
|
115
118
|
- `visibility` (string, optional): "public" or "private"
|
|
116
119
|
|
|
117
120
|
**Example:**
|
|
@@ -124,43 +127,63 @@ const asset = await client.assets.create({
|
|
|
124
127
|
type: "application/pdf",
|
|
125
128
|
},
|
|
126
129
|
description: "Important document",
|
|
127
|
-
visibility: "private"
|
|
130
|
+
visibility: "private",
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
// Submit from URL
|
|
131
134
|
const asset = await client.assets.create({
|
|
132
135
|
name: "website-content",
|
|
133
136
|
url: "https://example.com/page",
|
|
134
|
-
description: "Web page to
|
|
137
|
+
description: "Web page to classify",
|
|
135
138
|
});
|
|
136
139
|
```
|
|
137
140
|
|
|
138
|
-
##### assets.
|
|
141
|
+
##### assets.classify(options) and text.classify(options)
|
|
139
142
|
|
|
140
|
-
Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.
|
|
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.
|
|
141
144
|
|
|
142
145
|
```typescript
|
|
143
|
-
async assets.
|
|
144
|
-
async text.
|
|
146
|
+
async assets.classify(options: SubmitAssetOptions): Promise<Asset>
|
|
147
|
+
async text.classify(options: ClassifyTextOptions): Promise<Asset>
|
|
145
148
|
```
|
|
146
149
|
|
|
147
150
|
Both resolve to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:
|
|
148
151
|
|
|
149
152
|
```typescript
|
|
150
|
-
const asset = await client.text.
|
|
151
|
-
asset.id;
|
|
152
|
-
asset.
|
|
153
|
-
asset.
|
|
154
|
-
asset.moderation.
|
|
155
|
-
asset.moderation
|
|
156
|
-
asset.moderation.
|
|
157
|
-
asset.content.status;
|
|
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"
|
|
158
161
|
asset.content.removalReason; // "policy" | "retention" | "requested" | "parent-removed" | "disposed" | null
|
|
159
|
-
asset.type;
|
|
160
|
-
asset.createdAt;
|
|
162
|
+
asset.type; // "plaintext-document" for text
|
|
163
|
+
asset.createdAt; // milliseconds since the Unix epoch
|
|
161
164
|
```
|
|
162
165
|
|
|
163
|
-
Always present: `id`, `projectId`, `name`, `type`, `createdAt`, `updatedAt`, `
|
|
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).
|
|
164
187
|
|
|
165
188
|
##### assets.retrieve(options)
|
|
166
189
|
|
|
@@ -172,14 +195,14 @@ async assets.retrieve(options: { assetId: string }): Promise<Asset>
|
|
|
172
195
|
|
|
173
196
|
**Example:**
|
|
174
197
|
```typescript
|
|
175
|
-
const asset = await client.assets.retrieve({ assetId: "
|
|
176
|
-
console.log(asset.moderation.status);
|
|
177
|
-
console.log(asset.moderation
|
|
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
|
|
178
201
|
```
|
|
179
202
|
|
|
180
203
|
##### ensureLens(options)
|
|
181
204
|
|
|
182
|
-
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`.
|
|
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.
|
|
183
206
|
|
|
184
207
|
```typescript
|
|
185
208
|
const lens = await client.ensureLens({
|
|
@@ -208,7 +231,7 @@ For several lenses, use `ensureLenses()` to ensure them together: new definition
|
|
|
208
231
|
const [normal, uhd] = await client.ensureLenses({ lenses: [normalLens, uhdLens] });
|
|
209
232
|
```
|
|
210
233
|
|
|
211
|
-
The CDN uses named lenses with an `@` prefix in URLs, such as `/
|
|
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.
|
|
212
235
|
|
|
213
236
|
##### assets.list(options)
|
|
214
237
|
|
|
@@ -218,7 +241,7 @@ List assets in your project with pagination.
|
|
|
218
241
|
async assets.list(options: {
|
|
219
242
|
cursor?: string;
|
|
220
243
|
pageSize?: number;
|
|
221
|
-
ordering?: Array<{ field: "createdAt" | "id"; direction: "
|
|
244
|
+
ordering?: Array<{ field: "createdAt" | "id"; direction: "asc" | "desc" }>;
|
|
222
245
|
filter?: string | null;
|
|
223
246
|
}): Promise<{
|
|
224
247
|
items: Asset[];
|
|
@@ -231,19 +254,22 @@ async assets.list(options: {
|
|
|
231
254
|
**Example:**
|
|
232
255
|
```typescript
|
|
233
256
|
const result = await client.assets.list({
|
|
234
|
-
pageSize: 20
|
|
257
|
+
pageSize: 20,
|
|
235
258
|
});
|
|
236
259
|
|
|
237
260
|
console.log(`Received ${result.items.length} assets`);
|
|
238
|
-
result.items.forEach(asset => {
|
|
239
|
-
console.log(`${asset.name}: ${asset.moderation.status}`);
|
|
261
|
+
result.items.forEach((asset) => {
|
|
262
|
+
console.log(`${asset.name}: ${asset.classification.moderation.status}`);
|
|
240
263
|
});
|
|
241
264
|
```
|
|
242
265
|
|
|
243
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.
|
|
244
267
|
|
|
245
268
|
```typescript
|
|
246
|
-
for await (const asset of client.assets.iterate({
|
|
269
|
+
for await (const asset of client.assets.iterate({
|
|
270
|
+
filter: "classification.moderation.status==blocked",
|
|
271
|
+
pageSize: 100,
|
|
272
|
+
})) {
|
|
247
273
|
console.log(asset.id);
|
|
248
274
|
}
|
|
249
275
|
```
|
|
@@ -265,12 +291,12 @@ for await (const child of client.assets.iterateChildren({ assetId: parentAssetId
|
|
|
265
291
|
|
|
266
292
|
##### assets.createSignedUrl(options)
|
|
267
293
|
|
|
268
|
-
Create a short-lived URL for private
|
|
294
|
+
Create a short-lived URL for a private asset's `rendered` (default) or `original` resource.
|
|
269
295
|
|
|
270
296
|
```typescript
|
|
271
297
|
const signed = await client.assets.createSignedUrl({
|
|
272
298
|
assetId,
|
|
273
|
-
resource: "
|
|
299
|
+
resource: "rendered",
|
|
274
300
|
lens: "thumbnail",
|
|
275
301
|
expiresInSeconds: 900,
|
|
276
302
|
});
|
|
@@ -283,47 +309,108 @@ Move an asset into a folder, or to the project root by passing `null`.
|
|
|
283
309
|
```typescript
|
|
284
310
|
// Move asset to a folder
|
|
285
311
|
await client.assets.move({
|
|
286
|
-
assetId: "
|
|
287
|
-
folderId: "
|
|
312
|
+
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
313
|
+
folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
288
314
|
});
|
|
289
315
|
|
|
290
316
|
// Move asset to project root
|
|
291
317
|
await client.assets.move({
|
|
292
|
-
assetId: "
|
|
318
|
+
assetId: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
293
319
|
folderId: null,
|
|
294
320
|
});
|
|
295
321
|
```
|
|
296
322
|
|
|
297
323
|
##### assets.retry(options)
|
|
298
324
|
|
|
299
|
-
Process an asset whose processing failed (`
|
|
325
|
+
Process an asset whose processing failed (`classification.status` `failed`) again. It returns to
|
|
300
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:
|
|
301
327
|
otherwise this throws `asset/content-unavailable`, and you need to upload it again.
|
|
302
328
|
|
|
303
329
|
```typescript
|
|
304
|
-
const asset = await client.assets.retry({ assetId: "
|
|
305
|
-
// asset.
|
|
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
|
+
});
|
|
306
379
|
```
|
|
307
380
|
|
|
308
381
|
##### assets.waitForDecision(options)
|
|
309
382
|
|
|
310
|
-
Wait for an asset's moderation decision and get the decided asset: it returns once `moderation.status`
|
|
311
|
-
is
|
|
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
|
|
312
385
|
`timeoutMs` (default 60000). Pass `signal` to stop waiting early (`sdk/cancelled`). Use it after
|
|
313
386
|
`assets.create`, or with an asset ID you stored earlier. Webhooks report decisions without polling.
|
|
314
387
|
|
|
315
388
|
```typescript
|
|
316
|
-
const queued = await client.assets.create({
|
|
389
|
+
const queued = await client.assets.create({
|
|
390
|
+
name: "photo.jpg",
|
|
391
|
+
content: { blob, type: "image/jpeg" },
|
|
392
|
+
});
|
|
317
393
|
const decided = await client.assets.waitForDecision({ assetId: queued.id, timeoutMs: 30_000 });
|
|
318
|
-
|
|
394
|
+
const { status } = decided.classification.moderation;
|
|
395
|
+
if (status !== "allowed" && status !== "limited") hidePhoto(decided.id);
|
|
319
396
|
```
|
|
320
397
|
|
|
321
|
-
##### folders.list()
|
|
398
|
+
##### folders.list(options)
|
|
322
399
|
|
|
323
|
-
|
|
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).
|
|
324
405
|
|
|
325
406
|
```typescript
|
|
326
|
-
const
|
|
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
|
+
}
|
|
327
414
|
```
|
|
328
415
|
|
|
329
416
|
##### folders.retrieve(options)
|
|
@@ -331,7 +418,7 @@ const folders = await client.folders.list();
|
|
|
331
418
|
Retrieve details of a specific folder by ID.
|
|
332
419
|
|
|
333
420
|
```typescript
|
|
334
|
-
const folder = await client.folders.retrieve({ folderId: "
|
|
421
|
+
const folder = await client.folders.retrieve({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
|
|
335
422
|
```
|
|
336
423
|
|
|
337
424
|
##### folders.create(options)
|
|
@@ -355,7 +442,7 @@ Rename an existing folder.
|
|
|
355
442
|
|
|
356
443
|
```typescript
|
|
357
444
|
const updated = await client.folders.update({
|
|
358
|
-
folderId: "
|
|
445
|
+
folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
359
446
|
name: "New Name",
|
|
360
447
|
});
|
|
361
448
|
```
|
|
@@ -383,7 +470,7 @@ await client.folders.move({
|
|
|
383
470
|
Delete an empty folder. If the folder still contains assets or child folders, this throws `folder/not-empty`.
|
|
384
471
|
|
|
385
472
|
```typescript
|
|
386
|
-
await client.folders.delete({ folderId: "
|
|
473
|
+
await client.folders.delete({ folderId: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P" });
|
|
387
474
|
```
|
|
388
475
|
|
|
389
476
|
##### cdn.getAssetUrl(options) and cdn.serve(options)
|
|
@@ -393,25 +480,54 @@ await client.folders.delete({ folderId: "folder-uuid" });
|
|
|
393
480
|
```typescript
|
|
394
481
|
const url = client.cdn.getAssetUrl({
|
|
395
482
|
assetIdentifier: assetId,
|
|
396
|
-
resource: "
|
|
483
|
+
resource: "rendered",
|
|
397
484
|
lens: "thumbnail",
|
|
398
485
|
});
|
|
399
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
|
+
|
|
400
493
|
const response = await client.cdn.serve({
|
|
401
494
|
assetIdentifier: assetId,
|
|
402
|
-
resource: "
|
|
495
|
+
resource: "original",
|
|
403
496
|
});
|
|
404
497
|
```
|
|
405
498
|
|
|
406
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"`.
|
|
407
500
|
|
|
408
|
-
|
|
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`).
|
|
409
525
|
|
|
410
526
|
##### Multipart upload flow
|
|
411
527
|
|
|
412
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.
|
|
413
529
|
|
|
414
|
-
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 `
|
|
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.
|
|
415
531
|
|
|
416
532
|
Image upload sizes use distinct direct and multipart paths:
|
|
417
533
|
|
|
@@ -431,8 +547,8 @@ const uploading = await client.assets.create({
|
|
|
431
547
|
name: "large-image.png",
|
|
432
548
|
part: {
|
|
433
549
|
uint8Array: firstPartBytes,
|
|
434
|
-
type: "image/png"
|
|
435
|
-
}
|
|
550
|
+
type: "image/png",
|
|
551
|
+
},
|
|
436
552
|
});
|
|
437
553
|
|
|
438
554
|
await client.assets.uploadPart({
|
|
@@ -455,15 +571,15 @@ const completed = await client.assets.uploadPart({
|
|
|
455
571
|
|
|
456
572
|
**Example (single-call synchronous analysis):**
|
|
457
573
|
```typescript
|
|
458
|
-
const analyzed = await client.assets.
|
|
574
|
+
const analyzed = await client.assets.classify({
|
|
459
575
|
name: "large-image.png",
|
|
460
576
|
part: {
|
|
461
577
|
base64: finalPartBase64,
|
|
462
578
|
type: "image/png",
|
|
463
|
-
isFinal: true
|
|
464
|
-
}
|
|
579
|
+
isFinal: true,
|
|
580
|
+
},
|
|
465
581
|
});
|
|
466
|
-
console.log(analyzed.moderation.status);
|
|
582
|
+
console.log(analyzed.classification.moderation.status);
|
|
467
583
|
```
|
|
468
584
|
|
|
469
585
|
#### Static Methods
|
|
@@ -491,22 +607,28 @@ It returns `true` only when the `x-calmlens-signature` header contains a `v1=` H
|
|
|
491
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.
|
|
492
608
|
|
|
493
609
|
```ts
|
|
494
|
-
import { CalmLensAppClient } from "
|
|
610
|
+
import { CalmLensAppClient } from "calmlens/app";
|
|
495
611
|
|
|
496
612
|
// In the browser: no API key
|
|
497
613
|
const client = new CalmLensAppClient({
|
|
498
|
-
uploadPresetId: "
|
|
614
|
+
uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
|
|
499
615
|
// Only for presets that require a token: called once per new upload
|
|
500
|
-
token: () =>
|
|
616
|
+
token: () =>
|
|
617
|
+
fetch("/api/upload-token", { method: "POST" })
|
|
618
|
+
.then((r) => r.json())
|
|
619
|
+
.then((b) => b.token),
|
|
501
620
|
});
|
|
502
621
|
|
|
503
|
-
const asset = await client.assets.
|
|
622
|
+
const asset = await client.assets.classify({
|
|
623
|
+
name: file.name,
|
|
624
|
+
content: { data: file, type: file.type },
|
|
625
|
+
});
|
|
504
626
|
// asset.status (the moderation status) is present when the preset shows decisions to uploaders
|
|
505
627
|
```
|
|
506
628
|
|
|
507
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`.
|
|
508
630
|
|
|
509
|
-
The client has `assets.create`, `assets.
|
|
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.
|
|
510
632
|
|
|
511
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).
|
|
512
634
|
|
|
@@ -514,13 +636,72 @@ On your server, sign a single-use token for each upload with `CalmLensServerClie
|
|
|
514
636
|
|
|
515
637
|
```ts
|
|
516
638
|
const { token, expiresAt } = await client.uploadTokens.sign({
|
|
517
|
-
uploadPresetId: "
|
|
639
|
+
uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
|
|
518
640
|
externalId: "post-42", // optional
|
|
519
641
|
expiresInSeconds: 600, // optional, up to 3600
|
|
520
642
|
});
|
|
521
643
|
```
|
|
522
644
|
|
|
523
|
-
Server uploads can also pass `uploadPresetId` to `assets.create` or `assets.
|
|
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 |
|
|
658
|
+
|
|
659
|
+
`react`, `vue` and `svelte` are optional peer dependencies: install the one you use.
|
|
660
|
+
|
|
661
|
+
**The uploader** is a drop zone with a file list. It uploads each file through `CalmLensAppClient`, shows byte progress, checks the upload until it has a moderation decision (for about 15 seconds; anything still pending is your webhook's to settle), and shows the decision or a message for each error code.
|
|
662
|
+
|
|
663
|
+
```tsx
|
|
664
|
+
import { CalmLensUploader } from "calmlens/react";
|
|
665
|
+
|
|
666
|
+
<CalmLensUploader
|
|
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.
|
|
524
705
|
|
|
525
706
|
## Asset Types
|
|
526
707
|
|
|
@@ -535,26 +716,27 @@ Assets can be of the following types:
|
|
|
535
716
|
|
|
536
717
|
## Asset Status
|
|
537
718
|
|
|
538
|
-
|
|
719
|
+
Upload, classification and content each have a status; moderation has its own decision:
|
|
539
720
|
|
|
540
|
-
- `
|
|
541
|
-
- `
|
|
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.
|
|
542
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`
|
|
543
725
|
|
|
544
726
|
## Moderation Reports
|
|
545
727
|
|
|
546
|
-
Completed reports always contain the
|
|
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.
|
|
547
729
|
|
|
548
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.
|
|
549
731
|
|
|
550
732
|
```typescript
|
|
551
|
-
const violence = asset.
|
|
552
|
-
if (violence?.status ===
|
|
733
|
+
const violence = asset.classification.moderation.raw?.categories.violence;
|
|
734
|
+
if (violence?.status === "evaluated" || violence?.status === "partial") {
|
|
553
735
|
console.log(violence.score);
|
|
554
736
|
}
|
|
555
737
|
```
|
|
556
738
|
|
|
557
|
-
See the [
|
|
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.
|
|
558
740
|
|
|
559
741
|
## Asset Visibility
|
|
560
742
|
|
|
@@ -566,7 +748,7 @@ See the [moderation report guide](https://calmlens.com/docs/guides/moderation-re
|
|
|
566
748
|
Every failure is a `CalmLensError`, whether the API returned an error or the request never completed. Branch on its stable `code`:
|
|
567
749
|
|
|
568
750
|
```typescript
|
|
569
|
-
import { CALMLENS_ERROR_CODES, CalmLensError } from "
|
|
751
|
+
import { CALMLENS_ERROR_CODES, CalmLensError } from "calmlens";
|
|
570
752
|
|
|
571
753
|
try {
|
|
572
754
|
await client.assets.retrieve({ assetId });
|
|
@@ -578,42 +760,42 @@ try {
|
|
|
578
760
|
}
|
|
579
761
|
```
|
|
580
762
|
|
|
581
|
-
| Property
|
|
582
|
-
|
|
|
583
|
-
| `code`
|
|
584
|
-
| `status`
|
|
585
|
-
| `requestId`
|
|
586
|
-
| `headers`
|
|
587
|
-
| `retryAfter` | Seconds to wait, from the `Retry-After` header (on `429` and `idempotency/request-in-progress`)
|
|
588
|
-
| `details`
|
|
589
|
-
| `cause`
|
|
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 |
|
|
590
772
|
|
|
591
|
-
`error.isNotFound()` is true for `404`, and `error.isRetryable()` for transport failures, `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.
|
|
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.
|
|
592
774
|
|
|
593
775
|
Codes the SDK raises itself:
|
|
594
776
|
|
|
595
|
-
| Code
|
|
596
|
-
|
|
|
597
|
-
| `sdk/invalid-argument`
|
|
598
|
-
| `sdk/invalid-config`
|
|
599
|
-
| `sdk/transport-error`
|
|
600
|
-
| `sdk/timeout`
|
|
601
|
-
| `sdk/cancelled`
|
|
602
|
-
| `sdk/invalid-response`
|
|
603
|
-
| `sdk/http-error`
|
|
604
|
-
| `sdk/response-too-large`
|
|
605
|
-
| `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent`
|
|
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` |
|
|
606
788
|
|
|
607
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`.
|
|
608
790
|
|
|
609
791
|
## Retries and idempotency
|
|
610
792
|
|
|
611
|
-
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, `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.
|
|
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.
|
|
612
794
|
|
|
613
|
-
`assets.create`, `assets.
|
|
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:
|
|
614
796
|
|
|
615
797
|
```typescript
|
|
616
|
-
await client.text.
|
|
798
|
+
await client.text.classify({
|
|
617
799
|
text: comment.body,
|
|
618
800
|
externalId: `comment-${comment.id}`,
|
|
619
801
|
idempotencyKey: `comment-${comment.id}`,
|
|
@@ -624,50 +806,90 @@ The API keeps a response for 24 hours. Reusing a key for a different request fai
|
|
|
624
806
|
|
|
625
807
|
## Forward compatibility
|
|
626
808
|
|
|
627
|
-
The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `
|
|
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.
|
|
628
810
|
|
|
629
811
|
## Testing
|
|
630
812
|
|
|
631
|
-
|
|
813
|
+
`calmlens/testing` exports `MockCalmLensServerClient` and `MockCalmLensAppClient`, which run the real clients against an in-memory CalmLens API and CDN, in-memory storage implementations, mock API key helpers, webhook delivery builders and `generateMediaTestCode`, for testing without network requests. `generateMediaTestCode(signal, testInputKey)` asynchronously returns the spoken or on-screen test phrase authenticated with your project's secret test input key, shown under **Assets → Try it → Test inputs** in the portal; a phrase with another project's key is moderated normally.
|
|
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.
|
|
632
831
|
|
|
633
|
-
|
|
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`:
|
|
634
833
|
|
|
635
834
|
```typescript
|
|
636
|
-
import { MockCalmLensServerClient, createMockApiKey } from "
|
|
835
|
+
import { MockCalmLensServerClient, createMockApiKey, mockDecision } from "calmlens/testing";
|
|
637
836
|
|
|
638
837
|
const client = new MockCalmLensServerClient({
|
|
639
838
|
apiKey: createMockApiKey(),
|
|
640
|
-
projectId
|
|
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
|
+
},
|
|
641
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 });
|
|
642
850
|
```
|
|
643
851
|
|
|
644
|
-
`
|
|
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:
|
|
645
853
|
|
|
646
|
-
|
|
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
|
+
```
|
|
647
861
|
|
|
648
|
-
Every
|
|
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.
|
|
649
863
|
|
|
650
864
|
```typescript
|
|
651
|
-
const client = new MockCalmLensServerClient({
|
|
865
|
+
const client = new MockCalmLensServerClient({
|
|
866
|
+
apiKey: createMockApiKey(),
|
|
867
|
+
projectId,
|
|
868
|
+
timeoutMs: 5_000,
|
|
869
|
+
});
|
|
652
870
|
|
|
653
|
-
client.failNextRequest("timeout", { operation: "text.
|
|
871
|
+
client.failNextRequest("timeout", { operation: "text.classify" });
|
|
654
872
|
await expect(postComment(client, "hello")).rejects.toMatchObject({ code: "sdk/timeout" });
|
|
655
873
|
|
|
656
874
|
await postComment(client, "hello");
|
|
657
|
-
expect(client.callsTo("text.
|
|
875
|
+
expect(client.callsTo("text.classify").at(-1)).toMatchObject({ body: "hello", status: 200 });
|
|
658
876
|
```
|
|
659
877
|
|
|
660
878
|
To test a webhook handler, build a signed delivery the way CalmLens sends it:
|
|
661
879
|
|
|
662
880
|
```typescript
|
|
663
|
-
import { createWebhookHeaders, toWebhookEventPayload } from "
|
|
881
|
+
import { createWebhookHeaders, toWebhookEventPayload } from "calmlens/testing";
|
|
664
882
|
|
|
665
|
-
const event = client.enqueueEvent({ type: "asset.
|
|
883
|
+
const event = await client.enqueueEvent({ type: "asset.allowed", assetId: asset.id });
|
|
666
884
|
const body = JSON.stringify(toWebhookEventPayload(event));
|
|
667
885
|
const headers = await createWebhookHeaders({ secret, body, deliveryId: "delivery-1" });
|
|
668
886
|
const response = await app.request("/api/webhooks/calmlens", { method: "POST", body, headers });
|
|
669
887
|
```
|
|
670
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
|
+
|
|
671
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).
|
|
672
894
|
|
|
673
895
|
## Webhooks
|
|
@@ -677,36 +899,36 @@ A `webhook` connector POSTs each event to your URL as JSON:
|
|
|
677
899
|
```json
|
|
678
900
|
{
|
|
679
901
|
"id": "…",
|
|
680
|
-
"type": "asset.flagged",
|
|
681
|
-
"createdAt": 1760000000000,
|
|
682
902
|
"projectId": "…",
|
|
683
|
-
"
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
691
911
|
}
|
|
692
912
|
```
|
|
693
913
|
|
|
914
|
+
The body is the event exactly as [`events.list`](#events-feed) returns it.
|
|
915
|
+
|
|
694
916
|
Each delivery has these headers (the names are exported as constants):
|
|
695
917
|
|
|
696
|
-
| Header
|
|
697
|
-
|
|
698
|
-
| `x-calmlens-delivery`
|
|
699
|
-
| `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed
|
|
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 |
|
|
700
922
|
| `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
|
|
701
923
|
|
|
702
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`.
|
|
703
925
|
|
|
704
|
-
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. 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`.
|
|
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`.
|
|
705
927
|
|
|
706
928
|
**Fetch API** (Next.js route handlers, Remix, Cloudflare Workers, Bun, Deno):
|
|
707
929
|
|
|
708
930
|
```typescript
|
|
709
|
-
import { CalmLensError, parseWebhookEvent } from "
|
|
931
|
+
import { CalmLensError, parseWebhookEvent } from "calmlens/server";
|
|
710
932
|
|
|
711
933
|
export async function POST(request: Request) {
|
|
712
934
|
let event;
|
|
@@ -717,7 +939,8 @@ export async function POST(request: Request) {
|
|
|
717
939
|
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
|
|
718
940
|
});
|
|
719
941
|
} catch (error) {
|
|
720
|
-
if (CalmLensError.isCalmLensError(error))
|
|
942
|
+
if (CalmLensError.isCalmLensError(error))
|
|
943
|
+
return new Response("Invalid delivery", { status: 401 });
|
|
721
944
|
throw error;
|
|
722
945
|
}
|
|
723
946
|
// Queue the work, keyed by event.id, then respond quickly.
|
|
@@ -749,52 +972,59 @@ app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), as
|
|
|
749
972
|
|
|
750
973
|
## Command-line tool
|
|
751
974
|
|
|
752
|
-
|
|
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:
|
|
753
976
|
|
|
754
977
|
```bash
|
|
755
978
|
npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens --env-file .env.local
|
|
756
979
|
```
|
|
757
980
|
|
|
758
|
-
|
|
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.
|
|
759
989
|
|
|
760
990
|
The same feed is available in code with [`events.list`](#events-feed).
|
|
761
991
|
|
|
762
992
|
## Events feed
|
|
763
993
|
|
|
764
|
-
`client.events.list({
|
|
994
|
+
`client.events.list({ cursor?, pageSize? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
|
|
765
995
|
|
|
766
996
|
```typescript
|
|
767
997
|
{
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
hasMore: boolean;
|
|
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`
|
|
771
1001
|
}
|
|
772
1002
|
```
|
|
773
1003
|
|
|
774
|
-
- Without `
|
|
775
|
-
- With `
|
|
776
|
-
- Keep passing the latest `
|
|
777
|
-
- An unknown or malformed cursor fails with the `CalmLensError` code `
|
|
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`.
|
|
778
1008
|
|
|
779
|
-
`client.events.pages({
|
|
1009
|
+
`client.events.pages({ cursor, pageSize })` follows the cursor for you: it yields each page until no more events are waiting.
|
|
780
1010
|
|
|
781
1011
|
```typescript
|
|
782
|
-
let { cursor } = await client.events.list(); // no events, cursor
|
|
1012
|
+
let { nextCursor: cursor } = await client.events.list(); // no events, a cursor for "now"
|
|
783
1013
|
|
|
784
1014
|
async function drainEvents() {
|
|
785
|
-
for await (const page of client.events.pages({
|
|
786
|
-
for (const event of page.
|
|
1015
|
+
for await (const page of client.events.pages({ cursor, pageSize: 100 })) {
|
|
1016
|
+
for (const event of page.items) {
|
|
787
1017
|
// narrow on event.type to read its typed facts
|
|
788
1018
|
console.log(event.type, event.facts);
|
|
789
1019
|
}
|
|
790
|
-
cursor = page.
|
|
1020
|
+
cursor = page.nextCursor; // save it to resume after a restart
|
|
791
1021
|
}
|
|
792
1022
|
}
|
|
793
1023
|
|
|
794
1024
|
setInterval(drainEvents, 5_000);
|
|
795
1025
|
```
|
|
796
1026
|
|
|
797
|
-
Events carry `facts: { key, value, url? }[]`;
|
|
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.
|
|
798
1028
|
|
|
799
1029
|
## Request budgets
|
|
800
1030
|
|
|
@@ -802,33 +1032,47 @@ Set `timeoutMs` (default 120000), `uploadTimeoutMs`, `maxRetries` or `signal` on
|
|
|
802
1032
|
|
|
803
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.
|
|
804
1034
|
|
|
805
|
-
`text.
|
|
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.
|
|
806
1036
|
|
|
807
1037
|
## Releasing
|
|
808
1038
|
|
|
809
|
-
|
|
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.
|
|
810
1042
|
|
|
811
1043
|
### Test-input images
|
|
812
1044
|
|
|
813
|
-
The optional
|
|
1045
|
+
The optional `calmlens/test-inputs` entry generates and recognises the same
|
|
814
1046
|
project-specific colour grids used by the CalmLens portal:
|
|
815
1047
|
|
|
816
1048
|
```ts
|
|
817
|
-
import {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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.
|
|
824
1061
|
```
|
|
825
1062
|
|
|
826
1063
|
Encode the returned RGBA bytes with your image library to download a PNG. To
|
|
827
1064
|
recognise a downloaded image, decode it into RGBA first. These helpers have no
|
|
828
|
-
DOM, image-codec, or Cloudflare dependencies.
|
|
829
|
-
lossy compression; cropping, rotation, and severe colour changes are unsupported.
|
|
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.
|
|
830
1066
|
The entry also exports `generateTestTextCode` and `detectTestTrigger` for text
|
|
831
1067
|
test inputs. Use your project's test input key; another project's key does not
|
|
832
1068
|
activate its test signals.
|
|
833
1069
|
|
|
834
|
-
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 `moderation
|
|
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.
|
|
1075
|
+
|
|
1076
|
+
Age-restricted content returns `403` with `viewer/age-confirmation-required` unless age confirmation is true, including API-key and signed-URL requests. Private assets separately require access permission. Blocks and safety holds cannot be overridden by claims. Applications that never use age-restricted content should leave confirmation false, including processing jobs.
|
|
1077
|
+
|
|
1078
|
+
Raw HTTP clients can send a JSON `viewerClaims` query parameter or the JSON `X-CalmLens-Viewer-Claims` header; the query takes precedence. Signed URLs include claims when created and must be forwarded unchanged. Asset retrieval returns stored moderation results without a request-specific `delivery` object. Proxy caches must not bypass current viewer and moderation checks; honour `private, no-store` responses.
|