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