@calmlens/sdk 0.16.2 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +399 -162
- package/cjs/BrowserUpload-DZ0Vbmxi.js +10 -0
- package/cjs/BrowserUpload-uZjVFGfP.d.ts +11 -0
- package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/cjs/CalmLensAppClient-DBGmaDlG.js +89 -0
- package/cjs/CalmLensConnection-K1TAD1Z-.js +659 -0
- package/cjs/CalmLensServerClient-C-pxiz0C.js +645 -0
- package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
- package/cjs/FileIcons-Ob003MdS.js +175 -0
- package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
- package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/cjs/ModerationContextLimits-Dj1YAl3G.js +1311 -0
- package/cjs/ReactUpload-DxtFMr06.js +54 -0
- package/cjs/ReactUpload-nZahpmyg.d.ts +17 -0
- package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/cjs/TestTriggers-DcArt0Nu-CvoaONUB.js +1326 -0
- package/cjs/UploadController-BBkN4Ni6.js +618 -0
- package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/cjs/app.d.ts +7 -44
- package/cjs/app.js +11 -89
- package/cjs/elements.d.ts +88 -0
- package/cjs/elements.js +555 -0
- package/cjs/file-icons.d.ts +67 -0
- package/cjs/file-icons.js +10 -0
- package/cjs/index-CI4BzN4W.d.ts +252 -0
- package/cjs/index.d.ts +6 -4
- package/cjs/index.js +9 -4
- package/cjs/react-native.d.ts +56 -0
- package/cjs/react-native.js +495 -0
- package/cjs/react.d.ts +30 -0
- package/cjs/react.js +370 -0
- package/cjs/server.d.ts +12 -191
- package/cjs/server.js +18 -378
- package/cjs/test-inputs.d.ts +2 -2
- package/cjs/test-inputs.js +9 -264
- package/cjs/testing.d.ts +285 -190
- package/cjs/testing.js +2393 -1203
- package/cjs/ui-CgZpgpNo.d.ts +526 -0
- package/cjs/ui-KceuoVCM.js +583 -0
- package/cjs/ui.d.ts +2 -0
- package/cjs/ui.js +27 -0
- package/cjs/vue.d.ts +490 -0
- package/cjs/vue.js +419 -0
- package/esm/BrowserUpload-D7-zNmc0.js +5 -0
- package/esm/BrowserUpload-uZjVFGfP.d.ts +11 -0
- package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
- package/esm/CalmLensAppClient-FVMwrHlH.js +84 -0
- package/esm/CalmLensConnection-iyNQHA8L.js +582 -0
- package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
- package/esm/CalmLensServerClient-fQEYKLw4.js +586 -0
- package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
- package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
- package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
- package/esm/FileIcons-M6dzjFxd.js +128 -0
- package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
- package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
- package/esm/ModerationContextLimits-D1x_WAtP.js +1114 -0
- package/esm/ReactUpload-D6yBCkcw.js +37 -0
- package/esm/ReactUpload-nZahpmyg.d.ts +17 -0
- package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
- package/esm/TestTriggers-DcArt0Nu-s0AQZuWE.js +1266 -0
- package/esm/UploadController-txp5NuzH.js +589 -0
- package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
- package/esm/app.d.ts +7 -44
- package/esm/app.js +3 -86
- package/esm/elements.d.ts +88 -0
- package/esm/elements.js +528 -0
- package/esm/file-icons.d.ts +67 -0
- package/esm/file-icons.js +1 -0
- package/esm/index-CI4BzN4W.d.ts +252 -0
- package/esm/index.d.ts +6 -4
- package/esm/index.js +1 -1
- package/esm/react-native.d.ts +56 -0
- package/esm/react-native.js +467 -0
- package/esm/react.d.ts +30 -0
- package/esm/react.js +343 -0
- package/esm/server.d.ts +12 -191
- package/esm/server.js +3 -369
- package/esm/styles.css +147 -0
- package/esm/svelte/CalmLensMedia.svelte +134 -0
- package/esm/svelte/CalmLensUploader.svelte +175 -0
- package/esm/svelte/Icon.svelte +7 -0
- package/esm/svelte/index.d.ts +37 -0
- package/esm/svelte/index.js +6 -0
- package/esm/test-inputs.d.ts +2 -2
- package/esm/test-inputs.js +1 -256
- package/esm/testing.d.ts +285 -190
- package/esm/testing.js +2329 -1122
- package/esm/ui-Ca_SpJXf.js +458 -0
- package/esm/ui-CgZpgpNo.d.ts +526 -0
- package/esm/ui.d.ts +2 -0
- package/esm/ui.js +3 -0
- package/esm/vue.d.ts +490 -0
- package/esm/vue.js +392 -0
- package/package.json +110 -11
- package/cjs/CalmLensConnection-5Hj3trGr.js +0 -234
- package/cjs/CalmLensUtils-BeIl1N6N.js +0 -699
- package/cjs/CalmLensVersion-BGpB9t4-.js +0 -480
- package/cjs/CalmLensWebhooks-CogUYk8-.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-BAJdoZHy.js +0 -193
- package/esm/CalmLensUtils-DqniTKJ8.js +0 -574
- package/esm/CalmLensVersion-l_khh4nS.js +0 -415
- package/esm/CalmLensWebhooks-BtrKKLvg.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/esm/testing.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { A as invalidArgument, C as MAX_IMAGE_UPLOAD_BYTES, E as isRejectedUploadMimeType, M as API_ERROR_CODES, N as API_ERROR_DEFINITIONS, O as CalmLensError, T as getRejectedUploadMimeTypeError, _ as validateAppClientOptions, b as validateServerClientOptions, f as randomUuid, j as invalidConfig, k as SDK_ERROR_CODES, r as selectModerationContextItems, w as MIN_MULTIPART_IMAGE_UPLOAD_BYTES } from "./ModerationContextLimits-D1x_WAtP.js";
|
|
2
|
+
import { a as WEBHOOK_TIMESTAMP_HEADER, l as signUploadTokenWithApiKey, n as WEBHOOK_DELIVERY_HEADER, r as WEBHOOK_SIGNATURE_HEADER, t as CalmLensServerClient, u as stringifyJsonCanonical } from "./CalmLensServerClient-fQEYKLw4.js";
|
|
3
|
+
import { d as assetDataToUint8Array, f as assetToData, n as MOCK_HOOKS, p as validateAssetPayload, u as sleep } from "./CalmLensConnection-iyNQHA8L.js";
|
|
4
|
+
import { t as hmacSha256Hex } from "./CryptoUtils-B_x8lMIx-DhuWGzqn.js";
|
|
5
|
+
import { t as CalmLensAppClient } from "./CalmLensAppClient-FVMwrHlH.js";
|
|
6
|
+
import { a as placeholderHashToDataURL, r as isFileAccepted } from "./UploadController-txp5NuzH.js";
|
|
7
|
+
import { s as getTestInputTag, u as CLASSIFICATION_CATEGORIES } from "./TestTriggers-DcArt0Nu-s0AQZuWE.js";
|
|
4
8
|
import * as zod from "zod";
|
|
5
|
-
|
|
9
|
+
// src/contract/MediaTestTriggers.js
|
|
10
|
+
const MEDIA_TEST_SIGNALS = [...CLASSIFICATION_CATEGORIES, "violence.graphic"];
|
|
11
|
+
const HEX_WORDS = [
|
|
12
|
+
"zero",
|
|
13
|
+
"one",
|
|
14
|
+
"two",
|
|
15
|
+
"three",
|
|
16
|
+
"four",
|
|
17
|
+
"five",
|
|
18
|
+
"six",
|
|
19
|
+
"seven",
|
|
20
|
+
"eight",
|
|
21
|
+
"nine",
|
|
22
|
+
"alpha",
|
|
23
|
+
"bravo",
|
|
24
|
+
"charlie",
|
|
25
|
+
"delta",
|
|
26
|
+
"echo",
|
|
27
|
+
"foxtrot"
|
|
28
|
+
];
|
|
29
|
+
/** Read this aloud, or render it as large, high-contrast text in a video frame. */
|
|
30
|
+
async function generateMediaTestCode(signal, testInputKey) {
|
|
31
|
+
if (!MEDIA_TEST_SIGNALS.includes(signal)) throw new RangeError("Unsupported media test signal");
|
|
32
|
+
return `Calm Lens test ${[...await getTestInputTag("media", signal, testInputKey)].map((character) => HEX_WORDS[Number.parseInt(character, 16)]).join(" ")} ${signal.replace(/[.-]/g, " ")} end test`;
|
|
33
|
+
}
|
|
34
|
+
// src/contract/Ids-aHyP9ZcF.js
|
|
6
35
|
var __defProp = Object.defineProperty;
|
|
7
|
-
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
36
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod, mod.exports), cb = null), mod.exports);
|
|
8
37
|
var __exportAll = (all, no_symbols) => {
|
|
9
38
|
let target = {};
|
|
10
39
|
for (var name in all) __defProp(target, name, {
|
|
@@ -346,6 +375,17 @@ function prefixIssues(path, issues) {
|
|
|
346
375
|
function unwrapMessage(message) {
|
|
347
376
|
return typeof message === "string" ? message : message?.message;
|
|
348
377
|
}
|
|
378
|
+
/** The leading size checks of `defChecks` that set an upper bound, each paired with it. */
|
|
379
|
+
function sizeCaps(defChecks) {
|
|
380
|
+
const caps = [];
|
|
381
|
+
for (const ch of defChecks ?? []) {
|
|
382
|
+
const d = ch._zod.def;
|
|
383
|
+
if (!/_(length|size)$|_equals$/.test(d.check)) break;
|
|
384
|
+
const max = d.maximum ?? d.length ?? d.size;
|
|
385
|
+
if (max >= 0) caps.push([max, ch]);
|
|
386
|
+
}
|
|
387
|
+
return caps;
|
|
388
|
+
}
|
|
349
389
|
function attachSchema(issues, start, inst) {
|
|
350
390
|
var _a;
|
|
351
391
|
for (let i = start; i < issues.length; i++) (_a = issues[i]).schema ?? (_a.schema = inst);
|
|
@@ -444,6 +484,7 @@ function hide(inst, key, value) {
|
|
|
444
484
|
return own(inst, key, value, false);
|
|
445
485
|
}
|
|
446
486
|
/** Adds members a table derives from the instance: each builds on first read and shadows as own data, and assignment shadows the same way, as when these were own properties. */
|
|
487
|
+
/* @__NO_SIDE_EFFECTS__ */
|
|
447
488
|
function derived(computes, table) {
|
|
448
489
|
for (const key in computes) {
|
|
449
490
|
const compute = computes[key];
|
|
@@ -593,6 +634,7 @@ function newError(Definition) {
|
|
|
593
634
|
}
|
|
594
635
|
return new Definition();
|
|
595
636
|
}
|
|
637
|
+
/* @__NO_SIDE_EFFECTS__ */
|
|
596
638
|
function $constructor(name, initializer, proto, params) {
|
|
597
639
|
const zodProto = {};
|
|
598
640
|
function Internals(def) {
|
|
@@ -721,8 +763,8 @@ const initializer$1 = (inst, def) => {
|
|
|
721
763
|
});
|
|
722
764
|
}
|
|
723
765
|
};
|
|
724
|
-
const $ZodError = $constructor("$ZodError", initializer$1);
|
|
725
|
-
const $ZodRealError = $constructor("$ZodError", initializer$1, void 0, { Parent: Error });
|
|
766
|
+
const $ZodError = /* @__PURE__ */ $constructor("$ZodError", initializer$1);
|
|
767
|
+
const $ZodRealError = /* @__PURE__ */ $constructor("$ZodError", initializer$1, void 0, { Parent: Error });
|
|
726
768
|
/** Get-or-create `obj[key]` as an own data property. A path segment naming an inherited member
|
|
727
769
|
* ("toString", "constructor") would otherwise read through to the prototype, and assigning
|
|
728
770
|
* "__proto__" would hit the setter instead of creating a key. */
|
|
@@ -1656,7 +1698,7 @@ var Doc = class {
|
|
|
1656
1698
|
const version = {
|
|
1657
1699
|
major: 4,
|
|
1658
1700
|
minor: 6,
|
|
1659
|
-
patch:
|
|
1701
|
+
patch: 7
|
|
1660
1702
|
};
|
|
1661
1703
|
const $ZodType = /*@__PURE__*/ $constructor("$ZodType", (inst, def) => {
|
|
1662
1704
|
var _a;
|
|
@@ -2348,6 +2390,28 @@ const $ZodDate = /*@__PURE__*/ $constructor("$ZodDate", (inst, def) => {
|
|
|
2348
2390
|
return payload;
|
|
2349
2391
|
};
|
|
2350
2392
|
});
|
|
2393
|
+
function capSize(inst, size) {
|
|
2394
|
+
const caps = sizeCaps(inst._zod.def.checks);
|
|
2395
|
+
if (!caps.length) return;
|
|
2396
|
+
const cap = Math.min(...caps.map((c) => c[0]));
|
|
2397
|
+
const parse = inst._zod.parse;
|
|
2398
|
+
inst._zod.parse = (payload, ctx) => {
|
|
2399
|
+
const n = size(payload.value);
|
|
2400
|
+
if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
|
|
2401
|
+
for (const [max, ch] of sizeCaps(inst._zod.def.checks)) if (n > max && ch._zod.def.when(payload)) {
|
|
2402
|
+
const start = payload.issues.length;
|
|
2403
|
+
ch._zod.check(payload);
|
|
2404
|
+
payload.issues[start].continue = false;
|
|
2405
|
+
attachSchema(payload.issues, start, inst);
|
|
2406
|
+
return payload;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
return parse(payload, ctx);
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
const arraySize = (input) => Array.isArray(input) ? input.length : -1;
|
|
2413
|
+
const mapSize = (input) => input instanceof Map ? input.size : -1;
|
|
2414
|
+
const setSize = (input) => input instanceof Set ? input.size : -1;
|
|
2351
2415
|
function handleArrayResult(result, final, index) {
|
|
2352
2416
|
if (result.issues.length) final.issues.push(...prefixIssues(index, result.issues));
|
|
2353
2417
|
final.value[index] = result.value;
|
|
@@ -2385,6 +2449,7 @@ const $ZodArray = /*@__PURE__*/ $constructor("$ZodArray", (inst, def) => {
|
|
|
2385
2449
|
if (proms.length) return Promise.all(proms).then(() => payload);
|
|
2386
2450
|
return payload;
|
|
2387
2451
|
};
|
|
2452
|
+
capSize(inst, arraySize);
|
|
2388
2453
|
});
|
|
2389
2454
|
function handlePropertyResult(result, final, key, input, optin, optout) {
|
|
2390
2455
|
const isPresent = key in input;
|
|
@@ -2493,7 +2558,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
|
|
|
2493
2558
|
}
|
|
2494
2559
|
return propValues;
|
|
2495
2560
|
});
|
|
2496
|
-
const isObject$
|
|
2561
|
+
const isObject$1 = isObject;
|
|
2497
2562
|
const catchall = def.catchall;
|
|
2498
2563
|
let value;
|
|
2499
2564
|
const memo = globalConfig.memoizer;
|
|
@@ -2501,7 +2566,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
|
|
|
2501
2566
|
inst._zod.parse = (payload, ctx) => {
|
|
2502
2567
|
value ?? (value = _normalized.value);
|
|
2503
2568
|
const input = payload.value;
|
|
2504
|
-
if (!isObject$
|
|
2569
|
+
if (!isObject$1(input)) {
|
|
2505
2570
|
payload.issues.push({
|
|
2506
2571
|
expected: "object",
|
|
2507
2572
|
code: "invalid_type",
|
|
@@ -2631,7 +2696,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
|
|
|
2631
2696
|
return doc.compile();
|
|
2632
2697
|
};
|
|
2633
2698
|
let fastpass;
|
|
2634
|
-
const isObject$
|
|
2699
|
+
const isObject$2 = isObject;
|
|
2635
2700
|
const jit = !globalConfig.jitless;
|
|
2636
2701
|
const fastEnabled = jit && allowsEval.value;
|
|
2637
2702
|
const catchall = def.catchall;
|
|
@@ -2639,7 +2704,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
|
|
|
2639
2704
|
inst._zod.parse = (payload, ctx) => {
|
|
2640
2705
|
value ?? (value = _normalized.value);
|
|
2641
2706
|
const input = payload.value;
|
|
2642
|
-
if (!isObject$
|
|
2707
|
+
if (!isObject$2(input)) {
|
|
2643
2708
|
payload.issues.push({
|
|
2644
2709
|
expected: "object",
|
|
2645
2710
|
code: "invalid_type",
|
|
@@ -3217,6 +3282,7 @@ const $ZodMap = /*@__PURE__*/ $constructor("$ZodMap", (inst, def) => {
|
|
|
3217
3282
|
if (proms.length) return Promise.all(proms).then(() => payload);
|
|
3218
3283
|
return payload;
|
|
3219
3284
|
};
|
|
3285
|
+
capSize(inst, mapSize);
|
|
3220
3286
|
});
|
|
3221
3287
|
function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
|
|
3222
3288
|
if (keyResult.issues.length) {
|
|
@@ -3276,6 +3342,7 @@ const $ZodSet = /*@__PURE__*/ $constructor("$ZodSet", (inst, def) => {
|
|
|
3276
3342
|
if (proms.length) return Promise.all(proms).then(() => payload);
|
|
3277
3343
|
return payload;
|
|
3278
3344
|
};
|
|
3345
|
+
capSize(inst, setSize);
|
|
3279
3346
|
});
|
|
3280
3347
|
function handleSetResult(result, final) {
|
|
3281
3348
|
if (result.issues.length) final.issues.push(...result.issues);
|
|
@@ -7761,214 +7828,6 @@ ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
|
7761
7828
|
...schemas_exports,
|
|
7762
7829
|
...checks_exports
|
|
7763
7830
|
});
|
|
7764
|
-
const ZOD_META_KEY = "__zod_meta__";
|
|
7765
|
-
const createMetaType = (type) => {
|
|
7766
|
-
return Object.assign((value) => {
|
|
7767
|
-
return {
|
|
7768
|
-
type,
|
|
7769
|
-
data: value ?? {}
|
|
7770
|
-
};
|
|
7771
|
-
}, type);
|
|
7772
|
-
};
|
|
7773
|
-
const metaStore = (meta) => {
|
|
7774
|
-
const metaMap = meta.reduce((acc, meta) => {
|
|
7775
|
-
if (meta) acc[meta.type.id] = meta;
|
|
7776
|
-
return acc;
|
|
7777
|
-
}, {});
|
|
7778
|
-
return { [ZOD_META_KEY]: {
|
|
7779
|
-
itemMap: metaMap,
|
|
7780
|
-
get itemList() {
|
|
7781
|
-
return Object.values(metaMap);
|
|
7782
|
-
}
|
|
7783
|
-
} };
|
|
7784
|
-
};
|
|
7785
|
-
const MODERATION_CATEGORY_DEFINITIONS = {
|
|
7786
|
-
sexual: {
|
|
7787
|
-
summary: "Sexual acts, explicit sexual anatomy, sexual services, sexual assault depictions and clearly erotic material.",
|
|
7788
|
-
definition: "covers sexual acts, explicit sexual anatomy, sexual services and clearly erotic material. Descriptions of sexual assault can qualify without erotic intent. Fictional, illustrated and generated depictions can also qualify.",
|
|
7789
|
-
exclusions: "Ordinary affection, swimwear, fitness, fashion, non-erotic medical diagrams and general sexual-health education do not qualify on their own. A person accepting money or wearing revealing clothing does not establish sexual content without a qualifying act, display or offer.",
|
|
7790
|
-
docsHref: "/docs/guides/moderation-categories#sexual-content"
|
|
7791
|
-
},
|
|
7792
|
-
violence: {
|
|
7793
|
-
summary: "Physical attacks, injury, death, threats against others, and planning or encouraging physical violence.",
|
|
7794
|
-
definition: "covers physical attacks, bodily injury, death and threats against others. Accidental or medical injuries and natural death also count; an attacker or intent to harm is not required. It also includes planning, requesting, encouraging or praising physical violence. A depicted or described act can qualify in fiction or news reporting even when the author condemns it.",
|
|
7795
|
-
exclusions: "Solely intentional self-injury or suicide belongs under self-harm. Property damage alone, figurative language, abstract discussion of violence, injury or death and merely possessing a weapon do not establish violence. Consensual sport or training without injury or abuse is excluded.",
|
|
7796
|
-
docsHref: "/docs/guides/moderation-categories#violence"
|
|
7797
|
-
},
|
|
7798
|
-
"self-harm": {
|
|
7799
|
-
summary: "Intentional self-injury, suicide, preparation, methods or encouragement; text also includes current or recurring self-harm thoughts.",
|
|
7800
|
-
definition: "covers intentional self-injury, suicide, current self-harm risk, preparation, methods and encouragement. For text, it also includes current or recurring self-harm thoughts and an explicitly expressed desire to die. Asking for support does not cancel a separate disclosure of current risk or behavior.",
|
|
7801
|
-
exclusions: "For images, require evidence of a self-directed act, preparation, harmful methods or encouragement. An injury or covered body does not establish that harm was self-inflicted. General sadness, neutral statistics, prevention, recovery and healed scars alone are excluded.",
|
|
7802
|
-
docsHref: "/docs/guides/moderation-categories#self-harm"
|
|
7803
|
-
},
|
|
7804
|
-
harassment: {
|
|
7805
|
-
summary: "Targeted personal abuse, humiliation, bullying or serious intimidation; dislike, ordinary criticism and neutral reporting alone are excluded.",
|
|
7806
|
-
definition: "covers personal abuse, humiliation, bullying and serious intimidation directed at a person or identifiable group. The target can be named, implied or defined by an activity or affiliation. A protected identity is not required.",
|
|
7807
|
-
exclusions: "Expressions of dislike or anger, such as \"I hate you\" or \"i hate u\", disagreement, ordinary criticism of conduct or competence, untargeted profanity and neutral reporting of abuse do not qualify on their own. Dislike can still qualify when accompanied by personal degradation, threats, serious intimidation or sustained bullying.",
|
|
7808
|
-
docsHref: "/docs/guides/moderation-categories#harassment-and-bullying"
|
|
7809
|
-
},
|
|
7810
|
-
hate: {
|
|
7811
|
-
summary: "Identity-based abuse, dehumanization, discriminatory exclusion or threats against people because of protected characteristics.",
|
|
7812
|
-
definition: "covers attacks on people because of race, ethnicity, nationality, religion, caste, sex, gender identity, sexual orientation or disability. It includes dehumanization, asserted inferiority, discriminatory exclusion, identity-based abuse and threats.",
|
|
7813
|
-
exclusions: "Both the protected target and the identity-based attack must be supported. Identity mentions, cultural clothing, religious imagery, criticism of beliefs or an unrelated threat do not establish hate. Neutral reporting, condemnation and opposition to hate are excluded unless a separate attack is expressed or endorsed.",
|
|
7814
|
-
docsHref: "/docs/guides/moderation-categories#hate-and-identity-abuse"
|
|
7815
|
-
},
|
|
7816
|
-
"criminal-activity": {
|
|
7817
|
-
summary: "Planning, assistance, instructions, encouragement or facilitation of a concrete criminal or seriously dangerous act, fraud and scams, and illegal drugs or contraband.",
|
|
7818
|
-
definition: "covers planning, requests for assistance, instructions, encouragement or facilitation of a concrete criminal or seriously dangerous act. A request can qualify even when no act occurs and a later reply refuses it. Dangerous activity requires a concrete serious risk of physical harm. It also covers fraud, scams and phishing aimed at the reader, illegal drug use, dealing, production or trafficking, and contraband such as stolen goods, counterfeit money or documents and smuggled or illegally traded weapons.",
|
|
7819
|
-
exclusions: "Neutral reporting, prevention, lawful safety advice, fiction without criminal assistance, and vague dangerous references do not qualify. Medical, pharmaceutical, clinical, research and educational content does not qualify, including prescriptions, dosages, side effects, controlled medicines used as prescribed, pharmacies, hospitals and drug manufacturing by pharmaceutical companies. Lawful alcohol and tobacco, harm reduction, addiction recovery and drug-policy discussion do not qualify either. Masks, computers, payment cards, detention or ordinary tools do not establish criminal facilitation by themselves. A clear visual demonstration of how to carry out a crime can qualify.",
|
|
7820
|
-
docsHref: "/docs/guides/moderation-categories#criminal-and-dangerous-activity"
|
|
7821
|
-
}
|
|
7822
|
-
};
|
|
7823
|
-
const MODERATION_QUALIFIERS = [
|
|
7824
|
-
"sexual-nudity",
|
|
7825
|
-
"sexual-activity",
|
|
7826
|
-
"sexual-explicit-language",
|
|
7827
|
-
"sexual-solicitation",
|
|
7828
|
-
"sexual-historical",
|
|
7829
|
-
"sexual-documentary",
|
|
7830
|
-
"sexual-educational",
|
|
7831
|
-
"sexual-fictional",
|
|
7832
|
-
"sexual-support-prevention",
|
|
7833
|
-
"violence-attack",
|
|
7834
|
-
"violence-threat",
|
|
7835
|
-
"violence-preparation",
|
|
7836
|
-
"violence-encouragement",
|
|
7837
|
-
"violence-injury",
|
|
7838
|
-
"violence-violent-death",
|
|
7839
|
-
"violence-natural-death",
|
|
7840
|
-
"violence-graphic",
|
|
7841
|
-
"violence-historical",
|
|
7842
|
-
"violence-documentary",
|
|
7843
|
-
"violence-educational",
|
|
7844
|
-
"violence-fictional",
|
|
7845
|
-
"violence-support-prevention",
|
|
7846
|
-
"self-harm-thoughts",
|
|
7847
|
-
"self-harm-preparation",
|
|
7848
|
-
"self-harm-act",
|
|
7849
|
-
"self-harm-methods",
|
|
7850
|
-
"self-harm-encouragement",
|
|
7851
|
-
"self-harm-documentary",
|
|
7852
|
-
"self-harm-educational",
|
|
7853
|
-
"self-harm-fictional",
|
|
7854
|
-
"self-harm-support-prevention",
|
|
7855
|
-
"harassment-abuse",
|
|
7856
|
-
"harassment-intimidation",
|
|
7857
|
-
"harassment-threat",
|
|
7858
|
-
"harassment-sexual",
|
|
7859
|
-
"harassment-encouragement",
|
|
7860
|
-
"harassment-fictional",
|
|
7861
|
-
"hate-abuse",
|
|
7862
|
-
"hate-dehumanization",
|
|
7863
|
-
"hate-inferiority",
|
|
7864
|
-
"hate-exclusion",
|
|
7865
|
-
"hate-threat",
|
|
7866
|
-
"hate-historical",
|
|
7867
|
-
"hate-fictional",
|
|
7868
|
-
"criminal-activity-demonstration",
|
|
7869
|
-
"criminal-activity-preparation",
|
|
7870
|
-
"criminal-activity-request",
|
|
7871
|
-
"criminal-activity-instructions",
|
|
7872
|
-
"criminal-activity-promotion",
|
|
7873
|
-
"criminal-activity-substances",
|
|
7874
|
-
"criminal-activity-fraud",
|
|
7875
|
-
"criminal-activity-fictional"
|
|
7876
|
-
];
|
|
7877
|
-
const docPropertyInfo = createMetaType({ id: "docPropertyInfo" });
|
|
7878
|
-
/**
|
|
7879
|
-
* Optional analyses a request can turn on with `processingOptions.features.<id>`. The catalog is
|
|
7880
|
-
* the single source for the request and result schemas, cache keys and metering. Features are
|
|
7881
|
-
* chosen per request only; there is no project-level default.
|
|
7882
|
-
*/
|
|
7883
|
-
/** How mature a feature is. Beta results may change shape. */
|
|
7884
|
-
const PROCESSING_FEATURE_STAGES = ["beta", "ga"];
|
|
7885
|
-
const createFeatureOptionsSchemas = (booleanSchema) => {
|
|
7886
|
-
const enabled = booleanSchema.meta(metaStore([docPropertyInfo({ description: "Whether to run the feature. Every feature is off unless this is true." })]));
|
|
7887
|
-
return {
|
|
7888
|
-
ocr: zod.object({ enabled }).strict().meta(metaStore([docPropertyInfo({
|
|
7889
|
-
description: "Extract image text into a child asset and run normal text moderation. Applies to images, including those extracted from multimedia documents and websites.",
|
|
7890
|
-
namedType: "OcrFeatureOptions"
|
|
7891
|
-
})])),
|
|
7892
|
-
personDetection: zod.object({ enabled }).strict().meta(metaStore([docPropertyInfo({
|
|
7893
|
-
description: "Beta. Locate people in images, including people whose faces are not visible. Results are informational and never change the moderation decision.",
|
|
7894
|
-
namedType: "PersonDetectionFeatureOptions"
|
|
7895
|
-
})])),
|
|
7896
|
-
faceDetection: zod.object({
|
|
7897
|
-
enabled,
|
|
7898
|
-
ageEstimation: booleanSchema.optional().meta(metaStore([docPropertyInfo({
|
|
7899
|
-
description: "Also estimate an age range for each face",
|
|
7900
|
-
defaultValue: false
|
|
7901
|
-
})]))
|
|
7902
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7903
|
-
description: "Beta. Locate faces in images and optionally estimate an age range for each. Faces are not identified or stored as biometric templates. Results are informational and never change the moderation decision.",
|
|
7904
|
-
namedType: "FaceDetectionFeatureOptions"
|
|
7905
|
-
})]))
|
|
7906
|
-
};
|
|
7907
|
-
};
|
|
7908
|
-
const createProcessingFeaturesSchema = (booleanSchema) => {
|
|
7909
|
-
const options = createFeatureOptionsSchemas(booleanSchema);
|
|
7910
|
-
return zod.object({
|
|
7911
|
-
ocr: options.ocr.nullish(),
|
|
7912
|
-
personDetection: options.personDetection.nullish(),
|
|
7913
|
-
faceDetection: options.faceDetection.nullish()
|
|
7914
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7915
|
-
description: "Optional analyses to run on this asset, keyed by feature. Unknown features are rejected.",
|
|
7916
|
-
namedType: "ProcessingFeatures"
|
|
7917
|
-
})]));
|
|
7918
|
-
};
|
|
7919
|
-
createProcessingFeaturesSchema(zod.boolean());
|
|
7920
|
-
/** Position and size as fractions of the image width and height, from 0 to 1. */
|
|
7921
|
-
const BOUNDING_BOX_SCHEMA = zod.object({
|
|
7922
|
-
left: zod.number(),
|
|
7923
|
-
top: zod.number(),
|
|
7924
|
-
width: zod.number().min(0),
|
|
7925
|
-
height: zod.number().min(0)
|
|
7926
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7927
|
-
description: "Position and size as fractions of the image width and height, from 0 to 1. `left` and `top` can be slightly negative when the subject extends past the edge.",
|
|
7928
|
-
namedType: "BoundingBox"
|
|
7929
|
-
})]));
|
|
7930
|
-
const DETECTION_CONFIDENCE_SCHEMA = zod.number().min(0).max(1).describe("Detection confidence from 0 to 1");
|
|
7931
|
-
const AGE_RANGE_SCHEMA = zod.object({
|
|
7932
|
-
low: zod.number().int().nonnegative(),
|
|
7933
|
-
high: zod.number().int().nonnegative()
|
|
7934
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7935
|
-
description: "Estimated age range in years. Estimates can be off by several years.",
|
|
7936
|
-
namedType: "AgeRange"
|
|
7937
|
-
})]));
|
|
7938
|
-
const DETECTED_PERSON_SCHEMA = zod.object({
|
|
7939
|
-
box: BOUNDING_BOX_SCHEMA,
|
|
7940
|
-
confidence: DETECTION_CONFIDENCE_SCHEMA
|
|
7941
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7942
|
-
description: "A detected person",
|
|
7943
|
-
namedType: "DetectedPerson"
|
|
7944
|
-
})]));
|
|
7945
|
-
const DETECTED_FACE_SCHEMA = zod.object({
|
|
7946
|
-
box: BOUNDING_BOX_SCHEMA,
|
|
7947
|
-
confidence: DETECTION_CONFIDENCE_SCHEMA,
|
|
7948
|
-
ageRange: AGE_RANGE_SCHEMA.optional()
|
|
7949
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7950
|
-
description: "A detected face",
|
|
7951
|
-
namedType: "DetectedFace"
|
|
7952
|
-
})]));
|
|
7953
|
-
const NOT_EVALUATED_FEATURE_RESULT_SCHEMA = zod.object({
|
|
7954
|
-
status: zod.literal("not-evaluated"),
|
|
7955
|
-
stage: zod.enum(PROCESSING_FEATURE_STAGES),
|
|
7956
|
-
reason: zod.enum(["not-supported", "unavailable"])
|
|
7957
|
-
}).strict();
|
|
7958
|
-
const createFeatureResultSchema = (shape) => zod.discriminatedUnion("status", [zod.object({
|
|
7959
|
-
status: zod.literal("evaluated"),
|
|
7960
|
-
stage: zod.enum(PROCESSING_FEATURE_STAGES),
|
|
7961
|
-
...shape
|
|
7962
|
-
}).strict(), NOT_EVALUATED_FEATURE_RESULT_SCHEMA]);
|
|
7963
|
-
const PERSON_DETECTION_RESULT_SCHEMA = createFeatureResultSchema({ people: zod.array(DETECTED_PERSON_SCHEMA) });
|
|
7964
|
-
const FACE_DETECTION_RESULT_SCHEMA = createFeatureResultSchema({ faces: zod.array(DETECTED_FACE_SCHEMA) });
|
|
7965
|
-
const PROCESSING_FEATURE_RESULTS_SCHEMA = zod.object({
|
|
7966
|
-
personDetection: PERSON_DETECTION_RESULT_SCHEMA.optional(),
|
|
7967
|
-
faceDetection: FACE_DETECTION_RESULT_SCHEMA.optional()
|
|
7968
|
-
}).strict().meta(metaStore([docPropertyInfo({
|
|
7969
|
-
description: "Results of the optional features requested in `processingOptions.features`, keyed by feature",
|
|
7970
|
-
namedType: "ProcessingFeatureResults"
|
|
7971
|
-
})]));
|
|
7972
7831
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
7973
7832
|
const CHARS_GLOBAL_BACKSLASH_SUPPORTED_RX = /[\0\b\t\n\r\x1a"'\\]/g;
|
|
7974
7833
|
const CHARS_ESCAPE_BACKSLASH_SUPPORTED_MAP = {
|
|
@@ -8025,166 +7884,106 @@ const PROCESSING_FEATURE_RESULTS_SCHEMA = zod.object({
|
|
|
8025
7884
|
}
|
|
8026
7885
|
module.exports = escapeString;
|
|
8027
7886
|
})))();
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
7887
|
+
/**
|
|
7888
|
+
* The prefix of every kind of object customers can see, so an ID says what it identifies:
|
|
7889
|
+
* `proj_8fK2mQx7Lp4NvB3tRz9Y` is a project. Rows that never leave the backend (usage meters, upload
|
|
7890
|
+
* token uses, retained variants, terms acceptances, notices) keep their own internal IDs.
|
|
7891
|
+
*/
|
|
7892
|
+
const ID_PREFIXES = {
|
|
7893
|
+
organisation: "org",
|
|
7894
|
+
project: "proj",
|
|
7895
|
+
asset: "asset",
|
|
7896
|
+
folder: "fldr",
|
|
7897
|
+
lens: "lens",
|
|
7898
|
+
apiKey: "key",
|
|
7899
|
+
uploadPreset: "preset",
|
|
7900
|
+
connector: "conn",
|
|
7901
|
+
connectorDelivery: "dlv",
|
|
7902
|
+
hashList: "hl",
|
|
7903
|
+
hashListEntry: "hle",
|
|
7904
|
+
policy: "pol",
|
|
7905
|
+
event: "evt",
|
|
7906
|
+
user: "user",
|
|
7907
|
+
member: "mem",
|
|
7908
|
+
invitation: "inv",
|
|
7909
|
+
supportTicket: "tkt",
|
|
7910
|
+
supportTicketMessage: "msg",
|
|
7911
|
+
humanReview: "rev",
|
|
7912
|
+
appeal: "apl",
|
|
7913
|
+
complianceCase: "case",
|
|
7914
|
+
safetyHold: "hold",
|
|
7915
|
+
creditPurchase: "cp",
|
|
7916
|
+
oauthClient: "oac",
|
|
7917
|
+
waitlistEntry: "wl"
|
|
8034
7918
|
};
|
|
8035
|
-
const
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
7919
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
7920
|
+
const ID_BODY = `[0-9A-Za-z]{20}`;
|
|
7921
|
+
const ID_PREFIX_VALUES = new Set(Object.values(ID_PREFIXES));
|
|
7922
|
+
const patterns = /* @__PURE__ */ new Map();
|
|
7923
|
+
/** Matches one ID with `prefix`, or any registered prefix when none is given. */
|
|
7924
|
+
const idPattern = (prefix) => {
|
|
7925
|
+
const key = prefix ?? "";
|
|
7926
|
+
let pattern = patterns.get(key);
|
|
7927
|
+
if (!pattern) {
|
|
7928
|
+
const prefixes = prefix ? prefix : [...ID_PREFIX_VALUES].join("|");
|
|
7929
|
+
pattern = new RegExp(`^(?:${prefixes})_${ID_BODY}$`);
|
|
7930
|
+
patterns.set(key, pattern);
|
|
7931
|
+
}
|
|
7932
|
+
return pattern;
|
|
8043
7933
|
};
|
|
8044
|
-
|
|
8045
|
-
const
|
|
8046
|
-
"
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
"
|
|
8064
|
-
"
|
|
8065
|
-
"
|
|
8066
|
-
"
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
status: zod.literal("not-evaluated"),
|
|
8098
|
-
reason: CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA,
|
|
8099
|
-
qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
|
|
8100
|
-
}).strict()
|
|
8101
|
-
]);
|
|
8102
|
-
const CATEGORIES_SCHEMA = zod.object({
|
|
8103
|
-
sexual: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
|
|
8104
|
-
violence: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
|
|
8105
|
-
"self-harm": CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
|
|
8106
|
-
harassment: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
|
|
8107
|
-
hate: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
|
|
8108
|
-
"criminal-activity": CLASSIFICATION_CATEGORY_REPORT_SCHEMA
|
|
8109
|
-
}).strict();
|
|
8110
|
-
/** What a moderation policy does with a category: nothing, alert, hide or purge. */
|
|
8111
|
-
const FLAGGED_CONTENT_ACTION_SCHEMA = zod.enum([
|
|
8112
|
-
"ignore",
|
|
8113
|
-
"alert",
|
|
8114
|
-
"hide",
|
|
8115
|
-
"purge"
|
|
8116
|
-
]);
|
|
8117
|
-
/** What the project's policy made of the report when the asset was decided. */
|
|
8118
|
-
const CLASSIFICATION_REPORT_POLICY_SCHEMA = zod.object({
|
|
8119
|
-
score: zod.number().min(0).max(1).describe("The highest category policy score."),
|
|
8120
|
-
categories: zod.partialRecord(CLASSIFICATION_CATEGORY_SCHEMA, zod.object({
|
|
8121
|
-
score: zod.number().min(0).max(1).describe("How strongly the content meets the policy, from 0 to 1: the category score, lowered by allowances that cover the content and raised to the presence score of a restriction."),
|
|
8122
|
-
action: FLAGGED_CONTENT_ACTION_SCHEMA.describe("What the policy does with this category. `ignore` when the policy score is below the category threshold.")
|
|
8123
|
-
}).strict()).describe("Each assessed category's policy score and action. Unassessed categories are left out.")
|
|
8124
|
-
}).strict();
|
|
8125
|
-
const CLASSIFICATION_LABEL_SCHEMA = zod.object({
|
|
8126
|
-
description: saneStringField(),
|
|
8127
|
-
score: zod.number(),
|
|
8128
|
-
topicalityScore: zod.number().nullish(),
|
|
8129
|
-
likelihood: LIKELIHOOD_SCHEMA,
|
|
8130
|
-
knowledgeBaseId: saneStringField().nullish()
|
|
8131
|
-
});
|
|
8132
|
-
zod.object({
|
|
8133
|
-
processedAt: zod.number(),
|
|
8134
|
-
categories: CATEGORIES_SCHEMA,
|
|
8135
|
-
score: zod.number(),
|
|
8136
|
-
notes: saneStringField().nullish(),
|
|
8137
|
-
media: zod.object({
|
|
8138
|
-
durationSeconds: zod.number().positive(),
|
|
8139
|
-
frameIntervalSeconds: zod.number().positive().optional(),
|
|
8140
|
-
sampledFrames: zod.number().int().nonnegative(),
|
|
8141
|
-
audioTranscribed: zod.boolean(),
|
|
8142
|
-
segments: zod.array(zod.object({
|
|
8143
|
-
source: zod.enum([
|
|
8144
|
-
"frame",
|
|
8145
|
-
"speech",
|
|
8146
|
-
"frame-text",
|
|
8147
|
-
"clip"
|
|
8148
|
-
]),
|
|
8149
|
-
startSeconds: zod.number().nonnegative(),
|
|
8150
|
-
endSeconds: zod.number().nonnegative(),
|
|
8151
|
-
categories: CATEGORIES_SCHEMA
|
|
8152
|
-
}).strict())
|
|
8153
|
-
}).strict().optional(),
|
|
8154
|
-
labels: zod.array(CLASSIFICATION_LABEL_SCHEMA).nullish(),
|
|
8155
|
-
features: PROCESSING_FEATURE_RESULTS_SCHEMA.optional(),
|
|
8156
|
-
policy: CLASSIFICATION_REPORT_POLICY_SCHEMA.optional().describe("What the project's moderation policy made of this report when the asset was decided. Absent until then.")
|
|
8157
|
-
}).strict();
|
|
8158
|
-
//#endregion
|
|
8159
|
-
//#region src/contract/MediaTestTriggers.js
|
|
8160
|
-
const MEDIA_TEST_SIGNALS = [...CLASSIFICATION_CATEGORIES, "violence.graphic"];
|
|
8161
|
-
const HEX_WORDS = [
|
|
8162
|
-
"zero",
|
|
8163
|
-
"one",
|
|
8164
|
-
"two",
|
|
8165
|
-
"three",
|
|
8166
|
-
"four",
|
|
8167
|
-
"five",
|
|
8168
|
-
"six",
|
|
8169
|
-
"seven",
|
|
8170
|
-
"eight",
|
|
8171
|
-
"nine",
|
|
8172
|
-
"alpha",
|
|
8173
|
-
"bravo",
|
|
8174
|
-
"charlie",
|
|
8175
|
-
"delta",
|
|
8176
|
-
"echo",
|
|
8177
|
-
"foxtrot"
|
|
8178
|
-
];
|
|
8179
|
-
/** Read this aloud, or render it as large, high-contrast text in a video frame. */
|
|
8180
|
-
function generateMediaTestCode(signal, testInputKey) {
|
|
8181
|
-
if (!MEDIA_TEST_SIGNALS.includes(signal)) throw new Error("Unsupported media test signal");
|
|
8182
|
-
const suffix = testInputKey.toLowerCase();
|
|
8183
|
-
if (!/^[a-f0-9]{8}$/.test(suffix)) throw new Error("Expected an eight-character hexadecimal test input key");
|
|
8184
|
-
return `Calm Lens test ${[...suffix].map((character) => HEX_WORDS[Number.parseInt(character, 16)]).join(" ")} ${signal.replace(/[.-]/g, " ")} end test`;
|
|
8185
|
-
}
|
|
8186
|
-
//#endregion
|
|
8187
|
-
//#region src/contract/Permissions-B5dqc8aj.js
|
|
7934
|
+
/** Base62 characters from a CSPRNG, without modulo bias (bytes 248-255 are redrawn). */
|
|
7935
|
+
const randomBase62 = (length) => {
|
|
7936
|
+
let result = "";
|
|
7937
|
+
while (result.length < length) {
|
|
7938
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
|
|
7939
|
+
for (const byte of bytes) {
|
|
7940
|
+
if (byte >= 248) continue;
|
|
7941
|
+
result += ALPHABET[byte % 62];
|
|
7942
|
+
if (result.length === length) break;
|
|
7943
|
+
}
|
|
7944
|
+
}
|
|
7945
|
+
return result;
|
|
7946
|
+
};
|
|
7947
|
+
/** A new random ID, such as `newId("proj")` → `proj_8fK2mQx7Lp4NvB3tRz9Y`. */
|
|
7948
|
+
const newId = (prefix) => `${prefix}_${randomBase62(20)}`;
|
|
7949
|
+
/** Whether `value` is an ID with `prefix` (or with any registered prefix). */
|
|
7950
|
+
const isId = (value, prefix) => typeof value === "string" && idPattern(prefix).test(value);
|
|
7951
|
+
/** Example IDs for docs, OpenAPI and SDK fixtures: fixed, so generated output stays stable. */
|
|
7952
|
+
const EXAMPLE_IDS = {
|
|
7953
|
+
org: "org_3hQ7tLk2Vb9XpR4mWc8N",
|
|
7954
|
+
proj: "proj_8fK2mQx7Lp4NvB3tRz9Y",
|
|
7955
|
+
asset: "asset_5Gm2Rk8XqT3wLp9VbN4c",
|
|
7956
|
+
fldr: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
|
|
7957
|
+
lens: "lens_2Xr9Lp4Vm7Qk3Tb8Nw5C",
|
|
7958
|
+
key: "key_9Pt3Wm6Rk2Lx8Qv4Nb7C",
|
|
7959
|
+
preset: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
|
|
7960
|
+
conn: "conn_6Rb2Xk9Lm4Qt7Wp3Nv8C",
|
|
7961
|
+
dlv: "dlv_3Vm8Qk2Xt7Lp9Rb4Nw6C",
|
|
7962
|
+
hl: "hl_5Xk3Rm9Lq2Vt8Wb4Np7C",
|
|
7963
|
+
hle: "hle_8Qm4Xk2Rt9Lv3Wb7Np5C",
|
|
7964
|
+
pol: "pol_2Wk7Rm4Xq9Lt3Vb8Np6C",
|
|
7965
|
+
evt: "evt_7Lm3Xq8Rk2Vt9Wb4Np5C",
|
|
7966
|
+
user: "user_4Rk9Xm2Lq7Vt3Wb8Np6C",
|
|
7967
|
+
mem: "mem_9Xm4Rk7Lq2Vt8Wb3Np5C",
|
|
7968
|
+
inv: "inv_3Qk8Xm2Rt9Lv4Wb7Np6C",
|
|
7969
|
+
tkt: "tkt_6Xm2Rk9Lq4Vt7Wb3Np8C",
|
|
7970
|
+
msg: "msg_2Rk7Xm4Lq9Vt3Wb8Np5C",
|
|
7971
|
+
rev: "rev_8Xm3Rk2Lq7Vt9Wb4Np6C",
|
|
7972
|
+
apl: "apl_4Qk9Xm7Rt2Lv8Wb3Np5C",
|
|
7973
|
+
case: "case_7Xm2Rk4Lq9Vt3Wb8Np6C",
|
|
7974
|
+
hold: "hold_3Rk8Xm2Lq7Vt4Wb9Np5C",
|
|
7975
|
+
cp: "cp_9Qk4Xm7Rt3Lv8Wb2Np6C",
|
|
7976
|
+
oac: "oac_5Xm9Rk3Lq2Vt7Wb4Np8C",
|
|
7977
|
+
wl: "wl_2Qk7Xm4Rt9Lv3Wb8Np5C"
|
|
7978
|
+
};
|
|
7979
|
+
/**
|
|
7980
|
+
* A string field holding an ID with `prefix`: a string schema with a pattern, so OpenAPI and the
|
|
7981
|
+
* docs show the pattern and the example. Parsed values are typed `string`, not `Id<P>`: typing
|
|
7982
|
+
* every stored model's IDs by prefix is a separate change (it surfaces about a thousand call sites
|
|
7983
|
+
* across the packages that build records from plain strings).
|
|
7984
|
+
*/
|
|
7985
|
+
const idField = (prefix) => zod.string().regex(idPattern(prefix), { message: `Must be an ID starting with "${prefix}_"` }).meta({ examples: [EXAMPLE_IDS[prefix]] });
|
|
7986
|
+
// src/contract/Permissions-DjKQmOVp.js
|
|
8188
7987
|
const ACTION_SCHEMA = zod.enum([
|
|
8189
7988
|
"assets:read",
|
|
8190
7989
|
"assets:download",
|
|
@@ -8210,6 +8009,8 @@ const ACTION_SCHEMA = zod.enum([
|
|
|
8210
8009
|
"lenses:write",
|
|
8211
8010
|
"upload-presets:read",
|
|
8212
8011
|
"upload-presets:write",
|
|
8012
|
+
"hash-lists:read",
|
|
8013
|
+
"hash-lists:write",
|
|
8213
8014
|
"reviews:read",
|
|
8214
8015
|
"reviews:write",
|
|
8215
8016
|
"project:manage"
|
|
@@ -8222,6 +8023,7 @@ const PROJECT_ACTIONS = [...[
|
|
|
8222
8023
|
"metadata",
|
|
8223
8024
|
"lenses",
|
|
8224
8025
|
"upload-presets",
|
|
8026
|
+
"hash-lists",
|
|
8225
8027
|
"reviews"
|
|
8226
8028
|
].flatMap((feature) => [`${feature}:read`, `${feature}:write`]), "project:manage"];
|
|
8227
8029
|
const isProjectAction = (action) => PROJECT_ACTIONS.includes(action);
|
|
@@ -8229,7 +8031,7 @@ const isProjectAction = (action) => PROJECT_ACTIONS.includes(action);
|
|
|
8229
8031
|
const actionsImplying = (action) => !isProjectAction(action) || action === "project:manage" ? [] : action.endsWith(":read") ? [action.replace(/:read$/, ":write"), "project:manage"] : ["project:manage"];
|
|
8230
8032
|
const GRANT_SCHEMA = zod.object({
|
|
8231
8033
|
actions: zod.array(ACTION_SCHEMA),
|
|
8232
|
-
folderId:
|
|
8034
|
+
folderId: idField("fldr").nullable(),
|
|
8233
8035
|
includeSubfolders: zod.boolean()
|
|
8234
8036
|
}).strict().refine((g) => g.folderId === null || !g.actions.some(isProjectAction), "Project configuration permissions require project scope");
|
|
8235
8037
|
const GRANTS_SCHEMA = zod.array(GRANT_SCHEMA).max(100);
|
|
@@ -8252,15 +8054,15 @@ const permits = (grants, action, folderId, folders) => grants.some((grant) => {
|
|
|
8252
8054
|
}
|
|
8253
8055
|
return false;
|
|
8254
8056
|
});
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
zod.
|
|
8057
|
+
/**
|
|
8058
|
+
* What a person can let an AI assistant or the CLI do for them. `read` looks at assets, folders,
|
|
8059
|
+
* hash lists and events; `write` also creates, changes and deletes assets, folders and hash lists.
|
|
8060
|
+
* Members, API keys, connectors, sharing, downloads and project settings are never delegated.
|
|
8061
|
+
*/
|
|
8062
|
+
const AGENT_SCOPE_SCHEMA = zod.enum(["read", "write"]);
|
|
8063
|
+
zod.array(AGENT_SCOPE_SCHEMA).min(1).max(2);
|
|
8261
8064
|
zod.enum(["active", "banned"]);
|
|
8262
|
-
|
|
8263
|
-
//#region src/testing/MockApiKey.ts
|
|
8065
|
+
// src/testing/MockApiKey.ts
|
|
8264
8066
|
const MOCK_API_KEY_PREFIX = "sk_test_";
|
|
8265
8067
|
const toBase64Url = (value) => btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
8266
8068
|
const fromBase64Url = (value) => {
|
|
@@ -8276,133 +8078,322 @@ const getMockApiKeyGrants = (apiKey) => {
|
|
|
8276
8078
|
return [];
|
|
8277
8079
|
}
|
|
8278
8080
|
};
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8081
|
+
// src/testing/MockClassification.ts
|
|
8082
|
+
/** The parent categories, in the API's order. */
|
|
8083
|
+
const MOCK_CATEGORIES = [
|
|
8084
|
+
"sexual",
|
|
8085
|
+
"violence",
|
|
8086
|
+
"self-harm",
|
|
8087
|
+
"harassment",
|
|
8088
|
+
"hate",
|
|
8089
|
+
"criminal-activity",
|
|
8090
|
+
"personal-information",
|
|
8091
|
+
"profanity"
|
|
8092
|
+
];
|
|
8093
|
+
/** Each category's qualifiers. */
|
|
8094
|
+
const MOCK_QUALIFIERS = {
|
|
8095
|
+
"sexual": [
|
|
8096
|
+
"sexual-pornographic-media",
|
|
8097
|
+
"sexual-nudity",
|
|
8098
|
+
"sexual-activity",
|
|
8099
|
+
"sexual-explicit-language",
|
|
8100
|
+
"sexual-assault-media",
|
|
8101
|
+
"sexual-non-consensual",
|
|
8102
|
+
"sexual-intimate-image-abuse",
|
|
8103
|
+
"sexual-extreme",
|
|
8104
|
+
"sexual-minors",
|
|
8105
|
+
"sexual-solicitation",
|
|
8106
|
+
"sexual-paid",
|
|
8107
|
+
"sexual-adult-promotion",
|
|
8108
|
+
"sexual-revealing-clothing",
|
|
8109
|
+
"sexual-suggestive",
|
|
8110
|
+
"sexual-non-erotic-nudity",
|
|
8111
|
+
"sexual-text",
|
|
8112
|
+
"sexual-historical",
|
|
8113
|
+
"sexual-documentary",
|
|
8114
|
+
"sexual-educational",
|
|
8115
|
+
"sexual-discussion",
|
|
8116
|
+
"sexual-fictional",
|
|
8117
|
+
"sexual-support-prevention"
|
|
8118
|
+
],
|
|
8119
|
+
"violence": [
|
|
8120
|
+
"violence-high-impact",
|
|
8121
|
+
"violence-attack",
|
|
8122
|
+
"violence-threat",
|
|
8123
|
+
"violence-preparation",
|
|
8124
|
+
"violence-encouragement",
|
|
8125
|
+
"violence-wish",
|
|
8126
|
+
"violence-coded-incitement",
|
|
8127
|
+
"violence-extremism",
|
|
8128
|
+
"violence-injury",
|
|
8129
|
+
"violence-violent-death",
|
|
8130
|
+
"violence-natural-death",
|
|
8131
|
+
"violence-graphic",
|
|
8132
|
+
"violence-decapitation-act",
|
|
8133
|
+
"violence-gore",
|
|
8134
|
+
"violence-weapon",
|
|
8135
|
+
"violence-sport",
|
|
8136
|
+
"violence-text",
|
|
8137
|
+
"violence-historical",
|
|
8138
|
+
"violence-documentary",
|
|
8139
|
+
"violence-educational",
|
|
8140
|
+
"violence-discussion",
|
|
8141
|
+
"violence-fictional",
|
|
8142
|
+
"violence-fantasy",
|
|
8143
|
+
"violence-support-prevention"
|
|
8144
|
+
],
|
|
8145
|
+
"self-harm": [
|
|
8146
|
+
"self-harm-age-restricted",
|
|
8147
|
+
"self-harm-thoughts",
|
|
8148
|
+
"self-harm-preparation",
|
|
8149
|
+
"self-harm-act",
|
|
8150
|
+
"self-harm-methods",
|
|
8151
|
+
"self-harm-encouragement",
|
|
8152
|
+
"self-harm-historical",
|
|
8153
|
+
"self-harm-documentary",
|
|
8154
|
+
"self-harm-educational",
|
|
8155
|
+
"self-harm-fictional",
|
|
8156
|
+
"self-harm-support-prevention"
|
|
8157
|
+
],
|
|
8158
|
+
"harassment": [
|
|
8159
|
+
"harassment-abuse",
|
|
8160
|
+
"harassment-mild",
|
|
8161
|
+
"harassment-bullying",
|
|
8162
|
+
"harassment-public-target",
|
|
8163
|
+
"harassment-threat",
|
|
8164
|
+
"harassment-sexual",
|
|
8165
|
+
"harassment-encouragement",
|
|
8166
|
+
"harassment-historical",
|
|
8167
|
+
"harassment-documentary",
|
|
8168
|
+
"harassment-educational",
|
|
8169
|
+
"harassment-fictional",
|
|
8170
|
+
"harassment-support-prevention"
|
|
8171
|
+
],
|
|
8172
|
+
"hate": [
|
|
8173
|
+
"hate-nazi-symbols-public-display",
|
|
8174
|
+
"hate-nazi-insignia-public-display",
|
|
8175
|
+
"hate-nazi-genocide-public-peace",
|
|
8176
|
+
"hate-nazi-genocide-denial-or-approval",
|
|
8177
|
+
"hate-adjudicated-nazi-crimes-denial",
|
|
8178
|
+
"hate-abuse",
|
|
8179
|
+
"hate-slurs",
|
|
8180
|
+
"hate-dehumanization",
|
|
8181
|
+
"hate-inferiority",
|
|
8182
|
+
"hate-exclusion",
|
|
8183
|
+
"hate-symbol",
|
|
8184
|
+
"hate-hostility",
|
|
8185
|
+
"hate-threat",
|
|
8186
|
+
"hate-personal",
|
|
8187
|
+
"hate-historical",
|
|
8188
|
+
"hate-documentary",
|
|
8189
|
+
"hate-educational",
|
|
8190
|
+
"hate-fictional",
|
|
8191
|
+
"hate-counterspeech",
|
|
8192
|
+
"hate-support-prevention"
|
|
8193
|
+
],
|
|
8194
|
+
"criminal-activity": [
|
|
8195
|
+
"criminal-activity-demonstration",
|
|
8196
|
+
"criminal-activity-preparation",
|
|
8197
|
+
"criminal-activity-request",
|
|
8198
|
+
"criminal-activity-instructions",
|
|
8199
|
+
"criminal-activity-promotion",
|
|
8200
|
+
"criminal-activity-coded-incitement",
|
|
8201
|
+
"criminal-activity-substances",
|
|
8202
|
+
"criminal-activity-grooming",
|
|
8203
|
+
"criminal-activity-csam",
|
|
8204
|
+
"criminal-activity-alcohol",
|
|
8205
|
+
"criminal-activity-tobacco",
|
|
8206
|
+
"criminal-activity-gambling",
|
|
8207
|
+
"criminal-activity-fraud",
|
|
8208
|
+
"criminal-activity-sexual-solicitation",
|
|
8209
|
+
"criminal-activity-graffiti",
|
|
8210
|
+
"criminal-activity-historical",
|
|
8211
|
+
"criminal-activity-documentary",
|
|
8212
|
+
"criminal-activity-educational",
|
|
8213
|
+
"criminal-activity-discussion",
|
|
8214
|
+
"criminal-activity-fictional",
|
|
8215
|
+
"criminal-activity-support-prevention"
|
|
8216
|
+
],
|
|
8217
|
+
"personal-information": [
|
|
8218
|
+
"personal-information-contact",
|
|
8219
|
+
"personal-information-email",
|
|
8220
|
+
"personal-information-phone",
|
|
8221
|
+
"personal-information-government-id",
|
|
8222
|
+
"personal-information-financial",
|
|
8223
|
+
"personal-information-location",
|
|
8224
|
+
"personal-information-identifiers",
|
|
8225
|
+
"personal-information-credentials",
|
|
8226
|
+
"personal-information-request",
|
|
8227
|
+
"personal-information-self-disclosure",
|
|
8228
|
+
"personal-information-third-party",
|
|
8229
|
+
"personal-information-historical",
|
|
8230
|
+
"personal-information-documentary",
|
|
8231
|
+
"personal-information-educational",
|
|
8232
|
+
"personal-information-fictional",
|
|
8233
|
+
"personal-information-support-prevention"
|
|
8234
|
+
],
|
|
8235
|
+
"profanity": [
|
|
8236
|
+
"profanity-mild",
|
|
8237
|
+
"profanity-strong",
|
|
8238
|
+
"profanity-slurs",
|
|
8239
|
+
"profanity-obfuscated",
|
|
8240
|
+
"profanity-historical",
|
|
8241
|
+
"profanity-documentary",
|
|
8242
|
+
"profanity-educational",
|
|
8243
|
+
"profanity-fictional",
|
|
8244
|
+
"profanity-support-prevention"
|
|
8245
|
+
]
|
|
8317
8246
|
};
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8247
|
+
// src/testing/MockDecision.ts
|
|
8248
|
+
const OUTCOMES = [
|
|
8249
|
+
"pending",
|
|
8250
|
+
"failed",
|
|
8251
|
+
"allowed",
|
|
8252
|
+
"limited",
|
|
8253
|
+
"age-restricted",
|
|
8254
|
+
"blocked",
|
|
8255
|
+
"purged"
|
|
8256
|
+
];
|
|
8257
|
+
/** The policy action that gives each decided status. */
|
|
8258
|
+
const STATUS_ACTION = {
|
|
8259
|
+
allowed: "allow",
|
|
8260
|
+
limited: "limit",
|
|
8261
|
+
"age-restricted": "age-restrict",
|
|
8262
|
+
blocked: "block",
|
|
8263
|
+
purged: "purge"
|
|
8264
|
+
};
|
|
8265
|
+
/** The likelihood the API reports for a score (ClassificationCategories.ts, scoreToLikelihood). */
|
|
8266
|
+
const scoreToLikelihood = (score) => score >= .8 ? "very-likely" : score >= .6 ? "likely" : score >= .4 ? "possible" : score >= .2 ? "unlikely" : "very-unlikely";
|
|
8267
|
+
const isScore = (score) => typeof score === "number" && Number.isFinite(score) && score >= 0 && score <= 1;
|
|
8268
|
+
/**
|
|
8269
|
+
* A moderation decision for `mockOptions.moderate` or `completeProcessing`, built with
|
|
8270
|
+
* `mockDecision(status)`: flag the categories it found, with their scores and qualifiers, and
|
|
8271
|
+
* mark the categories it couldn't assess. Each call checks its names and scores and throws
|
|
8272
|
+
* `sdk/invalid-argument` for one CalmLens doesn't have.
|
|
8273
|
+
*/
|
|
8274
|
+
var MockDecision = class {
|
|
8275
|
+
#flags = /* @__PURE__ */ new Map();
|
|
8276
|
+
#notEvaluated = /* @__PURE__ */ new Set();
|
|
8277
|
+
constructor(status) {
|
|
8278
|
+
if (!OUTCOMES.includes(status)) throw invalidArgument(`status must be one of ${OUTCOMES.join(", ")}`);
|
|
8279
|
+
this.status = status;
|
|
8343
8280
|
}
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
8281
|
+
/**
|
|
8282
|
+
* Records findings in `category` with its `score` (0 to 1) and, optionally, scores for its
|
|
8283
|
+
* qualifiers, such as `{ "violence-threat": 0.9 }`. The policy acts on a category at 0.5 and
|
|
8284
|
+
* above, so `flaggedCategories` lists it there unless the status is `allowed`.
|
|
8285
|
+
*/
|
|
8286
|
+
flag(category, score, qualifiers = {}) {
|
|
8287
|
+
this.#checkCategory(category);
|
|
8288
|
+
if (this.status === "pending" || this.status === "failed") throw invalidArgument(`a ${this.status} decision has no findings`);
|
|
8289
|
+
if (this.#flags.has(category) || this.#notEvaluated.has(category)) throw invalidArgument(`${category} is already in this decision`);
|
|
8290
|
+
if (!isScore(score)) throw invalidArgument(`${category}'s score must be from 0 to 1`);
|
|
8291
|
+
const known = MOCK_QUALIFIERS[category];
|
|
8292
|
+
for (const [qualifier, qualifierScore] of Object.entries(qualifiers)) {
|
|
8293
|
+
if (!known.includes(qualifier)) throw invalidArgument(`${qualifier} is not a qualifier of ${category}`);
|
|
8294
|
+
if (!isScore(qualifierScore)) throw invalidArgument(`${qualifier}'s score must be from 0 to 1`);
|
|
8295
|
+
}
|
|
8296
|
+
this.#flags.set(category, {
|
|
8297
|
+
score,
|
|
8298
|
+
qualifiers: Object.entries(qualifiers)
|
|
8299
|
+
});
|
|
8300
|
+
return this;
|
|
8347
8301
|
}
|
|
8348
|
-
|
|
8349
|
-
|
|
8302
|
+
/** Marks `category` as not assessed (`not-evaluated` for reason `not-supported`). */
|
|
8303
|
+
notEvaluated(category) {
|
|
8304
|
+
this.#checkCategory(category);
|
|
8305
|
+
if (this.status === "pending" || this.status === "failed") throw invalidArgument(`a ${this.status} decision has no findings`);
|
|
8306
|
+
if (this.#flags.has(category) || this.#notEvaluated.has(category)) throw invalidArgument(`${category} is already in this decision`);
|
|
8307
|
+
this.#notEvaluated.add(category);
|
|
8308
|
+
return this;
|
|
8350
8309
|
}
|
|
8351
|
-
|
|
8352
|
-
|
|
8310
|
+
#checkCategory(category) {
|
|
8311
|
+
if (!MOCK_CATEGORIES.includes(category)) throw invalidArgument(`${category} is not a CalmLens category`);
|
|
8353
8312
|
}
|
|
8354
|
-
|
|
8355
|
-
|
|
8313
|
+
/**
|
|
8314
|
+
* The report a decided asset gets, as the API sends it: every category (unflagged ones
|
|
8315
|
+
* evaluated with score 0), policy-adjusted results and the automated action and
|
|
8316
|
+
* `flaggedCategories`. `undefined` for `pending` and `failed`.
|
|
8317
|
+
*/
|
|
8318
|
+
report() {
|
|
8319
|
+
const status = this.status;
|
|
8320
|
+
if (status === "pending" || status === "failed") return void 0;
|
|
8321
|
+
const action = (score) => status !== "allowed" && score >= .5 ? STATUS_ACTION[status] : "allow";
|
|
8322
|
+
const evaluated = MOCK_CATEGORIES.filter((category) => !this.#notEvaluated.has(category)).map((category) => [category, this.#flags.get(category)?.score ?? 0]);
|
|
8323
|
+
const categories = Object.fromEntries(MOCK_CATEGORIES.map((category) => {
|
|
8324
|
+
if (this.#notEvaluated.has(category)) return [category, {
|
|
8325
|
+
status: "not-evaluated",
|
|
8326
|
+
reason: "not-supported"
|
|
8327
|
+
}];
|
|
8328
|
+
const flag = this.#flags.get(category);
|
|
8329
|
+
const score = flag?.score ?? 0;
|
|
8330
|
+
return [category, {
|
|
8331
|
+
status: "evaluated",
|
|
8332
|
+
likelihood: scoreToLikelihood(score),
|
|
8333
|
+
score,
|
|
8334
|
+
...flag?.qualifiers.length && { qualifiers: Object.fromEntries(flag.qualifiers.map(([qualifier, someScore]) => [qualifier, {
|
|
8335
|
+
status: "evaluated",
|
|
8336
|
+
someScore,
|
|
8337
|
+
likelihood: scoreToLikelihood(someScore)
|
|
8338
|
+
}])) }
|
|
8339
|
+
}];
|
|
8340
|
+
}));
|
|
8341
|
+
return {
|
|
8342
|
+
categories: Object.fromEntries(evaluated.map(([category, score]) => [category, {
|
|
8343
|
+
score,
|
|
8344
|
+
action: action(score)
|
|
8345
|
+
}])),
|
|
8346
|
+
explanationComplete: true,
|
|
8347
|
+
source: "policy",
|
|
8348
|
+
automatedAction: STATUS_ACTION[status],
|
|
8349
|
+
regions: [],
|
|
8350
|
+
flaggedCategories: evaluated.filter(([, score]) => action(score) !== "allow").map(([category]) => category),
|
|
8351
|
+
raw: { categories }
|
|
8352
|
+
};
|
|
8356
8353
|
}
|
|
8357
8354
|
};
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
const createPage = (items, query) => {
|
|
8382
|
-
const pageSize = query.pageSize ?? 10;
|
|
8383
|
-
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) throw invalidArgument("Invalid page size");
|
|
8384
|
-
const ordering = query.ordering?.length ? [...query.ordering] : [{
|
|
8385
|
-
field: "createdAt",
|
|
8386
|
-
direction: "DESC"
|
|
8387
|
-
}];
|
|
8388
|
-
if (ordering.some(({ field }) => field !== "createdAt" && field !== "id")) throw invalidArgument("Invalid ordering");
|
|
8355
|
+
/** Starts a moderation decision with this status; see `MockDecision`. */
|
|
8356
|
+
const mockDecision = (status) => new MockDecision(status);
|
|
8357
|
+
/** The default `moderate`: queued work stays pending until `completeProcessing`, which allows it. */
|
|
8358
|
+
const defaultModerate = ({ trigger, queued }) => queued && trigger !== "complete" ? "pending" : "allowed";
|
|
8359
|
+
// src/testing/MockUtils.ts
|
|
8360
|
+
const toBase64Utf8 = (value) => btoa(Array.from(new TextEncoder().encode(value), (byte) => String.fromCharCode(byte)).join(""));
|
|
8361
|
+
const fromBase64Utf8 = (value) => new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0)));
|
|
8362
|
+
/**
|
|
8363
|
+
* One keyset page like the API's (packages/shared-backend/src/CursorPagination.ts): ordered by
|
|
8364
|
+
* `ordering` (newest first by default) with an `id` tie-breaker added, starting after the
|
|
8365
|
+
* cursor's values. The cursor is tied to `scope` (the filter, folder or parent) and the
|
|
8366
|
+
* ordering, so it only continues the query it came from. `name` and `id` are compared by code
|
|
8367
|
+
* point, like the database.
|
|
8368
|
+
*/
|
|
8369
|
+
const createPage = (items, query, { scope, fail, defaultPageSize = 50, defaultOrdering = [{
|
|
8370
|
+
field: "createdAt",
|
|
8371
|
+
direction: "desc"
|
|
8372
|
+
}] }) => {
|
|
8373
|
+
const pageSize = query.pageSize ?? defaultPageSize;
|
|
8374
|
+
if (typeof pageSize !== "number" || !Number.isFinite(pageSize) || pageSize < 1) throw fail("validation/too-small", "pageSize");
|
|
8375
|
+
if (pageSize > 100) throw fail("validation/too-big", "pageSize");
|
|
8376
|
+
if (!Number.isInteger(pageSize)) throw invalidArgument("Invalid page size");
|
|
8377
|
+
const ordering = query.ordering?.length ? [...query.ordering] : [...defaultOrdering];
|
|
8389
8378
|
if (!ordering.some(({ field }) => field === "id")) ordering.push({
|
|
8390
8379
|
field: "id",
|
|
8391
8380
|
direction: ordering[0].direction
|
|
8392
8381
|
});
|
|
8382
|
+
const signature = JSON.stringify([scope, ordering]);
|
|
8393
8383
|
let remaining = orderItems(items, ordering);
|
|
8394
8384
|
if (query.cursor) {
|
|
8395
8385
|
let values;
|
|
8396
8386
|
try {
|
|
8397
|
-
|
|
8398
|
-
|
|
8387
|
+
const parsed = JSON.parse(fromBase64Utf8(query.cursor));
|
|
8388
|
+
values = parsed?.values;
|
|
8389
|
+
if (parsed?.scope !== signature || !Array.isArray(values) || values.length !== ordering.length || values.some((value, i) => STRING_FIELDS.has(ordering[i].field) ? typeof value !== "string" : typeof value !== "number" || !Number.isFinite(value))) throw new Error();
|
|
8399
8390
|
} catch {
|
|
8400
|
-
throw
|
|
8391
|
+
throw fail("validation/invalid-cursor");
|
|
8401
8392
|
}
|
|
8402
8393
|
remaining = remaining.filter((item) => {
|
|
8403
8394
|
for (const [i, rule] of ordering.entries()) {
|
|
8404
8395
|
const result = compareValues(item[rule.field], values[i]);
|
|
8405
|
-
if (result) return rule.direction === "
|
|
8396
|
+
if (result) return rule.direction === "desc" ? result < 0 : result > 0;
|
|
8406
8397
|
}
|
|
8407
8398
|
return false;
|
|
8408
8399
|
});
|
|
@@ -8415,26 +8406,31 @@ const createPage = (items, query) => {
|
|
|
8415
8406
|
pageSize,
|
|
8416
8407
|
hasMore,
|
|
8417
8408
|
ordering,
|
|
8418
|
-
nextCursor: hasMore && last ?
|
|
8409
|
+
nextCursor: hasMore && last ? toBase64Utf8(JSON.stringify({
|
|
8410
|
+
scope: signature,
|
|
8411
|
+
values: ordering.map(({ field }) => last[field])
|
|
8412
|
+
})) : null
|
|
8419
8413
|
};
|
|
8420
8414
|
};
|
|
8415
|
+
const STRING_FIELDS = /* @__PURE__ */ new Set(["id", "name"]);
|
|
8421
8416
|
const compareValues = (left, right) => {
|
|
8422
8417
|
if (left === right) return 0;
|
|
8423
8418
|
if (left === void 0 || left === null) return -1;
|
|
8424
8419
|
if (right === void 0 || right === null) return 1;
|
|
8425
8420
|
if (typeof left === "number" && typeof right === "number") return left - right;
|
|
8426
|
-
|
|
8421
|
+
const [a, b] = [String(left), String(right)];
|
|
8422
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
8427
8423
|
};
|
|
8428
8424
|
/** Sorts like the API's `ordering`; without one, newest first. */
|
|
8429
8425
|
const orderItems = (items, ordering) => {
|
|
8430
8426
|
const rules = ordering?.length ? ordering : [{
|
|
8431
8427
|
field: "createdAt",
|
|
8432
|
-
direction: "
|
|
8428
|
+
direction: "desc"
|
|
8433
8429
|
}];
|
|
8434
8430
|
return [...items].sort((a, b) => {
|
|
8435
8431
|
for (const rule of rules) {
|
|
8436
8432
|
const result = compareValues(a[rule.field], b[rule.field]);
|
|
8437
|
-
if (result !== 0) return rule.direction === "
|
|
8433
|
+
if (result !== 0) return rule.direction === "desc" ? -result : result;
|
|
8438
8434
|
}
|
|
8439
8435
|
return 0;
|
|
8440
8436
|
});
|
|
@@ -8450,7 +8446,7 @@ const parseFilterValue = (raw) => {
|
|
|
8450
8446
|
return value;
|
|
8451
8447
|
};
|
|
8452
8448
|
const readField = (item, path) => path.split(".").reduce((value, key) => value && typeof value === "object" ? value[key] : void 0, item);
|
|
8453
|
-
const unsupportedFilter = (expression) => invalidArgument(`MockCalmLensServerClient cannot evaluate the filter "${expression}". It supports ==, !=, <, <=, >, >=, =in= and =out=, joined by ; (and) or , (or)
|
|
8449
|
+
const unsupportedFilter = (expression) => invalidArgument(`MockCalmLensServerClient cannot evaluate the filter "${expression}". It supports ==, !=, <, <=, >, >=, =in= and =out=, joined by ; (and) or , (or) and grouped with parentheses.`);
|
|
8454
8450
|
const matchesComparison = (item, comparison) => {
|
|
8455
8451
|
const match = COMPARISON.exec(comparison.trim());
|
|
8456
8452
|
if (!match) throw unsupportedFilter(comparison);
|
|
@@ -8474,7 +8470,7 @@ const matchesComparison = (item, comparison) => {
|
|
|
8474
8470
|
default: return compareValues(actual, values[0]) <= 0;
|
|
8475
8471
|
}
|
|
8476
8472
|
};
|
|
8477
|
-
/** Splits on `separator` outside quotes and parentheses
|
|
8473
|
+
/** Splits on `separator` outside quotes and parentheses. */
|
|
8478
8474
|
const splitTopLevel = (text, separator) => {
|
|
8479
8475
|
const parts = [];
|
|
8480
8476
|
let depth = 0;
|
|
@@ -8496,757 +8492,1969 @@ const splitTopLevel = (text, separator) => {
|
|
|
8496
8492
|
parts.push(current);
|
|
8497
8493
|
return parts;
|
|
8498
8494
|
};
|
|
8495
|
+
/** The expression inside `( … )` when the parentheses enclose all of `text`, else undefined. */
|
|
8496
|
+
const unwrapGroup = (text) => {
|
|
8497
|
+
if (!text.startsWith("(") || !text.endsWith(")")) return void 0;
|
|
8498
|
+
let depth = 0;
|
|
8499
|
+
let quote = null;
|
|
8500
|
+
for (const [index, char] of [...text].entries()) if (quote) {
|
|
8501
|
+
if (char === quote) quote = null;
|
|
8502
|
+
} else if (char === "\"" || char === "'") quote = char;
|
|
8503
|
+
else if (char === "(") depth++;
|
|
8504
|
+
else if (char === ")" && --depth === 0) return index === text.length - 1 ? text.slice(1, -1) : void 0;
|
|
8505
|
+
};
|
|
8506
|
+
/** Evaluates `,` (or) of `;` (and) terms, where a term is a comparison or a `( … )` group. */
|
|
8507
|
+
const matchesExpression = (item, expression) => splitTopLevel(expression, ",").some((alternative) => splitTopLevel(alternative, ";").every((term) => {
|
|
8508
|
+
const trimmed = term.trim();
|
|
8509
|
+
if (!trimmed) throw unsupportedFilter(expression);
|
|
8510
|
+
const group = unwrapGroup(trimmed);
|
|
8511
|
+
return group === void 0 ? matchesComparison(item, trimmed) : matchesExpression(item, group);
|
|
8512
|
+
}));
|
|
8499
8513
|
/**
|
|
8500
|
-
* Applies an RSQL `filter` like the API
|
|
8501
|
-
* (
|
|
8514
|
+
* Applies an RSQL `filter` like the API: comparisons joined by `;` (and), which binds tighter
|
|
8515
|
+
* than `,` (or), and grouped with parentheses. Anything it can't evaluate throws instead of
|
|
8516
|
+
* returning wrong results.
|
|
8502
8517
|
*/
|
|
8503
8518
|
const filterAssets = (items, filter) => {
|
|
8504
8519
|
if (!filter) return items;
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
return items.filter((item) => alternatives.some((conditions) => conditions.every((condition) => matchesComparison(item, condition))));
|
|
8520
|
+
if (items.length === 0) matchesExpression({}, filter);
|
|
8521
|
+
return items.filter((item) => matchesExpression(item, filter));
|
|
8508
8522
|
};
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
const
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8523
|
+
const MAX_SEARCH_TOKENS = 8;
|
|
8524
|
+
/** The keyword fields the API searches, in the order it reports matches. */
|
|
8525
|
+
const searchFieldValues = (asset) => [
|
|
8526
|
+
["name", asset.name],
|
|
8527
|
+
["id", asset.id],
|
|
8528
|
+
["externalId", asset.externalId ?? ""],
|
|
8529
|
+
["description", asset.description ?? ""],
|
|
8530
|
+
["tags", asset.tags?.join(" ") ?? ""],
|
|
8531
|
+
["type", asset.type],
|
|
8532
|
+
["content.format", asset.content?.format ?? ""],
|
|
8533
|
+
["content.extension", asset.content?.extension ?? ""],
|
|
8534
|
+
["classification.moderation.status", asset.classification.moderation.status]
|
|
8535
|
+
];
|
|
8536
|
+
/** The API's keyword score for the whole lowercased query. */
|
|
8537
|
+
const keywordScore = (values, query) => {
|
|
8538
|
+
const id = values.get("id");
|
|
8539
|
+
const externalId = values.get("externalId");
|
|
8540
|
+
const name = values.get("name");
|
|
8541
|
+
if (id === query) return 1e3;
|
|
8542
|
+
if (externalId === query) return 950;
|
|
8543
|
+
if (name === query) return 900;
|
|
8544
|
+
if (id.startsWith(query)) return 800;
|
|
8545
|
+
if (externalId.startsWith(query)) return 750;
|
|
8546
|
+
if (name.startsWith(query)) return 700;
|
|
8547
|
+
if (name.includes(query)) return 600;
|
|
8548
|
+
if (values.get("tags").includes(query)) return 500;
|
|
8549
|
+
return 100;
|
|
8518
8550
|
};
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8551
|
+
/**
|
|
8552
|
+
* Keyword search as the API runs it: every whitespace-separated token (case-insensitive, at
|
|
8553
|
+
* most 8) must appear in some searchable field. Results are ranked by the API's score, then
|
|
8554
|
+
* newest first.
|
|
8555
|
+
*/
|
|
8556
|
+
const searchAssets = (assets, query, limit) => {
|
|
8557
|
+
const normalized = query.trim().toLowerCase();
|
|
8558
|
+
const tokens = [...new Set(normalized.split(/\s+/).filter(Boolean))].slice(0, MAX_SEARCH_TOKENS);
|
|
8559
|
+
if (tokens.length === 0) return [];
|
|
8560
|
+
return assets.map((asset) => {
|
|
8561
|
+
const fields = searchFieldValues(asset).map(([field, value]) => [field, value.toLowerCase()]);
|
|
8562
|
+
return {
|
|
8563
|
+
asset,
|
|
8564
|
+
matches: tokens.every((token) => fields.some(([, value]) => value.includes(token))),
|
|
8565
|
+
score: keywordScore(new Map(fields), normalized),
|
|
8566
|
+
matchedFields: fields.filter(([, value]) => tokens.some((token) => value.includes(token))).map(([field]) => field)
|
|
8567
|
+
};
|
|
8568
|
+
}).filter((result) => result.matches).sort((a, b) => b.score - a.score || b.asset.createdAt - a.asset.createdAt).slice(0, limit).map(({ asset, score, matchedFields }) => ({
|
|
8569
|
+
asset,
|
|
8570
|
+
score,
|
|
8571
|
+
matchedFields
|
|
8572
|
+
}));
|
|
8573
|
+
};
|
|
8574
|
+
// src/testing/MockCalmLensApi.ts
|
|
8575
|
+
const REVIEW_DECISIONS = [
|
|
8576
|
+
"allowed",
|
|
8577
|
+
"limited",
|
|
8578
|
+
"age-restricted",
|
|
8579
|
+
"blocked",
|
|
8580
|
+
"purged"
|
|
8581
|
+
];
|
|
8582
|
+
const MAX_PAGE_SIZE = 100;
|
|
8583
|
+
const MAX_DECISION_NOTE_LENGTH = 1499;
|
|
8584
|
+
/** How the API names a reviewer acting through an unnamed API key. */
|
|
8585
|
+
const API_KEY_REVIEWER_NAME = "An API key";
|
|
8586
|
+
const MAX_SEARCH_LIMIT = 20;
|
|
8587
|
+
const MAX_SEARCH_QUERY_LENGTH = 200;
|
|
8588
|
+
/**
|
|
8589
|
+
* The ThumbHash the mock gives every classified image, standing in for one computed from its
|
|
8590
|
+
* pixels. The `placeholder` resource serves it as a PNG, like the CDN.
|
|
8591
|
+
*/
|
|
8592
|
+
const MOCK_THUMBHASH = "1QcSHQRnh493V4dIh4eXh1h4kJUI";
|
|
8593
|
+
/** Like the API: 1 to 255 printable ASCII characters, once HTTP has trimmed the header value. */
|
|
8594
|
+
const isValidIdempotencyKey = (key) => key.length > 0 && key.length <= 255 && /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(key);
|
|
8595
|
+
/** Like the CDN, content a browser could run as a document is served as a download. */
|
|
8596
|
+
const isActiveContentType = (type) => {
|
|
8597
|
+
const normalized = type?.toLowerCase().split(";", 1)[0]?.trim();
|
|
8598
|
+
return Boolean(normalized && ([
|
|
8599
|
+
"image/svg+xml",
|
|
8600
|
+
"application/xhtml+xml",
|
|
8601
|
+
"text/html",
|
|
8602
|
+
"application/xml",
|
|
8603
|
+
"text/xml"
|
|
8604
|
+
].includes(normalized) || normalized.endsWith("+xml")));
|
|
8605
|
+
};
|
|
8606
|
+
/** The asset snapshot events carry (getEventAsset in the API). */
|
|
8607
|
+
const toEventAsset = (asset) => ({
|
|
8608
|
+
id: asset.id,
|
|
8609
|
+
externalId: asset.externalId ?? null,
|
|
8610
|
+
uploadPresetId: asset.uploadPresetId ?? null,
|
|
8611
|
+
upload: { status: asset.upload.status },
|
|
8612
|
+
classification: {
|
|
8613
|
+
status: asset.classification.status,
|
|
8614
|
+
moderation: { status: asset.classification.moderation.status }
|
|
8615
|
+
},
|
|
8616
|
+
content: { status: asset.content.status }
|
|
8617
|
+
});
|
|
8618
|
+
/** A `moderate` answer or `completeProcessing` decision as a checked `MockDecision`. */
|
|
8619
|
+
const toDecision = (value) => {
|
|
8620
|
+
if (value instanceof MockDecision) return value;
|
|
8621
|
+
if (typeof value === "string") return new MockDecision(value);
|
|
8622
|
+
throw invalidArgument("a decision must be a status or a mockDecision(...)");
|
|
8623
|
+
};
|
|
8624
|
+
/** An asset's portal link, as event facts carry it. */
|
|
8625
|
+
const assetNameFact = (asset) => ({
|
|
8626
|
+
key: "asset-name",
|
|
8627
|
+
value: asset.name,
|
|
8628
|
+
url: `https://calmlens.com/go/asset/${asset.id}`
|
|
8629
|
+
});
|
|
8630
|
+
/** Removing content removes the placeholder and average colour with it. */
|
|
8631
|
+
const withoutPlaceholder = (image) => image ? {
|
|
8632
|
+
...image,
|
|
8633
|
+
thumbhash: null,
|
|
8634
|
+
averageColor: null
|
|
8635
|
+
} : image;
|
|
8636
|
+
/** An asset's type from its content type, or `website` for a URL. */
|
|
8637
|
+
const inferAssetType = (contentType) => {
|
|
8638
|
+
if (contentType === void 0) return "website";
|
|
8639
|
+
const type = contentType.toLowerCase();
|
|
8640
|
+
if (type.startsWith("image/")) return "image";
|
|
8641
|
+
if (type.startsWith("video/")) return "video";
|
|
8642
|
+
if (type.startsWith("audio/")) return "audio";
|
|
8643
|
+
if (type.startsWith("text/")) return "plaintext-document";
|
|
8644
|
+
if (type.includes("pdf") || type.includes("document")) return "multimedia-document";
|
|
8645
|
+
return "file";
|
|
8646
|
+
};
|
|
8647
|
+
/** The API's answer to a request it refuses: thrown by handlers, sent as a JSON error body. */
|
|
8648
|
+
var ApiFailure = class {
|
|
8649
|
+
constructor(status, code, message, details) {
|
|
8650
|
+
this.status = status;
|
|
8651
|
+
this.code = code;
|
|
8652
|
+
this.message = message;
|
|
8653
|
+
this.details = details;
|
|
8654
|
+
}
|
|
8655
|
+
};
|
|
8656
|
+
const fail = (code, { message, details } = {}) => new ApiFailure(API_ERROR_DEFINITIONS[code].status, code, message ?? API_ERROR_DEFINITIONS[code].message, details);
|
|
8657
|
+
/** A request the API's schema refuses: `validation/<zod issue>` with HTTP 400 and the field. */
|
|
8658
|
+
const invalidField = (code, path) => new ApiFailure(400, code, `Invalid ${path}`, { path });
|
|
8659
|
+
/** The client methods that send a request, the one it is recorded under first. */
|
|
8660
|
+
const operationsOf = (request) => {
|
|
8661
|
+
const { method, segments, query, cdn } = request;
|
|
8662
|
+
if (cdn) return ["cdn.serve"];
|
|
8663
|
+
const [root] = segments;
|
|
8664
|
+
if (root === "projects") {
|
|
8665
|
+
switch (segments.slice(2).join("/")) {
|
|
8666
|
+
case "events": return ["events.list", "events.pages"];
|
|
8667
|
+
case "lenses": return ["ensureLenses", "ensureLens"];
|
|
8668
|
+
case "folders": return method === "GET" ? ["folders.list"] : ["folders.create"];
|
|
8669
|
+
case "hash-lists": return method === "GET" ? ["hashLists.list"] : ["hashLists.create"];
|
|
8670
|
+
case "assets/search": return ["assets.search"];
|
|
8671
|
+
case "assets/batch": return ["assets.batch"];
|
|
8672
|
+
case "assets":
|
|
8673
|
+
if (method === "GET") return ["assets.list", "assets.iterate"];
|
|
8674
|
+
if (query.get("queue") === "true") return ["assets.create"];
|
|
8675
|
+
return isTextUpload(request) ? ["assets.classify", "text.classify"] : ["assets.classify"];
|
|
8676
|
+
}
|
|
8677
|
+
return [];
|
|
8678
|
+
}
|
|
8679
|
+
if (root === "upload-presets") {
|
|
8680
|
+
if (segments[3] === void 0) return [query.get("queue") === "true" ? "assets.create" : "assets.classify"];
|
|
8681
|
+
return segments[4] === "part" ? ["assets.uploadPart"] : ["assets.retrieve"];
|
|
8682
|
+
}
|
|
8683
|
+
const id = segments[1];
|
|
8684
|
+
const action = segments[2];
|
|
8685
|
+
if (root === "folders" && id !== void 0) {
|
|
8686
|
+
if (action === "move") return ["folders.move"];
|
|
8687
|
+
return method === "GET" ? ["folders.retrieve"] : method === "PATCH" ? ["folders.update"] : ["folders.delete"];
|
|
8688
|
+
}
|
|
8689
|
+
if (root === "hash-lists" && id !== void 0) {
|
|
8690
|
+
if (action === "entries") return method === "GET" ? ["hashLists.listEntries"] : ["hashLists.addEntry"];
|
|
8691
|
+
return method === "GET" ? ["hashLists.retrieve"] : method === "PATCH" ? ["hashLists.update"] : ["hashLists.delete"];
|
|
8692
|
+
}
|
|
8693
|
+
if (root === "hash-list-entries" && id !== void 0) return ["hashLists.removeEntry"];
|
|
8694
|
+
if (root !== "assets" || id === void 0) return [];
|
|
8695
|
+
switch (action) {
|
|
8696
|
+
case void 0: return method === "GET" ? ["assets.retrieve", "assets.waitForDecision"] : method === "PATCH" ? ["assets.update"] : ["assets.delete"];
|
|
8697
|
+
case "content": return ["assets.deleteContent"];
|
|
8698
|
+
case "children": return ["assets.listChildren", "assets.iterateChildren"];
|
|
8699
|
+
case "signed-url": return ["assets.createSignedUrl"];
|
|
8700
|
+
case "part": return ["assets.uploadPart"];
|
|
8701
|
+
case "move": return ["assets.move"];
|
|
8702
|
+
case "retry": return ["assets.retry"];
|
|
8703
|
+
case "explain": return ["assets.explain"];
|
|
8704
|
+
case "decision": return ["assets.decide"];
|
|
8705
|
+
}
|
|
8706
|
+
return [];
|
|
8707
|
+
};
|
|
8708
|
+
const isTextUpload = (request) => request.headers.get("x-calmlens-upload") === "content" && request.headers.get("content-type") === "text/plain";
|
|
8709
|
+
/** Like the API: a new folder is 201 Created, a queued batch 202 Accepted, everything else 200. */
|
|
8710
|
+
const SUCCESS_STATUS = {
|
|
8711
|
+
"folders.create": 201,
|
|
8712
|
+
"hashLists.create": 201,
|
|
8713
|
+
"assets.batch": 202
|
|
8714
|
+
};
|
|
8715
|
+
const decodeSegment = (segment) => {
|
|
8716
|
+
try {
|
|
8717
|
+
return decodeURIComponent(segment);
|
|
8718
|
+
} catch {
|
|
8719
|
+
return segment;
|
|
8532
8720
|
}
|
|
8533
|
-
|
|
8721
|
+
};
|
|
8722
|
+
const createMonotonicClock = () => {
|
|
8723
|
+
let last = 0;
|
|
8724
|
+
return () => {
|
|
8725
|
+
last = Math.max(Date.now(), last + 1);
|
|
8726
|
+
return last;
|
|
8727
|
+
};
|
|
8534
8728
|
};
|
|
8535
8729
|
/**
|
|
8536
|
-
*
|
|
8537
|
-
*
|
|
8538
|
-
* client's, so error handling can be tested without a network.
|
|
8730
|
+
* The in-memory CalmLens API and CDN one or more mock clients share: storage, events, lenses,
|
|
8731
|
+
* idempotency keys, upload presets, the API key's grants, queued failures and recorded requests.
|
|
8539
8732
|
*/
|
|
8540
|
-
var
|
|
8541
|
-
/** Accepts the same options as `CalmLensServerClient` (validated the same way), plus `mockOptions`. */
|
|
8733
|
+
var MockCalmLensApi = class {
|
|
8542
8734
|
constructor(options) {
|
|
8735
|
+
this.calls = [];
|
|
8543
8736
|
this.lenses = /* @__PURE__ */ new Map();
|
|
8544
8737
|
this.eventFeed = [];
|
|
8545
|
-
this.
|
|
8738
|
+
this.openUploads = /* @__PURE__ */ new Map();
|
|
8546
8739
|
this.idempotentResults = /* @__PURE__ */ new Map();
|
|
8547
|
-
this.requestCount = 0;
|
|
8548
8740
|
this.pendingFailures = [];
|
|
8549
|
-
this.
|
|
8550
|
-
|
|
8741
|
+
this.requestCount = 0;
|
|
8742
|
+
this.waitedMs = 0;
|
|
8743
|
+
this.hashLists = /* @__PURE__ */ new Map();
|
|
8744
|
+
this.hashListEntries = /* @__PURE__ */ new Map();
|
|
8745
|
+
this.pageFailure = (code, path) => code === "validation/invalid-cursor" ? fail(API_ERROR_CODES.INVALID_CURSOR) : invalidField(code, path ?? "pageSize");
|
|
8746
|
+
this.options = options;
|
|
8551
8747
|
this.projectId = options.projectId;
|
|
8552
8748
|
this.apiKey = options.apiKey;
|
|
8553
|
-
this.
|
|
8554
|
-
this.
|
|
8555
|
-
this.
|
|
8556
|
-
this.
|
|
8557
|
-
this.
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
}
|
|
8563
|
-
/** Keeps the API key out of `console.log` and `util.inspect`. */
|
|
8564
|
-
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
8565
|
-
return `MockCalmLensServerClient { projectId: ${JSON.stringify(this.projectId)} }`;
|
|
8566
|
-
}
|
|
8567
|
-
/**
|
|
8568
|
-
* Makes the next call (or the next call to `operation`) throw `failure` instead of running,
|
|
8569
|
-
* as if the request never completed. Queue several to fail several calls in a row.
|
|
8570
|
-
*/
|
|
8571
|
-
failNextRequest(failure = "transport-error", { operation } = {}) {
|
|
8749
|
+
this.grants = options.grants;
|
|
8750
|
+
this.databaseStorage = options.databaseStorage;
|
|
8751
|
+
this.imageStorage = options.imageStorage;
|
|
8752
|
+
this.createId = options.createId;
|
|
8753
|
+
this.now = options.now;
|
|
8754
|
+
}
|
|
8755
|
+
/** Queues a failure for the next request, or the next one `operation` sends. */
|
|
8756
|
+
failNextRequest(failure, options = {}) {
|
|
8757
|
+
if (options.retryAfter !== void 0 && !(Number.isInteger(options.retryAfter) && options.retryAfter >= 0)) throw invalidArgument("retryAfter must be a whole number of seconds");
|
|
8572
8758
|
this.pendingFailures.push({
|
|
8573
8759
|
failure,
|
|
8574
|
-
|
|
8760
|
+
...options
|
|
8575
8761
|
});
|
|
8576
8762
|
}
|
|
8577
|
-
/**
|
|
8578
|
-
|
|
8579
|
-
return
|
|
8763
|
+
/** A `fetch` that answers a client's requests from memory; app clients pass their session. */
|
|
8764
|
+
createFetch(endpoints, session) {
|
|
8765
|
+
return ((input, init) => this.handle(input, init, endpoints(), session));
|
|
8580
8766
|
}
|
|
8581
8767
|
/**
|
|
8582
|
-
* Adds an event to the feed `events.list` reads, as if CalmLens had recorded it
|
|
8583
|
-
* stored event
|
|
8768
|
+
* Adds an event to the feed `events.list` reads, as if CalmLens had recorded it, and returns
|
|
8769
|
+
* the stored event.
|
|
8584
8770
|
*/
|
|
8585
|
-
enqueueEvent(input) {
|
|
8771
|
+
async enqueueEvent(input) {
|
|
8772
|
+
const asset = input.asset === void 0 && input.assetId ? await this.databaseStorage.getAsset(input.assetId) : void 0;
|
|
8773
|
+
return this.pushEvent(asset ? {
|
|
8774
|
+
...input,
|
|
8775
|
+
asset: toEventAsset(asset)
|
|
8776
|
+
} : input);
|
|
8777
|
+
}
|
|
8778
|
+
/** Adds an event to the feed, filling the fields the API always sets. */
|
|
8779
|
+
pushEvent(input) {
|
|
8586
8780
|
const now = this.now();
|
|
8587
8781
|
const event = {
|
|
8588
|
-
id: this.createId(),
|
|
8782
|
+
id: this.createId("evt"),
|
|
8589
8783
|
projectId: this.projectId,
|
|
8590
8784
|
severity: "low",
|
|
8591
8785
|
title: input.type,
|
|
8786
|
+
description: null,
|
|
8787
|
+
assetId: null,
|
|
8788
|
+
asset: null,
|
|
8592
8789
|
facts: [],
|
|
8593
8790
|
createdAt: now,
|
|
8594
|
-
updatedAt: now,
|
|
8595
8791
|
...input
|
|
8596
8792
|
};
|
|
8597
8793
|
this.eventFeed.push(event);
|
|
8598
8794
|
return event;
|
|
8599
8795
|
}
|
|
8600
|
-
/**
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
"content-type": "application/json",
|
|
8611
|
-
"x-request-id": requestId
|
|
8612
|
-
})
|
|
8613
|
-
});
|
|
8796
|
+
/** Applies a reviewer's decision to a stored asset (see `MockCalmLensServerClient.decide`). */
|
|
8797
|
+
async decide(assetId, decision) {
|
|
8798
|
+
if (!REVIEW_DECISIONS.includes(decision)) throw invalidArgument(`decision must be one of ${REVIEW_DECISIONS.join(", ")}`);
|
|
8799
|
+
let asset;
|
|
8800
|
+
try {
|
|
8801
|
+
asset = await this.findOwnAsset(assetId);
|
|
8802
|
+
} catch (failure) {
|
|
8803
|
+
throw this.toError(failure);
|
|
8804
|
+
}
|
|
8805
|
+
return structuredClone(await this.recordDecision(asset, decision));
|
|
8614
8806
|
}
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8807
|
+
/**
|
|
8808
|
+
* Finishes processing a pending asset as the pipeline would (see
|
|
8809
|
+
* `MockCalmLensServerClient.completeProcessing`): with `decision`, or else what `moderate`
|
|
8810
|
+
* returns for the `complete` trigger.
|
|
8811
|
+
*/
|
|
8812
|
+
async completeProcessing(assetId, decision) {
|
|
8813
|
+
let asset;
|
|
8814
|
+
try {
|
|
8815
|
+
asset = await this.findOwnAsset(assetId);
|
|
8816
|
+
} catch (failure) {
|
|
8817
|
+
throw this.toError(failure);
|
|
8818
|
+
}
|
|
8819
|
+
if (asset.classification.status !== "processing") throw invalidArgument(`asset ${assetId} isn't processing`);
|
|
8820
|
+
const settled = decision === void 0 ? void 0 : toDecision(decision);
|
|
8821
|
+
return structuredClone(await this.moderateStored(asset, "complete", settled));
|
|
8620
8822
|
}
|
|
8621
|
-
|
|
8622
|
-
|
|
8823
|
+
/**
|
|
8824
|
+
* `POST /assets/:assetId/decision`, like the API: an asset still uploading or processing can't
|
|
8825
|
+
* be decided, one pending moderation can only be blocked or purged, a decision must change the
|
|
8826
|
+
* status and purged content can only be blocked. Mock API keys have no name, so the reviewer is
|
|
8827
|
+
* "An API key".
|
|
8828
|
+
*/
|
|
8829
|
+
async decideAsset(assetId, body) {
|
|
8830
|
+
const { decision, note: rawNote } = body;
|
|
8831
|
+
if (decision === "purged") {
|
|
8832
|
+
if (body.confirmPurge !== true) throw invalidField("validation/invalid-value", "confirmPurge");
|
|
8833
|
+
} else if (!REVIEW_DECISIONS.includes(decision)) throw invalidField("validation/invalid-value", "decision");
|
|
8834
|
+
if (typeof rawNote !== "string") throw invalidField("validation/invalid-type", "note");
|
|
8835
|
+
const note = rawNote.trim();
|
|
8836
|
+
if (!note) throw invalidField("validation/too-small", "note");
|
|
8837
|
+
if (note.length > MAX_DECISION_NOTE_LENGTH) throw invalidField("validation/too-big", "note");
|
|
8838
|
+
const asset = await this.findOwnAsset(assetId);
|
|
8839
|
+
if (asset.upload.status === "uploading" || asset.classification.status === "processing") throw fail(API_ERROR_CODES.ASSET_PROCESSING);
|
|
8840
|
+
const status = asset.classification.moderation.status;
|
|
8841
|
+
if (decision !== "purged" && decision !== "blocked" && status === "pending") throw fail(API_ERROR_CODES.ASSET_DECISION_NOT_ALLOWED);
|
|
8842
|
+
if (status === decision) throw fail(API_ERROR_CODES.ASSET_DECISION_UNCHANGED);
|
|
8843
|
+
if (decision !== "purged" && decision !== "blocked" && status === "purged") throw fail(API_ERROR_CODES.ASSET_CONTENT_PURGED);
|
|
8844
|
+
return this.recordDecision(asset, decision, {
|
|
8845
|
+
note,
|
|
8846
|
+
reviewerName: API_KEY_REVIEWER_NAME
|
|
8847
|
+
});
|
|
8623
8848
|
}
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8849
|
+
/**
|
|
8850
|
+
* Sets the reviewer's decision: the moderation status, a manual review and the
|
|
8851
|
+
* `asset.<status>` event (after `asset.content-removed` when purging). The test hook records no
|
|
8852
|
+
* reviewer or note; the API's handler records both.
|
|
8853
|
+
*/
|
|
8854
|
+
async recordDecision(asset, decision, review) {
|
|
8855
|
+
const decided = await this.applyOutcome(asset, decision, "requested");
|
|
8856
|
+
const now = this.now();
|
|
8857
|
+
decided.classification.moderation.manualReviews = [...decided.classification.moderation.manualReviews ?? [], {
|
|
8858
|
+
id: this.createId("rev"),
|
|
8859
|
+
decision,
|
|
8860
|
+
note: review?.note ?? null,
|
|
8861
|
+
...review && {
|
|
8862
|
+
reviewerName: review.reviewerName,
|
|
8863
|
+
reviewerEmail: null
|
|
8864
|
+
},
|
|
8865
|
+
reviewedAt: now,
|
|
8866
|
+
appliedToAsset: true
|
|
8867
|
+
}];
|
|
8868
|
+
const stored = await this.databaseStorage.updateAsset(decided);
|
|
8869
|
+
if (decision === "purged") this.recordLifecycleEvent("asset.content-removed", stored);
|
|
8870
|
+
this.pushEvent({
|
|
8871
|
+
type: `asset.${decision}`,
|
|
8872
|
+
severity: decision === "allowed" || decision === "limited" ? "low" : "high",
|
|
8873
|
+
title: `asset.${decision}`,
|
|
8874
|
+
assetId: stored.id,
|
|
8875
|
+
asset: toEventAsset(stored),
|
|
8876
|
+
facts: [
|
|
8877
|
+
{
|
|
8878
|
+
key: "decided-by",
|
|
8879
|
+
value: "reviewer"
|
|
8880
|
+
},
|
|
8881
|
+
{
|
|
8882
|
+
key: "asset-type",
|
|
8883
|
+
value: stored.type
|
|
8884
|
+
},
|
|
8885
|
+
{
|
|
8886
|
+
key: "asset-name",
|
|
8887
|
+
value: stored.name,
|
|
8888
|
+
url: `https://calmlens.com/go/asset/${stored.id}`
|
|
8889
|
+
},
|
|
8890
|
+
...review ? [{
|
|
8891
|
+
key: "reviewer-user-id",
|
|
8892
|
+
value: null
|
|
8893
|
+
}, {
|
|
8894
|
+
key: "reviewer-name",
|
|
8895
|
+
value: review.reviewerName
|
|
8896
|
+
}] : [],
|
|
8897
|
+
{
|
|
8898
|
+
key: "note",
|
|
8899
|
+
value: review?.note ?? null
|
|
8900
|
+
}
|
|
8901
|
+
]
|
|
8902
|
+
});
|
|
8903
|
+
return stored;
|
|
8629
8904
|
}
|
|
8630
|
-
|
|
8905
|
+
/** The `CalmLensError` the real client throws for an API failure, for helpers that send none. */
|
|
8906
|
+
toError(failure) {
|
|
8907
|
+
if (!(failure instanceof ApiFailure)) return failure;
|
|
8908
|
+
const requestId = `mock-request-${++this.requestCount}`;
|
|
8909
|
+
return new CalmLensError(failure.code, {
|
|
8910
|
+
message: failure.message,
|
|
8911
|
+
status: failure.status,
|
|
8912
|
+
details: failure.details ?? {},
|
|
8913
|
+
requestId,
|
|
8914
|
+
headers: new Headers({
|
|
8915
|
+
"content-type": "application/json",
|
|
8916
|
+
"x-request-id": requestId
|
|
8917
|
+
})
|
|
8918
|
+
});
|
|
8919
|
+
}
|
|
8920
|
+
async handle(input, init, endpoints, session) {
|
|
8921
|
+
if (init?.signal?.aborted) throw init.signal.reason;
|
|
8922
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
8923
|
+
const cdn = url.startsWith(`${endpoints.cdnBaseUrl}/`);
|
|
8924
|
+
const base = cdn ? endpoints.cdnBaseUrl : endpoints.baseUrl;
|
|
8925
|
+
if (!url.startsWith(`${base}/`)) throw new TypeError(`MockCalmLensServerClient does not serve ${url}`);
|
|
8926
|
+
const relative = url.slice(base.length);
|
|
8927
|
+
const queryIndex = relative.indexOf("?");
|
|
8928
|
+
const path = queryIndex === -1 ? relative : relative.slice(0, queryIndex);
|
|
8929
|
+
const headers = new Headers(init?.headers);
|
|
8930
|
+
const bytes = init?.body === void 0 || init.body === null ? /* @__PURE__ */ new Uint8Array(0) : new Uint8Array(await new Response(init.body).arrayBuffer());
|
|
8931
|
+
const request = {
|
|
8932
|
+
method: (init?.method ?? "GET").toUpperCase(),
|
|
8933
|
+
url,
|
|
8934
|
+
segments: path.split("/").filter(Boolean).map(decodeSegment),
|
|
8935
|
+
query: new URLSearchParams(queryIndex === -1 ? "" : relative.slice(queryIndex + 1)),
|
|
8936
|
+
headers,
|
|
8937
|
+
bytes,
|
|
8938
|
+
cdn
|
|
8939
|
+
};
|
|
8940
|
+
const operations = operationsOf(request);
|
|
8941
|
+
const call = {
|
|
8942
|
+
operation: operations[0] ?? "unknown",
|
|
8943
|
+
method: request.method,
|
|
8944
|
+
url,
|
|
8945
|
+
path: `/${request.segments.join("/")}`,
|
|
8946
|
+
query: Object.fromEntries(request.query),
|
|
8947
|
+
headers: Object.fromEntries(headers),
|
|
8948
|
+
body: this.describeBody(request),
|
|
8949
|
+
status: void 0
|
|
8950
|
+
};
|
|
8631
8951
|
this.calls.push({
|
|
8632
|
-
|
|
8633
|
-
|
|
8952
|
+
call,
|
|
8953
|
+
operations
|
|
8634
8954
|
});
|
|
8635
|
-
const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || pending.operation
|
|
8955
|
+
const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || operations.includes(pending.operation));
|
|
8636
8956
|
if (index !== -1) {
|
|
8637
|
-
const [
|
|
8638
|
-
|
|
8957
|
+
const [{ failure, details, retryAfter }] = this.pendingFailures.splice(index, 1);
|
|
8958
|
+
if (typeof failure === "string" && failure in API_ERROR_DEFINITIONS) return this.answered(call, this.failureResponse(fail(failure, { details }), url, retryAfter));
|
|
8959
|
+
throw this.toFailureError(failure);
|
|
8639
8960
|
}
|
|
8961
|
+
const response = cdn ? await this.serveCdn(request) : await this.answer(request, operations[0], session);
|
|
8962
|
+
return this.answered(call, response);
|
|
8640
8963
|
}
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8964
|
+
answered(call, response) {
|
|
8965
|
+
call.status = response.status;
|
|
8966
|
+
return response;
|
|
8967
|
+
}
|
|
8968
|
+
/** What `fetch` throws for a simulated network failure, as the real client reports it. */
|
|
8969
|
+
toFailureError(failure) {
|
|
8970
|
+
if (failure === "transport-error") return /* @__PURE__ */ new TypeError("Simulated network failure");
|
|
8971
|
+
if (failure === "timeout") {
|
|
8972
|
+
const cause = /* @__PURE__ */ new Error("The operation timed out.");
|
|
8973
|
+
cause.name = "TimeoutError";
|
|
8974
|
+
return new CalmLensError(SDK_ERROR_CODES.TIMEOUT, {
|
|
8975
|
+
message: "Request timed out",
|
|
8976
|
+
cause
|
|
8977
|
+
});
|
|
8978
|
+
}
|
|
8979
|
+
return failure;
|
|
8980
|
+
}
|
|
8981
|
+
describeBody({ bytes, headers }) {
|
|
8982
|
+
if (bytes.byteLength === 0) return void 0;
|
|
8983
|
+
const type = headers.get("content-type") ?? "";
|
|
8984
|
+
const text = () => new TextDecoder().decode(bytes);
|
|
8985
|
+
if (type === "application/json") try {
|
|
8986
|
+
return JSON.parse(text());
|
|
8987
|
+
} catch {
|
|
8988
|
+
return text();
|
|
8989
|
+
}
|
|
8990
|
+
return type.startsWith("text/") ? text() : bytes.slice();
|
|
8991
|
+
}
|
|
8992
|
+
response(status, body, url, headers, requestId = `mock-request-${++this.requestCount}`) {
|
|
8993
|
+
const response = new Response(body, {
|
|
8644
8994
|
status,
|
|
8645
8995
|
headers: {
|
|
8646
|
-
|
|
8647
|
-
"
|
|
8996
|
+
...headers,
|
|
8997
|
+
"X-Request-Id": requestId
|
|
8648
8998
|
}
|
|
8649
8999
|
});
|
|
8650
9000
|
Object.defineProperty(response, "url", { value: url });
|
|
8651
9001
|
return response;
|
|
8652
9002
|
}
|
|
8653
|
-
|
|
8654
|
-
return {
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
}
|
|
8692
|
-
const requestId = `mock-request-${++this.requestCount}`;
|
|
8693
|
-
const isHead = init?.method?.toUpperCase() === "HEAD";
|
|
8694
|
-
const response = new Response(isHead ? null : bytes, {
|
|
8695
|
-
status: 200,
|
|
8696
|
-
headers: {
|
|
8697
|
-
"Content-Type": storedContent.type ?? "application/octet-stream",
|
|
8698
|
-
"Content-Length": String(byteLength),
|
|
8699
|
-
"Cache-Control": asset.visibility === "public" ? "public, max-age=31536000" : "private, no-store",
|
|
8700
|
-
ETag: `"${asset.id}-${asset.updatedAt}"`,
|
|
8701
|
-
"X-Request-Id": requestId,
|
|
8702
|
-
"X-Mock-CalmLens-CDN-URL": url
|
|
8703
|
-
}
|
|
9003
|
+
jsonResponse(status, body, url, headers = {}, requestId) {
|
|
9004
|
+
return this.response(status, JSON.stringify(body), url, {
|
|
9005
|
+
"Content-Type": "application/json",
|
|
9006
|
+
...headers
|
|
9007
|
+
}, requestId);
|
|
9008
|
+
}
|
|
9009
|
+
/** The API's error body: `{ code, message, requestId, details }`, `requestId` as in the header. */
|
|
9010
|
+
failureResponse(failure, url, retryAfter) {
|
|
9011
|
+
const requestId = `mock-request-${++this.requestCount}`;
|
|
9012
|
+
return this.jsonResponse(failure.status, {
|
|
9013
|
+
code: failure.code,
|
|
9014
|
+
message: failure.message,
|
|
9015
|
+
requestId,
|
|
9016
|
+
details: failure.details ?? {}
|
|
9017
|
+
}, url, retryAfter === void 0 ? {} : { "Retry-After": String(retryAfter) }, requestId);
|
|
9018
|
+
}
|
|
9019
|
+
/** Answers an API request: the API key, idempotency, then the route. */
|
|
9020
|
+
async answer(request, operation, session) {
|
|
9021
|
+
const { url, headers } = request;
|
|
9022
|
+
if (!(request.segments[0] === "upload-presets") && headers.get("x-api-key") !== this.apiKey) return this.failureResponse(fail(API_ERROR_CODES.INVALID_API_KEY), url);
|
|
9023
|
+
const key = headers.get("idempotency-key")?.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
|
|
9024
|
+
const upload = headers.get("x-calmlens-upload");
|
|
9025
|
+
const fingerprint = JSON.stringify([
|
|
9026
|
+
request.method,
|
|
9027
|
+
request.segments,
|
|
9028
|
+
[...request.query].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
|
|
9029
|
+
headers.get("content-type"),
|
|
9030
|
+
upload,
|
|
9031
|
+
upload ? request.bytes.byteLength : new TextDecoder().decode(request.bytes)
|
|
9032
|
+
]);
|
|
9033
|
+
if (key !== void 0) {
|
|
9034
|
+
if (!isValidIdempotencyKey(key)) return this.failureResponse(fail(API_ERROR_CODES.INVALID_IDEMPOTENCY_KEY), url);
|
|
9035
|
+
const saved = this.idempotentResults.get(key);
|
|
9036
|
+
if (saved) {
|
|
9037
|
+
if (saved.fingerprint !== fingerprint) return this.failureResponse(fail(API_ERROR_CODES.IDEMPOTENCY_KEY_REUSED), url);
|
|
9038
|
+
return this.response(saved.status, saved.body, url, {
|
|
9039
|
+
"Content-Type": "application/json",
|
|
9040
|
+
"Idempotent-Replayed": "true"
|
|
8704
9041
|
});
|
|
8705
|
-
Object.defineProperty(response, "url", { value: url });
|
|
8706
|
-
return response;
|
|
8707
9042
|
}
|
|
8708
|
-
}
|
|
9043
|
+
}
|
|
9044
|
+
let answer;
|
|
9045
|
+
try {
|
|
9046
|
+
answer = await this.route(request, session);
|
|
9047
|
+
} catch (failure) {
|
|
9048
|
+
if (failure instanceof ApiFailure) return this.failureResponse(failure, url);
|
|
9049
|
+
throw failure;
|
|
9050
|
+
}
|
|
9051
|
+
const [status, body] = answer;
|
|
9052
|
+
const json = JSON.stringify(body ?? {});
|
|
9053
|
+
const finalStatus = operation ? SUCCESS_STATUS[operation] ?? status : status;
|
|
9054
|
+
if (key !== void 0) this.idempotentResults.set(key, {
|
|
9055
|
+
fingerprint,
|
|
9056
|
+
status: finalStatus,
|
|
9057
|
+
body: json
|
|
9058
|
+
});
|
|
9059
|
+
return this.response(finalStatus, json, url, { "Content-Type": "application/json" });
|
|
9060
|
+
}
|
|
9061
|
+
async route(request, session) {
|
|
9062
|
+
const { method, segments } = request;
|
|
9063
|
+
const [root, id, action, ...rest] = segments;
|
|
9064
|
+
if (root === "projects") {
|
|
9065
|
+
if (id !== this.projectId) throw fail(API_ERROR_CODES.API_KEY_PROJECT_MISMATCH);
|
|
9066
|
+
switch (`${method} ${segments.slice(2).join("/")}`) {
|
|
9067
|
+
case "POST assets": return [200, await this.createAsset(request)];
|
|
9068
|
+
case "GET assets": return [200, await this.listAssets(request.query)];
|
|
9069
|
+
case "GET assets/search": return [200, await this.searchAssets(request.query)];
|
|
9070
|
+
case "POST assets/batch": return [202, await this.batch(this.json(request))];
|
|
9071
|
+
case "GET events": return [200, this.listEvents(request.query)];
|
|
9072
|
+
case "GET folders": return [200, await this.listFolders(request.query)];
|
|
9073
|
+
case "POST folders": return [201, await this.createFolder(this.json(request))];
|
|
9074
|
+
case "GET hash-lists": return [200, this.listHashLists(request.query)];
|
|
9075
|
+
case "POST hash-lists": return [201, this.createHashList(this.json(request))];
|
|
9076
|
+
case "PUT lenses": return [200, this.ensureLenses(this.json(request))];
|
|
9077
|
+
}
|
|
9078
|
+
}
|
|
9079
|
+
if (root === "upload-presets" && id !== void 0 && action === "assets") return [200, await this.presetRoute(request, id, rest, session)];
|
|
9080
|
+
if (root === "assets" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
|
|
9081
|
+
case "GET ": return [200, await this.findOwnAsset(id)];
|
|
9082
|
+
case "PATCH ": return [200, await this.updateAsset(id, this.json(request))];
|
|
9083
|
+
case "DELETE ": return [200, await this.deleteAsset(id)];
|
|
9084
|
+
case "GET children": return [200, await this.listChildren(id, request.query)];
|
|
9085
|
+
case "POST part": return [200, await this.uploadPart(id, request)];
|
|
9086
|
+
case "POST signed-url": return [200, await this.createSignedUrl(id, this.json(request))];
|
|
9087
|
+
case "POST move": return [200, await this.moveAsset(id, this.json(request))];
|
|
9088
|
+
case "POST retry": return [200, await this.retryAsset(id)];
|
|
9089
|
+
case "POST explain": return [200, await this.explainAsset(id, this.json(request))];
|
|
9090
|
+
case "POST decision": return [200, await this.decideAsset(id, this.json(request))];
|
|
9091
|
+
case "DELETE content": return [200, await this.removeContent(await this.findOwnAsset(id))];
|
|
9092
|
+
}
|
|
9093
|
+
if (root === "folders" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
|
|
9094
|
+
case "GET ": return [200, await this.findOwnFolder(id)];
|
|
9095
|
+
case "PATCH ": return [200, await this.updateFolder(id, this.json(request))];
|
|
9096
|
+
case "DELETE ": return [200, await this.deleteFolder(id)];
|
|
9097
|
+
case "POST move": return [200, await this.moveFolder(id, this.json(request))];
|
|
9098
|
+
}
|
|
9099
|
+
if (root === "hash-lists" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
|
|
9100
|
+
case "GET ": return [200, this.findHashList(id)];
|
|
9101
|
+
case "PATCH ": return [200, this.updateHashList(id, this.json(request))];
|
|
9102
|
+
case "DELETE ": return [200, this.deleteHashList(id)];
|
|
9103
|
+
case "GET entries": return [200, this.listHashListEntries(id, request.query)];
|
|
9104
|
+
case "POST entries": return this.addHashListEntry(id, this.json(request));
|
|
9105
|
+
}
|
|
9106
|
+
if (root === "hash-list-entries" && id !== void 0 && method === "DELETE" && !action) {
|
|
9107
|
+
if (!this.hashListEntries.delete(id)) throw fail(API_ERROR_CODES.HASH_LIST_ENTRY_NOT_FOUND);
|
|
9108
|
+
return [200, {}];
|
|
9109
|
+
}
|
|
9110
|
+
throw fail(API_ERROR_CODES.ROUTE_NOT_FOUND, { message: `No route for ${method} /${segments.join("/")}` });
|
|
8709
9111
|
}
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
9112
|
+
createHashList(body) {
|
|
9113
|
+
const { name, description, action } = body;
|
|
9114
|
+
if (typeof name !== "string" || !name.trim() || action !== "flag" && action !== "block") throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "A hash list needs a name and an action of flag or block" });
|
|
9115
|
+
const now = this.now();
|
|
9116
|
+
const list = {
|
|
9117
|
+
id: this.createId("hl"),
|
|
9118
|
+
projectId: this.projectId,
|
|
9119
|
+
name: name.trim(),
|
|
9120
|
+
description: typeof description === "string" ? description : null,
|
|
9121
|
+
action,
|
|
9122
|
+
createdAt: now,
|
|
9123
|
+
updatedAt: now
|
|
8722
9124
|
};
|
|
9125
|
+
this.hashLists.set(list.id, list);
|
|
9126
|
+
return list;
|
|
8723
9127
|
}
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
9128
|
+
listHashLists(query) {
|
|
9129
|
+
return createPage([...this.hashLists.values()], this.pageQuery(query), {
|
|
9130
|
+
scope: `hash-lists:${this.projectId}`,
|
|
9131
|
+
fail: this.pageFailure
|
|
9132
|
+
});
|
|
9133
|
+
}
|
|
9134
|
+
findHashList(hashListId) {
|
|
9135
|
+
const list = this.hashLists.get(hashListId);
|
|
9136
|
+
if (!list) throw fail(API_ERROR_CODES.HASH_LIST_NOT_FOUND);
|
|
9137
|
+
return list;
|
|
9138
|
+
}
|
|
9139
|
+
updateHashList(hashListId, body) {
|
|
9140
|
+
const updated = {
|
|
9141
|
+
...this.findHashList(hashListId),
|
|
9142
|
+
...typeof body.name === "string" ? { name: body.name.trim() } : {},
|
|
9143
|
+
...body.description !== void 0 ? { description: body.description ?? null } : {},
|
|
9144
|
+
...body.action === "flag" || body.action === "block" ? { action: body.action } : {},
|
|
9145
|
+
updatedAt: this.now()
|
|
8731
9146
|
};
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
9147
|
+
this.hashLists.set(hashListId, updated);
|
|
9148
|
+
return updated;
|
|
9149
|
+
}
|
|
9150
|
+
deleteHashList(hashListId) {
|
|
9151
|
+
this.findHashList(hashListId);
|
|
9152
|
+
this.hashLists.delete(hashListId);
|
|
9153
|
+
for (const [id, entry] of this.hashListEntries) if (entry.hashListId === hashListId) this.hashListEntries.delete(id);
|
|
9154
|
+
return {};
|
|
9155
|
+
}
|
|
9156
|
+
listHashListEntries(hashListId, query) {
|
|
9157
|
+
this.findHashList(hashListId);
|
|
9158
|
+
const pageSize = this.integer(query, "pageSize", 50);
|
|
9159
|
+
const entries = [...this.hashListEntries.values()].filter((entry) => entry.hashListId === hashListId).reverse();
|
|
9160
|
+
const cursor = query.get("cursor");
|
|
9161
|
+
const start = cursor ? Number(atob(cursor)) : 0;
|
|
9162
|
+
if (!Number.isInteger(start) || start < 0) throw fail(API_ERROR_CODES.INVALID_CURSOR);
|
|
9163
|
+
const items = entries.slice(start, start + pageSize);
|
|
9164
|
+
const hasMore = start + pageSize < entries.length;
|
|
8737
9165
|
return {
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
9166
|
+
items,
|
|
9167
|
+
hasMore,
|
|
9168
|
+
nextCursor: hasMore ? btoa(String(start + pageSize)) : null,
|
|
9169
|
+
pageSize,
|
|
9170
|
+
ordering: [{
|
|
9171
|
+
field: "createdAt",
|
|
9172
|
+
direction: "desc"
|
|
9173
|
+
}, {
|
|
9174
|
+
field: "id",
|
|
9175
|
+
direction: "desc"
|
|
9176
|
+
}]
|
|
8741
9177
|
};
|
|
8742
9178
|
}
|
|
8743
|
-
async
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
if (!
|
|
8752
|
-
|
|
9179
|
+
async addHashListEntry(hashListId, body) {
|
|
9180
|
+
const list = this.findHashList(hashListId);
|
|
9181
|
+
let hash = typeof body.hash === "string" ? body.hash.trim().toLowerCase() : void 0;
|
|
9182
|
+
let quality = typeof body.quality === "number" ? body.quality : null;
|
|
9183
|
+
const assetId = typeof body.assetId === "string" ? body.assetId : void 0;
|
|
9184
|
+
if (hash === void 0 === (assetId === void 0)) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Send either hash or assetId" });
|
|
9185
|
+
if (assetId) {
|
|
9186
|
+
const image = (await this.findOwnAsset(assetId)).content.image;
|
|
9187
|
+
if (!image?.perceptualHash) throw fail(API_ERROR_CODES.HASH_LIST_ASSET_NOT_HASHED);
|
|
9188
|
+
hash = image.perceptualHash;
|
|
9189
|
+
quality = image.perceptualHashQuality ?? null;
|
|
8753
9190
|
}
|
|
9191
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Use a PDQ hash of 64 hexadecimal characters" });
|
|
9192
|
+
const existing = [...this.hashListEntries.values()].find((entry) => entry.hashListId === list.id && entry.hash === hash);
|
|
9193
|
+
if (existing) return [200, existing];
|
|
9194
|
+
const entry = {
|
|
9195
|
+
id: this.createId("hle"),
|
|
9196
|
+
projectId: this.projectId,
|
|
9197
|
+
hashListId: list.id,
|
|
9198
|
+
hash,
|
|
9199
|
+
quality,
|
|
9200
|
+
label: typeof body.label === "string" ? body.label : null,
|
|
9201
|
+
sourceAssetId: assetId ?? null,
|
|
9202
|
+
createdAt: this.now()
|
|
9203
|
+
};
|
|
9204
|
+
this.hashListEntries.set(entry.id, entry);
|
|
9205
|
+
return [201, entry];
|
|
8754
9206
|
}
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
9207
|
+
/** The request's JSON body, or `{}` without one. */
|
|
9208
|
+
json(request) {
|
|
9209
|
+
if (request.bytes.byteLength === 0) return {};
|
|
9210
|
+
try {
|
|
9211
|
+
const body = JSON.parse(new TextDecoder().decode(request.bytes));
|
|
9212
|
+
return body && typeof body === "object" ? body : {};
|
|
9213
|
+
} catch {
|
|
9214
|
+
throw new ApiFailure(400, "validation/invalid-json", "Invalid JSON body");
|
|
8762
9215
|
}
|
|
8763
9216
|
}
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
9217
|
+
/** A whole number from the query, or `fallback` when absent; other values fail like zod. */
|
|
9218
|
+
integer(query, name, fallback) {
|
|
9219
|
+
const raw = query.get(name);
|
|
9220
|
+
if (raw === null) return fallback;
|
|
9221
|
+
const value = Number(raw);
|
|
9222
|
+
if (raw.trim() === "" || !Number.isFinite(value)) throw invalidField("validation/invalid-type", name);
|
|
9223
|
+
if (!Number.isInteger(value)) throw invalidField("validation/invalid-type", name);
|
|
9224
|
+
return value;
|
|
9225
|
+
}
|
|
9226
|
+
/** `ordering[i][field]` and `ordering[i][direction]` pairs, as qs parses them. */
|
|
9227
|
+
ordering(query) {
|
|
9228
|
+
const ordering = [];
|
|
9229
|
+
for (const [key, value] of query) {
|
|
9230
|
+
const match = /^ordering\[(\d+)\]\[(field|direction)\]$/.exec(key);
|
|
9231
|
+
if (!match) continue;
|
|
9232
|
+
const index = Number(match[1]);
|
|
9233
|
+
ordering[index] = {
|
|
9234
|
+
...ordering[index],
|
|
9235
|
+
[match[2]]: value
|
|
9236
|
+
};
|
|
9237
|
+
}
|
|
9238
|
+
return ordering.filter(Boolean);
|
|
8769
9239
|
}
|
|
8770
|
-
|
|
9240
|
+
pageQuery(query) {
|
|
9241
|
+
const ordering = this.ordering(query);
|
|
8771
9242
|
return {
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
* otherwise; `mode` is accepted but there is no semantic search.
|
|
8776
|
-
*/
|
|
8777
|
-
search: async (options) => {
|
|
8778
|
-
this.begin("assets.search", options);
|
|
8779
|
-
const query = typeof options?.query === "string" ? options.query.trim() : "";
|
|
8780
|
-
if (!query || query.length > 200) throw this.invalidPayload("query must be 1 to 200 characters");
|
|
8781
|
-
const limit = options.limit ?? 10;
|
|
8782
|
-
const needle = query.toLowerCase();
|
|
8783
|
-
const fields = [
|
|
8784
|
-
"name",
|
|
8785
|
-
"id",
|
|
8786
|
-
"externalId",
|
|
8787
|
-
"description"
|
|
8788
|
-
];
|
|
8789
|
-
return {
|
|
8790
|
-
query,
|
|
8791
|
-
items: (await this.databaseStorage.listAssets(this.projectId)).map((asset) => {
|
|
8792
|
-
const matchedFields = [];
|
|
8793
|
-
for (const field of fields) if (String(asset[field] ?? "").toLowerCase().includes(needle)) matchedFields.push(field);
|
|
8794
|
-
if (asset.tags?.some((tag) => tag.toLowerCase().includes(needle))) matchedFields.push("tags");
|
|
8795
|
-
return {
|
|
8796
|
-
asset,
|
|
8797
|
-
score: fields.some((field) => String(asset[field] ?? "").toLowerCase() === needle) ? 1 : .5,
|
|
8798
|
-
matchedFields
|
|
8799
|
-
};
|
|
8800
|
-
}).filter((result) => result.matchedFields.length > 0).sort((a, b) => b.score - a.score).slice(0, limit),
|
|
8801
|
-
semanticStatus: "disabled"
|
|
8802
|
-
};
|
|
8803
|
-
},
|
|
8804
|
-
/** Lists assets with the real client's paging, `ordering`, `folderId` and `filter`. */
|
|
8805
|
-
list: async (options = {}) => {
|
|
8806
|
-
this.begin("assets.list", options);
|
|
8807
|
-
let assets = await this.databaseStorage.listAssets(this.projectId);
|
|
8808
|
-
if (options.folderId !== void 0) assets = assets.filter((asset) => (asset.folderId ?? null) === options.folderId);
|
|
8809
|
-
assets = filterAssets(assets, options.filter);
|
|
8810
|
-
return createPage(assets, options);
|
|
8811
|
-
},
|
|
8812
|
-
iterate: (options = {}) => this.iteratePages((cursor) => this.assets.list({
|
|
8813
|
-
...options,
|
|
8814
|
-
cursor
|
|
8815
|
-
})),
|
|
8816
|
-
retrieve: async (options) => {
|
|
8817
|
-
this.begin("assets.retrieve", options);
|
|
8818
|
-
return this.findOwnAsset(options?.assetId);
|
|
8819
|
-
},
|
|
8820
|
-
listChildren: async (childrenOptions = {}) => {
|
|
8821
|
-
this.begin("assets.listChildren", childrenOptions);
|
|
8822
|
-
const { assetId, ...options } = childrenOptions;
|
|
8823
|
-
encodePathSegment(assetId, "Asset ID");
|
|
8824
|
-
const assets = await this.databaseStorage.listAssets(this.projectId);
|
|
8825
|
-
return createPage(assets.filter((asset) => asset.parentId === assetId), options);
|
|
8826
|
-
},
|
|
8827
|
-
iterateChildren: (options) => {
|
|
8828
|
-
encodePathSegment(options?.assetId, "Asset ID");
|
|
8829
|
-
return this.iteratePages((cursor) => this.assets.listChildren({
|
|
8830
|
-
...options,
|
|
8831
|
-
cursor
|
|
8832
|
-
}));
|
|
8833
|
-
},
|
|
8834
|
-
update: async (options) => {
|
|
8835
|
-
this.begin("assets.update", options);
|
|
8836
|
-
const { assetId, ...body } = options;
|
|
8837
|
-
const asset = await this.findOwnAsset(assetId);
|
|
8838
|
-
const changes = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
|
|
8839
|
-
return this.databaseStorage.updateAsset({
|
|
8840
|
-
...asset,
|
|
8841
|
-
...changes,
|
|
8842
|
-
updatedAt: this.now()
|
|
8843
|
-
});
|
|
8844
|
-
},
|
|
8845
|
-
create: async (options) => {
|
|
8846
|
-
this.begin("assets.create", options);
|
|
8847
|
-
return this.withIdempotency("assets.create", options, () => this.createAsset(options, true));
|
|
8848
|
-
},
|
|
8849
|
-
moderate: async (options) => {
|
|
8850
|
-
this.begin("assets.moderate", options);
|
|
8851
|
-
return this.moderateAsset(options, "assets.moderate");
|
|
8852
|
-
},
|
|
8853
|
-
/** Continues a multipart upload: parts must arrive in order while it is `uploading`. */
|
|
8854
|
-
uploadPart: async (options) => {
|
|
8855
|
-
this.begin("assets.uploadPart", options);
|
|
8856
|
-
const { assetId, part } = options;
|
|
8857
|
-
encodePathSegment(assetId, "Asset ID");
|
|
8858
|
-
if (!part) throw invalidArgument("Part options are required");
|
|
8859
|
-
return this.withIdempotency("assets.uploadPart", options, async () => {
|
|
8860
|
-
const asset = await this.findOwnAsset(assetId);
|
|
8861
|
-
const validation = validateAssetPayload(part, {
|
|
8862
|
-
multipart: true,
|
|
8863
|
-
isFinal: part.isFinal
|
|
8864
|
-
});
|
|
8865
|
-
if (!validation.ok) throw this.invalidPayload(validation.error);
|
|
8866
|
-
if (asset.processing.status !== "uploading") throw this.apiError(API_ERROR_CODES.UPLOAD_NOT_OPEN);
|
|
8867
|
-
const expectedPartNumber = (asset.processing.uploadPartsReceived ?? 1) + 1;
|
|
8868
|
-
if (part.partNumber !== expectedPartNumber) throw this.apiError(API_ERROR_CODES.INVALID_UPLOAD_PART_NUMBER, { details: { expectedPartNumber } });
|
|
8869
|
-
await this.imageStorage.putPart(assetId, part);
|
|
8870
|
-
const queue = options.queue ?? this.uploadQueues.get(assetId) ?? false;
|
|
8871
|
-
if (part.isFinal) this.uploadQueues.delete(assetId);
|
|
8872
|
-
const now = this.now();
|
|
8873
|
-
const received = {
|
|
8874
|
-
...asset,
|
|
8875
|
-
processing: {
|
|
8876
|
-
...asset.processing,
|
|
8877
|
-
uploadPartsReceived: part.partNumber
|
|
8878
|
-
},
|
|
8879
|
-
updatedAt: now
|
|
8880
|
-
};
|
|
8881
|
-
return this.databaseStorage.updateAsset(part.isFinal ? await this.applyOutcome(received, queue ? this.queuedOutcome : this.analyzedOutcome) : received);
|
|
8882
|
-
});
|
|
8883
|
-
},
|
|
8884
|
-
createSignedUrl: async (signedUrlOptions) => {
|
|
8885
|
-
this.begin("assets.createSignedUrl", signedUrlOptions);
|
|
8886
|
-
const { assetId, ...options } = signedUrlOptions;
|
|
8887
|
-
const asset = await this.findOwnAsset(assetId);
|
|
8888
|
-
const expiresInSeconds = options.expiresInSeconds ?? 900;
|
|
8889
|
-
const resource = options.resource ?? "content";
|
|
8890
|
-
const transform = transformSelector(options.transform);
|
|
8891
|
-
const selector = (options.lens ? `&lens=${encodeURIComponent(options.lens)}` : "") + (transform ? `&transform=${encodeURIComponent(transform)}` : "");
|
|
8892
|
-
return {
|
|
8893
|
-
url: `${this.signedUrlBaseUrl}/assets/${asset.id}/${resource}?expiresInSeconds=${expiresInSeconds}${selector}`,
|
|
8894
|
-
expiresAt: this.now() + expiresInSeconds * 1e3
|
|
8895
|
-
};
|
|
8896
|
-
},
|
|
8897
|
-
move: async (options) => {
|
|
8898
|
-
this.begin("assets.move", options);
|
|
8899
|
-
const { assetId, folderId } = options;
|
|
8900
|
-
const asset = await this.findOwnAsset(assetId);
|
|
8901
|
-
if (folderId !== null && folderId !== void 0) {
|
|
8902
|
-
if (!await this.resolveFolder(folderId)) throw this.apiError(API_ERROR_CODES.FOLDER_NOT_FOUND);
|
|
8903
|
-
}
|
|
8904
|
-
return this.databaseStorage.updateAsset({
|
|
8905
|
-
...asset,
|
|
8906
|
-
folderId: folderId ?? null,
|
|
8907
|
-
updatedAt: this.now()
|
|
8908
|
-
});
|
|
8909
|
-
},
|
|
8910
|
-
retry: async (options) => {
|
|
8911
|
-
this.begin("assets.retry", options);
|
|
8912
|
-
const asset = await this.findOwnAsset(options?.assetId);
|
|
8913
|
-
if (asset.processing.status !== "failed") throw this.apiError(API_ERROR_CODES.ASSET_NOT_RETRYABLE);
|
|
8914
|
-
if (asset.content.status === "removed") throw this.apiError(API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
|
|
8915
|
-
return this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
|
|
8916
|
-
},
|
|
8917
|
-
/** Waits like the real client, checking the mock's storage for a decision. */
|
|
8918
|
-
waitForDecision: async (options) => {
|
|
8919
|
-
this.begin("assets.waitForDecision", options);
|
|
8920
|
-
const { assetId, ...waitOptions } = options;
|
|
8921
|
-
return pollForDecision(() => this.findOwnAsset(assetId), waitOptions, sleep);
|
|
8922
|
-
},
|
|
8923
|
-
deleteContent: async (options) => {
|
|
8924
|
-
this.begin("assets.deleteContent", options);
|
|
8925
|
-
const asset = await this.findOwnAsset(options?.assetId);
|
|
8926
|
-
await this.imageStorage.deleteContent(asset.id);
|
|
8927
|
-
const now = this.now();
|
|
8928
|
-
return this.databaseStorage.updateAsset({
|
|
8929
|
-
...asset,
|
|
8930
|
-
content: asset.content.status === "removed" ? asset.content : {
|
|
8931
|
-
...asset.content,
|
|
8932
|
-
status: "removed",
|
|
8933
|
-
removalReason: "requested",
|
|
8934
|
-
updatedAt: now,
|
|
8935
|
-
extractedText: null
|
|
8936
|
-
},
|
|
8937
|
-
updatedAt: now
|
|
8938
|
-
});
|
|
8939
|
-
},
|
|
8940
|
-
delete: async (options) => {
|
|
8941
|
-
this.begin("assets.delete", options);
|
|
8942
|
-
const asset = await this.findOwnAsset(options?.assetId);
|
|
8943
|
-
await this.imageStorage.deleteContent(asset.id);
|
|
8944
|
-
await this.databaseStorage.deleteAsset(asset.id);
|
|
8945
|
-
}
|
|
9243
|
+
pageSize: this.integer(query, "pageSize", 50),
|
|
9244
|
+
cursor: query.get("cursor") ?? void 0,
|
|
9245
|
+
ordering: ordering.length ? ordering : void 0
|
|
8946
9246
|
};
|
|
8947
9247
|
}
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
9248
|
+
async findOwnAsset(assetId) {
|
|
9249
|
+
const asset = await this.databaseStorage.getAsset(assetId);
|
|
9250
|
+
if (!asset || asset.projectId !== this.projectId) throw fail(API_ERROR_CODES.ASSET_NOT_FOUND);
|
|
9251
|
+
return asset;
|
|
9252
|
+
}
|
|
9253
|
+
async listAssets(query) {
|
|
9254
|
+
let assets = await this.databaseStorage.listAssets(this.projectId);
|
|
9255
|
+
const folderId = query.get("folderId");
|
|
9256
|
+
if (folderId !== null) assets = assets.filter((asset) => (asset.folderId ?? null) === folderId);
|
|
9257
|
+
const filter = query.get("filter") ?? void 0;
|
|
9258
|
+
assets = filterAssets(assets, filter);
|
|
9259
|
+
return createPage(assets, this.pageQuery(query), {
|
|
9260
|
+
scope: JSON.stringify([
|
|
9261
|
+
this.projectId,
|
|
9262
|
+
filter ?? null,
|
|
9263
|
+
folderId
|
|
9264
|
+
]),
|
|
9265
|
+
fail: this.pageFailure
|
|
9266
|
+
});
|
|
8957
9267
|
}
|
|
8958
|
-
|
|
9268
|
+
/**
|
|
9269
|
+
* Keyword search like the API's: every whitespace-separated token must appear,
|
|
9270
|
+
* case-insensitively, in the asset's name, ID, external ID, description, tags, type, content
|
|
9271
|
+
* format or extension, or moderation status. There is no semantic search.
|
|
9272
|
+
*/
|
|
9273
|
+
async searchAssets(params) {
|
|
9274
|
+
const query = (params.get("query") ?? "").trim();
|
|
9275
|
+
if (!query) throw invalidField("validation/too-small", "query");
|
|
9276
|
+
if (query.length > MAX_SEARCH_QUERY_LENGTH) throw invalidField("validation/too-big", "query");
|
|
9277
|
+
const limit = this.integer(params, "limit", 10);
|
|
9278
|
+
if (limit < 1) throw invalidField("validation/too-small", "limit");
|
|
9279
|
+
if (limit > MAX_SEARCH_LIMIT) throw invalidField("validation/too-big", "limit");
|
|
8959
9280
|
return {
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
},
|
|
8964
|
-
retrieve: async (options) => {
|
|
8965
|
-
this.begin("folders.retrieve", options);
|
|
8966
|
-
return this.findOwnFolder(options?.folderId);
|
|
8967
|
-
},
|
|
8968
|
-
create: async (options) => {
|
|
8969
|
-
this.begin("folders.create", options);
|
|
8970
|
-
if (!options?.name) throw this.invalidPayload("Folder name is required");
|
|
8971
|
-
if (options.parentId) await this.findOwnFolder(options.parentId);
|
|
8972
|
-
await this.assertUniqueFolderName(options.name, options.parentId ?? null);
|
|
8973
|
-
const folder = {
|
|
8974
|
-
id: this.createId(),
|
|
8975
|
-
projectId: this.projectId,
|
|
8976
|
-
parentId: options.parentId ?? null,
|
|
8977
|
-
name: options.name,
|
|
8978
|
-
createdAt: this.now(),
|
|
8979
|
-
updatedAt: this.now()
|
|
8980
|
-
};
|
|
8981
|
-
return this.databaseStorage.createFolder ? await this.databaseStorage.createFolder(folder) : folder;
|
|
8982
|
-
},
|
|
8983
|
-
update: async (options) => {
|
|
8984
|
-
this.begin("folders.update", options);
|
|
8985
|
-
const { folderId, ...body } = options;
|
|
8986
|
-
const folder = await this.findOwnFolder(folderId);
|
|
8987
|
-
if (body.name !== void 0 && body.name !== folder.name) await this.assertUniqueFolderName(body.name, folder.parentId ?? null, folderId);
|
|
8988
|
-
const updated = {
|
|
8989
|
-
...folder,
|
|
8990
|
-
...body.name !== void 0 ? { name: body.name } : {},
|
|
8991
|
-
updatedAt: this.now()
|
|
8992
|
-
};
|
|
8993
|
-
return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
|
|
8994
|
-
},
|
|
8995
|
-
delete: async (options) => {
|
|
8996
|
-
this.begin("folders.delete", options);
|
|
8997
|
-
const { folderId } = options;
|
|
8998
|
-
await this.findOwnFolder(folderId);
|
|
8999
|
-
const hasAssets = (await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.folderId === folderId);
|
|
9000
|
-
const hasChildren = (this.databaseStorage.listFolders ? await this.databaseStorage.listFolders(this.projectId) : []).some((f) => f.parentId === folderId);
|
|
9001
|
-
if (hasAssets || hasChildren) throw this.apiError(API_ERROR_CODES.FOLDER_NOT_EMPTY, { details: {
|
|
9002
|
-
hasAssets,
|
|
9003
|
-
hasChildren
|
|
9004
|
-
} });
|
|
9005
|
-
await this.databaseStorage.deleteFolder?.(folderId);
|
|
9006
|
-
},
|
|
9007
|
-
move: async (options) => {
|
|
9008
|
-
this.begin("folders.move", options);
|
|
9009
|
-
const { folderId, parentId } = options;
|
|
9010
|
-
const folder = await this.findOwnFolder(folderId);
|
|
9011
|
-
const targetParentId = parentId ?? null;
|
|
9012
|
-
if (targetParentId) {
|
|
9013
|
-
let candidate = await this.findOwnFolder(targetParentId);
|
|
9014
|
-
const visited = /* @__PURE__ */ new Set();
|
|
9015
|
-
while (candidate) {
|
|
9016
|
-
if (candidate.id === folderId || visited.has(candidate.id)) throw this.apiError(API_ERROR_CODES.FOLDER_PARENT_CYCLE);
|
|
9017
|
-
visited.add(candidate.id);
|
|
9018
|
-
candidate = candidate.parentId ? await this.resolveFolder(candidate.parentId) : void 0;
|
|
9019
|
-
}
|
|
9020
|
-
}
|
|
9021
|
-
if (targetParentId !== folder.parentId) await this.assertUniqueFolderName(folder.name, targetParentId, folderId);
|
|
9022
|
-
const updated = {
|
|
9023
|
-
...folder,
|
|
9024
|
-
parentId: targetParentId,
|
|
9025
|
-
updatedAt: this.now()
|
|
9026
|
-
};
|
|
9027
|
-
return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
|
|
9028
|
-
}
|
|
9281
|
+
query,
|
|
9282
|
+
items: searchAssets(await this.databaseStorage.listAssets(this.projectId), query, limit),
|
|
9283
|
+
semanticStatus: "disabled"
|
|
9029
9284
|
};
|
|
9030
9285
|
}
|
|
9031
|
-
async
|
|
9032
|
-
this.
|
|
9033
|
-
|
|
9034
|
-
return this.
|
|
9286
|
+
async listChildren(assetId, query) {
|
|
9287
|
+
const parent = await this.findOwnAsset(assetId);
|
|
9288
|
+
const assets = await this.databaseStorage.listAssets(this.projectId);
|
|
9289
|
+
return createPage(assets.filter((asset) => asset.parentId === parent.id), this.pageQuery(query), {
|
|
9290
|
+
scope: parent.id,
|
|
9291
|
+
fail: this.pageFailure
|
|
9292
|
+
});
|
|
9035
9293
|
}
|
|
9036
|
-
|
|
9037
|
-
const
|
|
9038
|
-
if (
|
|
9039
|
-
|
|
9040
|
-
id: this.createId(),
|
|
9041
|
-
projectId: this.projectId,
|
|
9042
|
-
userId: null,
|
|
9043
|
-
...options,
|
|
9044
|
-
createdAt: this.now(),
|
|
9045
|
-
updatedAt: this.now()
|
|
9046
|
-
};
|
|
9047
|
-
this.lenses.set(lens.identifier, lens);
|
|
9048
|
-
return lens;
|
|
9294
|
+
async updateAsset(assetId, body) {
|
|
9295
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9296
|
+
if (body.externalId) {
|
|
9297
|
+
if ((await this.databaseStorage.listAssets(this.projectId)).some((other) => other.id !== asset.id && other.externalId === body.externalId)) throw fail(API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
|
|
9049
9298
|
}
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9299
|
+
return this.databaseStorage.updateAsset({
|
|
9300
|
+
...asset,
|
|
9301
|
+
...body,
|
|
9302
|
+
updatedAt: this.now()
|
|
9303
|
+
});
|
|
9304
|
+
}
|
|
9305
|
+
async deleteAsset(assetId) {
|
|
9306
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9307
|
+
await this.imageStorage.deleteContent(asset.id);
|
|
9308
|
+
await this.databaseStorage.deleteAsset(asset.id);
|
|
9309
|
+
this.recordLifecycleEvent("asset.deleted", asset);
|
|
9310
|
+
}
|
|
9311
|
+
async moveAsset(assetId, body) {
|
|
9312
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9313
|
+
const folderId = body.folderId ?? null;
|
|
9314
|
+
if (folderId !== null && !await this.resolveFolder(folderId)) throw fail(API_ERROR_CODES.FOLDER_NOT_FOUND);
|
|
9315
|
+
return this.databaseStorage.updateAsset({
|
|
9316
|
+
...asset,
|
|
9317
|
+
folderId,
|
|
9318
|
+
updatedAt: this.now()
|
|
9319
|
+
});
|
|
9320
|
+
}
|
|
9321
|
+
async createSignedUrl(assetId, body) {
|
|
9322
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9323
|
+
const expiresInSeconds = body.expiresInSeconds ?? 900;
|
|
9324
|
+
const resource = body.resource ?? "rendered";
|
|
9325
|
+
const selector = (body.lens ? `&lens=${encodeURIComponent(String(body.lens))}` : "") + (body.transform ? `&transform=${encodeURIComponent(String(body.transform))}` : "");
|
|
9326
|
+
return {
|
|
9327
|
+
url: `${this.options.signedUrlBaseUrl}/assets/${asset.id}/${resource}?expiresInSeconds=${expiresInSeconds}${selector}`,
|
|
9328
|
+
expiresAt: this.now() + expiresInSeconds * 1e3
|
|
9329
|
+
};
|
|
9330
|
+
}
|
|
9331
|
+
async retryAsset(assetId) {
|
|
9332
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9333
|
+
if (asset.classification.status !== "failed" || asset.classification.moderation.status !== "pending") throw fail(API_ERROR_CODES.ASSET_NOT_RETRYABLE);
|
|
9334
|
+
if (asset.content.status === "removed") throw fail(API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
|
|
9335
|
+
const retried = await this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
|
|
9336
|
+
this.recordLifecycleEvent("asset.retried", retried);
|
|
9337
|
+
return this.moderateStored(retried, "retry");
|
|
9338
|
+
}
|
|
9339
|
+
async explainAsset(assetId, body) {
|
|
9340
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9341
|
+
if (asset.classification.status !== "completed") throw fail(API_ERROR_CODES.ASSET_NOT_DECIDED);
|
|
9342
|
+
if (!body.rerun) {
|
|
9343
|
+
if (asset.classification.moderation?.explanationComplete !== false) return asset;
|
|
9344
|
+
return this.databaseStorage.updateAsset({
|
|
9345
|
+
...asset,
|
|
9346
|
+
updatedAt: this.now(),
|
|
9347
|
+
classification: {
|
|
9348
|
+
...asset.classification,
|
|
9349
|
+
moderation: {
|
|
9350
|
+
...asset.classification.moderation,
|
|
9351
|
+
explanationComplete: true
|
|
9352
|
+
}
|
|
9353
|
+
}
|
|
9354
|
+
});
|
|
9059
9355
|
}
|
|
9060
|
-
throw
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
} });
|
|
9356
|
+
if (asset.classification.moderation.manualReviews.length) throw fail(API_ERROR_CODES.ASSET_REVIEWED);
|
|
9357
|
+
if (asset.content.status === "removed") throw fail(API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
|
|
9358
|
+
const rerun = await this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
|
|
9359
|
+
return this.moderateStored(rerun, "rerun");
|
|
9065
9360
|
}
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
if (
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
}
|
|
9083
|
-
const asset = await run();
|
|
9084
|
-
this.idempotentResults.set(key, {
|
|
9085
|
-
fingerprint,
|
|
9086
|
-
asset
|
|
9361
|
+
/**
|
|
9362
|
+
* Checks every asset like the API (an ID that is not an asset ID is
|
|
9363
|
+
* `validation/invalid-format`, an unknown
|
|
9364
|
+
* one `asset/not-found`, and either refuses the whole batch), then applies the operation at
|
|
9365
|
+
* once instead of in the background: `delete` deletes each asset with its stored child
|
|
9366
|
+
* assets and records `asset.deleted` with the asset as it was; `remove-content` removes
|
|
9367
|
+
* the content like `deleteContent`. Mock assets carry no safety holds, so none is skipped.
|
|
9368
|
+
*/
|
|
9369
|
+
async batch(body) {
|
|
9370
|
+
const assetIds = Array.isArray(body.assetIds) ? body.assetIds : [];
|
|
9371
|
+
const operation = body.operation;
|
|
9372
|
+
if (assetIds.length === 0) throw invalidField("validation/too-small", "assetIds");
|
|
9373
|
+
if (assetIds.length > 500) throw invalidField("validation/too-big", "assetIds");
|
|
9374
|
+
if (operation !== "delete" && operation !== "remove-content") throw invalidField("validation/invalid-value", "operation");
|
|
9375
|
+
assetIds.forEach((assetId, index) => {
|
|
9376
|
+
if (typeof assetId !== "string" || !isId(assetId, "asset")) throw invalidField("validation/invalid-format", `assetIds.${index}`);
|
|
9087
9377
|
});
|
|
9088
|
-
|
|
9378
|
+
const assets = [];
|
|
9379
|
+
for (const assetId of new Set(assetIds)) assets.push(await this.findOwnAsset(assetId));
|
|
9380
|
+
for (const asset of assets) if (operation === "delete") {
|
|
9381
|
+
await this.deleteAssetTree(asset);
|
|
9382
|
+
this.recordLifecycleEvent("asset.deleted", asset);
|
|
9383
|
+
} else await this.removeContent(asset);
|
|
9384
|
+
return { queued: true };
|
|
9089
9385
|
}
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
if (
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9386
|
+
/**
|
|
9387
|
+
* Removes an asset's content like the API: the record and report stay; stored content,
|
|
9388
|
+
* extracted text and the placeholder go. Content already removed keeps its reason, and an
|
|
9389
|
+
* `asset.content-removed` event is recorded either way.
|
|
9390
|
+
*/
|
|
9391
|
+
async removeContent(asset) {
|
|
9392
|
+
await this.imageStorage.deleteContent(asset.id);
|
|
9393
|
+
let removed = asset;
|
|
9394
|
+
if (asset.content.status === "removed") {
|
|
9395
|
+
if (asset.content.image?.thumbhash) removed = await this.databaseStorage.updateAsset({
|
|
9396
|
+
...asset,
|
|
9397
|
+
content: {
|
|
9398
|
+
...asset.content,
|
|
9399
|
+
image: withoutPlaceholder(asset.content.image)
|
|
9400
|
+
}
|
|
9401
|
+
});
|
|
9402
|
+
} else {
|
|
9403
|
+
const now = this.now();
|
|
9404
|
+
removed = await this.databaseStorage.updateAsset({
|
|
9405
|
+
...asset,
|
|
9406
|
+
context: null,
|
|
9407
|
+
content: {
|
|
9408
|
+
...asset.content,
|
|
9409
|
+
status: "removed",
|
|
9410
|
+
removalReason: "requested",
|
|
9411
|
+
updatedAt: now,
|
|
9412
|
+
extractedText: null,
|
|
9413
|
+
fileMetadata: null,
|
|
9414
|
+
image: withoutPlaceholder(asset.content.image)
|
|
9415
|
+
},
|
|
9416
|
+
updatedAt: now
|
|
9417
|
+
});
|
|
9418
|
+
}
|
|
9419
|
+
this.recordLifecycleEvent("asset.content-removed", removed);
|
|
9420
|
+
return removed;
|
|
9421
|
+
}
|
|
9422
|
+
/** Deletes an asset, its stored child assets and their content. */
|
|
9423
|
+
async deleteAssetTree(asset) {
|
|
9424
|
+
const assets = await this.databaseStorage.listAssets(this.projectId);
|
|
9425
|
+
const deleting = /* @__PURE__ */ new Set([asset.id]);
|
|
9426
|
+
for (let added = true; added;) {
|
|
9427
|
+
added = false;
|
|
9428
|
+
for (const other of assets) if (other.parentId && deleting.has(other.parentId) && !deleting.has(other.id)) {
|
|
9429
|
+
deleting.add(other.id);
|
|
9430
|
+
added = true;
|
|
9105
9431
|
}
|
|
9106
9432
|
}
|
|
9107
|
-
|
|
9108
|
-
this.
|
|
9109
|
-
|
|
9433
|
+
for (const id of deleting) {
|
|
9434
|
+
await this.imageStorage.deleteContent(id);
|
|
9435
|
+
await this.databaseStorage.deleteAsset(id);
|
|
9436
|
+
}
|
|
9437
|
+
}
|
|
9438
|
+
/** The upload fields of `POST .../assets`: raw uploads carry them in the query. */
|
|
9439
|
+
uploadFields(request) {
|
|
9440
|
+
const upload = request.headers.get("x-calmlens-upload");
|
|
9441
|
+
if (upload !== "content" && upload !== "part") return this.json(request);
|
|
9442
|
+
const { query } = request;
|
|
9443
|
+
const text = (name) => query.get(name) ?? void 0;
|
|
9444
|
+
const parsed = (name) => {
|
|
9445
|
+
const raw = query.get(name);
|
|
9446
|
+
if (raw === null) return void 0;
|
|
9447
|
+
try {
|
|
9448
|
+
return JSON.parse(raw);
|
|
9449
|
+
} catch {
|
|
9450
|
+
throw invalidField("validation/invalid-json", name);
|
|
9451
|
+
}
|
|
9452
|
+
};
|
|
9453
|
+
const total = query.get("totalSizeInBytes");
|
|
9454
|
+
return {
|
|
9455
|
+
name: query.get("name") ?? "",
|
|
9456
|
+
description: text("description"),
|
|
9457
|
+
processingOptions: parsed("processingOptions"),
|
|
9458
|
+
visibility: text("visibility"),
|
|
9459
|
+
metadata: parsed("metadata"),
|
|
9460
|
+
authorId: text("authorId"),
|
|
9461
|
+
context: parsed("context"),
|
|
9462
|
+
externalId: text("externalId"),
|
|
9463
|
+
externalUrl: text("externalUrl"),
|
|
9464
|
+
folderId: text("folderId"),
|
|
9465
|
+
uploadPresetId: text("uploadPresetId"),
|
|
9466
|
+
contentType: query.get("contentType") ?? request.headers.get("content-type") ?? "",
|
|
9467
|
+
bytes: request.bytes,
|
|
9468
|
+
part: upload === "part" ? {
|
|
9469
|
+
partNumber: this.integer(query, "partNumber", 1),
|
|
9470
|
+
isFinal: query.get("isFinal") === "true",
|
|
9471
|
+
totalSizeInBytes: total === null ? void 0 : this.integer(query, "totalSizeInBytes", 0)
|
|
9472
|
+
} : void 0
|
|
9473
|
+
};
|
|
9474
|
+
}
|
|
9475
|
+
async createAsset(request) {
|
|
9476
|
+
return this.storeAsset(this.uploadFields(request), request.query.get("queue") === "true");
|
|
9477
|
+
}
|
|
9478
|
+
async storeAsset(fields, queue) {
|
|
9479
|
+
if (!fields.name) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Asset name is required" });
|
|
9480
|
+
const { contentType, part } = fields;
|
|
9481
|
+
const bytes = fields.bytes ?? /* @__PURE__ */ new Uint8Array(0);
|
|
9482
|
+
if (contentType !== void 0 && isRejectedUploadMimeType(contentType)) throw fail(API_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
|
|
9483
|
+
message: getRejectedUploadMimeTypeError(contentType),
|
|
9484
|
+
details: { mimeType: contentType }
|
|
9485
|
+
});
|
|
9486
|
+
const type = inferAssetType(fields.url === void 0 ? contentType ?? "" : void 0);
|
|
9487
|
+
if (part) {
|
|
9488
|
+
if (part.partNumber > 1) throw fail(API_ERROR_CODES.USE_UPLOAD_PART);
|
|
9489
|
+
const partSize = bytes.byteLength;
|
|
9490
|
+
const sizeError = (part.totalSizeInBytes === void 0 ? void 0 : this.multipartImageSizeError(type, part.totalSizeInBytes, true)) ?? (part.isFinal ? this.multipartTotalSizeError(part.totalSizeInBytes, partSize, true) ?? this.multipartImageSizeError(type, partSize, true) : void 0);
|
|
9491
|
+
if (sizeError) throw fail(sizeError);
|
|
9492
|
+
const validation = validateAssetPayload({ uint8Array: bytes }, {
|
|
9110
9493
|
multipart: true,
|
|
9111
|
-
isFinal:
|
|
9494
|
+
isFinal: part.isFinal
|
|
9112
9495
|
});
|
|
9113
|
-
if (!validation.ok) throw
|
|
9496
|
+
if (!validation.ok) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
|
|
9497
|
+
if (!part.isFinal) {
|
|
9498
|
+
const totalError = this.multipartTotalSizeError(part.totalSizeInBytes, partSize, false);
|
|
9499
|
+
if (totalError) throw fail(totalError);
|
|
9500
|
+
}
|
|
9114
9501
|
}
|
|
9115
|
-
if (
|
|
9116
|
-
|
|
9502
|
+
if (fields.folderId) await this.findOwnFolder(fields.folderId);
|
|
9503
|
+
if (fields.externalId) {
|
|
9504
|
+
if ((await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.externalId === fields.externalId)) throw fail(API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
|
|
9117
9505
|
}
|
|
9118
|
-
const id = this.createId();
|
|
9506
|
+
const id = this.createId("asset");
|
|
9119
9507
|
const now = this.now();
|
|
9120
|
-
const isOpenUpload =
|
|
9121
|
-
const
|
|
9508
|
+
const isOpenUpload = part !== void 0 && !part.isFinal;
|
|
9509
|
+
const processingOptions = fields.processingOptions ?? { keepContentAfterProcessing: false };
|
|
9510
|
+
const keepContent = processingOptions.keepContentAfterProcessing === true;
|
|
9511
|
+
const hasContent = fields.url === void 0;
|
|
9512
|
+
const contextItems = selectModerationContextItems(fields.context?.items ?? []);
|
|
9122
9513
|
const asset = {
|
|
9123
9514
|
id,
|
|
9124
9515
|
projectId: this.projectId,
|
|
9125
|
-
|
|
9516
|
+
parentId: null,
|
|
9517
|
+
folderId: fields.folderId ?? null,
|
|
9518
|
+
uploadPresetId: fields.uploadPresetId ?? null,
|
|
9126
9519
|
createdAt: now,
|
|
9127
9520
|
updatedAt: now,
|
|
9128
|
-
name:
|
|
9129
|
-
description:
|
|
9130
|
-
externalId:
|
|
9131
|
-
externalUrl:
|
|
9132
|
-
type
|
|
9133
|
-
metadata:
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
uploadPartsReceived: "part" in options ? options.part.partNumber ?? 1 : null
|
|
9140
|
-
},
|
|
9141
|
-
moderation: {
|
|
9142
|
-
status: "pending",
|
|
9143
|
-
action: null,
|
|
9144
|
-
note: null,
|
|
9145
|
-
reviewerAction: null,
|
|
9146
|
-
reviewerNote: null,
|
|
9147
|
-
reviewerUserId: null,
|
|
9148
|
-
reviewedAt: null,
|
|
9149
|
-
decidedAt: null,
|
|
9150
|
-
report: null
|
|
9151
|
-
},
|
|
9521
|
+
name: fields.name,
|
|
9522
|
+
description: fields.description ?? null,
|
|
9523
|
+
externalId: fields.externalId ?? null,
|
|
9524
|
+
externalUrl: fields.url ?? fields.externalUrl ?? null,
|
|
9525
|
+
type,
|
|
9526
|
+
metadata: fields.metadata ?? null,
|
|
9527
|
+
authorId: fields.authorId ?? null,
|
|
9528
|
+
context: contextItems.length ? { items: contextItems } : null,
|
|
9529
|
+
tags: null,
|
|
9530
|
+
processingOptions,
|
|
9531
|
+
visibility: fields.visibility ?? "private",
|
|
9152
9532
|
content: {
|
|
9153
9533
|
status: keepContent ? "stored" : "temporary",
|
|
9154
9534
|
removalReason: null,
|
|
9155
9535
|
updatedAt: now,
|
|
9156
9536
|
safetyHold: null,
|
|
9157
|
-
format:
|
|
9537
|
+
format: hasContent ? contentType ?? null : null,
|
|
9158
9538
|
extension: null,
|
|
9159
|
-
sizeInBytes:
|
|
9539
|
+
sizeInBytes: hasContent ? bytes.byteLength : null,
|
|
9160
9540
|
checksums: null,
|
|
9161
9541
|
image: null,
|
|
9162
|
-
|
|
9163
|
-
|
|
9542
|
+
extractedText: null,
|
|
9543
|
+
fileMetadata: null
|
|
9544
|
+
},
|
|
9545
|
+
upload: {
|
|
9546
|
+
status: isOpenUpload ? "uploading" : "completed",
|
|
9547
|
+
updatedAt: now,
|
|
9548
|
+
partsReceived: isOpenUpload ? part.partNumber : null
|
|
9549
|
+
},
|
|
9550
|
+
classification: {
|
|
9551
|
+
status: isOpenUpload ? "pending" : "processing",
|
|
9552
|
+
failureReason: null,
|
|
9553
|
+
updatedAt: now,
|
|
9554
|
+
completedAt: null,
|
|
9555
|
+
moderation: {
|
|
9556
|
+
status: "pending",
|
|
9557
|
+
manualReviews: [],
|
|
9558
|
+
flaggedCategories: [],
|
|
9559
|
+
regions: []
|
|
9560
|
+
}
|
|
9164
9561
|
}
|
|
9165
9562
|
};
|
|
9166
|
-
if (
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9563
|
+
if (part) {
|
|
9564
|
+
await this.imageStorage.putPart(id, {
|
|
9565
|
+
uint8Array: bytes,
|
|
9566
|
+
type: contentType,
|
|
9567
|
+
partNumber: part.partNumber,
|
|
9568
|
+
isFinal: part.isFinal
|
|
9569
|
+
});
|
|
9570
|
+
if (isOpenUpload) this.openUploads.set(id, {
|
|
9571
|
+
queue,
|
|
9572
|
+
totalSizeInBytes: part.totalSizeInBytes
|
|
9573
|
+
});
|
|
9574
|
+
} else if (hasContent) await this.imageStorage.putContent(id, {
|
|
9575
|
+
uint8Array: bytes,
|
|
9576
|
+
type: contentType ?? ""
|
|
9577
|
+
});
|
|
9578
|
+
if (isOpenUpload) return this.databaseStorage.createAsset(asset);
|
|
9579
|
+
const decision = await this.decisionFor(asset, "upload", queue, hasContent ? bytes : null);
|
|
9580
|
+
const stored = await this.databaseStorage.createAsset(decision.status === "pending" ? asset : await this.decided(asset, decision));
|
|
9581
|
+
this.recordUploaded(asset);
|
|
9582
|
+
this.recordProcessingEvents(stored, decision);
|
|
9583
|
+
return stored;
|
|
9584
|
+
}
|
|
9585
|
+
/** Continues a multipart upload: parts must arrive in order while it is `uploading`. */
|
|
9586
|
+
async uploadPart(assetId, request) {
|
|
9587
|
+
const { query, bytes } = request;
|
|
9588
|
+
const partNumber = this.integer(query, "partNumber", 0);
|
|
9589
|
+
const isFinal = query.get("isFinal") === "true";
|
|
9590
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9591
|
+
if (asset.upload.status !== "uploading") throw fail(API_ERROR_CODES.UPLOAD_NOT_OPEN);
|
|
9592
|
+
const expectedPartNumber = (asset.upload.partsReceived ?? 0) + 1;
|
|
9593
|
+
if (partNumber !== expectedPartNumber) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PART_NUMBER, {
|
|
9594
|
+
message: `Expected part ${expectedPartNumber}`,
|
|
9595
|
+
details: {
|
|
9596
|
+
expectedPartNumber,
|
|
9597
|
+
receivedPartNumber: partNumber
|
|
9598
|
+
}
|
|
9599
|
+
});
|
|
9600
|
+
const received = (asset.content.sizeInBytes ?? 0) + bytes.byteLength;
|
|
9601
|
+
const upload = this.openUploads.get(assetId);
|
|
9602
|
+
const sizeError = this.multipartImageSizeError(asset.type, received, isFinal) ?? this.multipartTotalSizeError(upload?.totalSizeInBytes, received, isFinal);
|
|
9603
|
+
if (sizeError) throw fail(sizeError);
|
|
9604
|
+
const validation = validateAssetPayload({ uint8Array: bytes }, {
|
|
9605
|
+
multipart: true,
|
|
9606
|
+
isFinal
|
|
9607
|
+
});
|
|
9608
|
+
if (!validation.ok) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
|
|
9609
|
+
await this.imageStorage.putPart(assetId, {
|
|
9610
|
+
uint8Array: bytes,
|
|
9611
|
+
partNumber,
|
|
9612
|
+
isFinal
|
|
9613
|
+
});
|
|
9614
|
+
const queueOverride = query.get("queue");
|
|
9615
|
+
const queue = queueOverride === null ? upload?.queue ?? false : queueOverride === "true";
|
|
9616
|
+
if (isFinal) this.openUploads.delete(assetId);
|
|
9617
|
+
const now = this.now();
|
|
9618
|
+
const next = {
|
|
9619
|
+
...asset,
|
|
9620
|
+
updatedAt: now,
|
|
9621
|
+
content: {
|
|
9622
|
+
...asset.content,
|
|
9623
|
+
sizeInBytes: received
|
|
9624
|
+
},
|
|
9625
|
+
upload: {
|
|
9626
|
+
...asset.upload,
|
|
9627
|
+
updatedAt: now,
|
|
9628
|
+
partsReceived: isFinal ? null : partNumber
|
|
9629
|
+
}
|
|
9630
|
+
};
|
|
9631
|
+
if (!isFinal) return this.databaseStorage.updateAsset(next);
|
|
9632
|
+
const processing = await this.applyOutcome(next, "pending");
|
|
9633
|
+
const decision = await this.decisionFor(processing, "upload", queue);
|
|
9634
|
+
const stored = await this.databaseStorage.updateAsset(decision.status === "pending" ? processing : await this.decided(processing, decision));
|
|
9635
|
+
this.recordUploaded(processing);
|
|
9636
|
+
this.recordProcessingEvents(stored, decision);
|
|
9637
|
+
return stored;
|
|
9638
|
+
}
|
|
9639
|
+
/** Like the API: image uploads in parts total 10 MiB (checked at the last part) to 20 MiB. */
|
|
9640
|
+
multipartImageSizeError(type, sizeInBytes, isFinal) {
|
|
9641
|
+
if (type !== "image") return void 0;
|
|
9642
|
+
if (sizeInBytes > MAX_IMAGE_UPLOAD_BYTES) return API_ERROR_CODES.MULTIPART_IMAGE_TOO_LARGE;
|
|
9643
|
+
if (isFinal && sizeInBytes < MIN_MULTIPART_IMAGE_UPLOAD_BYTES) return API_ERROR_CODES.MULTIPART_IMAGE_TOO_SMALL;
|
|
9644
|
+
}
|
|
9645
|
+
/** Like the API: the parts may not overshoot a declared total, and must reach it at the end. */
|
|
9646
|
+
multipartTotalSizeError(totalSizeInBytes, receivedSizeInBytes, isFinal) {
|
|
9647
|
+
if (totalSizeInBytes === void 0) return void 0;
|
|
9648
|
+
return receivedSizeInBytes > totalSizeInBytes || isFinal && receivedSizeInBytes !== totalSizeInBytes ? API_ERROR_CODES.UPLOAD_SIZE_MISMATCH : void 0;
|
|
9649
|
+
}
|
|
9650
|
+
/**
|
|
9651
|
+
* Asks `moderate` how to decide `asset`, with its content: `bytes` when the caller has them,
|
|
9652
|
+
* otherwise the stored parts in order (none once the content is removed).
|
|
9653
|
+
*/
|
|
9654
|
+
async decisionFor(asset, trigger, queued, bytes) {
|
|
9655
|
+
let data = bytes;
|
|
9656
|
+
if (data === void 0) {
|
|
9657
|
+
const stored = asset.content.status === "removed" ? void 0 : await this.imageStorage.getContent(asset.id);
|
|
9658
|
+
const parts = stored ? [...stored.parts].sort(([a], [b]) => a - b).map(([, part]) => part) : [];
|
|
9659
|
+
data = stored ? new Uint8Array(await new Blob(parts).arrayBuffer()) : null;
|
|
9660
|
+
}
|
|
9661
|
+
const content = {
|
|
9662
|
+
bytes: data,
|
|
9663
|
+
text: data && asset.type === "plaintext-document" ? new TextDecoder().decode(data) : null
|
|
9664
|
+
};
|
|
9665
|
+
return toDecision(await this.options.moderate({
|
|
9666
|
+
asset: structuredClone(asset),
|
|
9667
|
+
content,
|
|
9668
|
+
trigger,
|
|
9669
|
+
queued
|
|
9670
|
+
}));
|
|
9671
|
+
}
|
|
9672
|
+
/** `asset` decided as `decision` says, with its report. */
|
|
9673
|
+
decided(asset, decision) {
|
|
9674
|
+
return this.applyOutcome(asset, decision.status, "policy", decision.report());
|
|
9675
|
+
}
|
|
9676
|
+
/**
|
|
9677
|
+
* Stores a processing asset's `decision` and records its events; a `pending` decision leaves it
|
|
9678
|
+
* processing.
|
|
9679
|
+
*/
|
|
9680
|
+
async settle(asset, decision) {
|
|
9681
|
+
if (decision.status === "pending") return asset;
|
|
9682
|
+
const stored = await this.databaseStorage.updateAsset(await this.decided(asset, decision));
|
|
9683
|
+
this.recordProcessingEvents(stored, decision);
|
|
9684
|
+
return stored;
|
|
9685
|
+
}
|
|
9686
|
+
/** Asks `moderate` (unless `decision` is given) and settles a processing asset. */
|
|
9687
|
+
async moderateStored(asset, trigger, decision) {
|
|
9688
|
+
return this.settle(asset, decision ?? await this.decisionFor(asset, trigger, true));
|
|
9689
|
+
}
|
|
9690
|
+
/** `asset.uploaded`, as the API records it when an upload completes (UploadAssetUtils.ts). */
|
|
9691
|
+
recordUploaded(asset) {
|
|
9692
|
+
this.pushEvent({
|
|
9693
|
+
type: "asset.uploaded",
|
|
9694
|
+
severity: "low",
|
|
9695
|
+
assetId: asset.id,
|
|
9696
|
+
asset: toEventAsset(asset),
|
|
9697
|
+
facts: [
|
|
9698
|
+
{
|
|
9699
|
+
key: "asset-type",
|
|
9700
|
+
value: asset.type
|
|
9701
|
+
},
|
|
9702
|
+
assetNameFact(asset),
|
|
9703
|
+
{
|
|
9704
|
+
key: "file-format",
|
|
9705
|
+
value: asset.content.format ?? null
|
|
9706
|
+
}
|
|
9707
|
+
]
|
|
9708
|
+
});
|
|
9709
|
+
}
|
|
9710
|
+
/**
|
|
9711
|
+
* The events the pipeline records when it settles an asset (AssetClassification.ts and
|
|
9712
|
+
* AssetErrorAlert.ts): `asset.classified` and the automated `asset.<status>`, or
|
|
9713
|
+
* `asset.failed`.
|
|
9714
|
+
*/
|
|
9715
|
+
recordProcessingEvents(asset, decision) {
|
|
9716
|
+
if (decision.status === "pending") return;
|
|
9717
|
+
const event = {
|
|
9718
|
+
assetId: asset.id,
|
|
9719
|
+
asset: toEventAsset(asset)
|
|
9720
|
+
};
|
|
9721
|
+
if (decision.status === "failed") {
|
|
9722
|
+
this.pushEvent({
|
|
9723
|
+
...event,
|
|
9724
|
+
type: "asset.failed",
|
|
9725
|
+
severity: "high",
|
|
9726
|
+
facts: [
|
|
9727
|
+
assetNameFact(asset),
|
|
9728
|
+
{
|
|
9729
|
+
key: "asset-type",
|
|
9730
|
+
value: asset.type
|
|
9731
|
+
},
|
|
9732
|
+
{
|
|
9733
|
+
key: "previous-status",
|
|
9734
|
+
value: "processing"
|
|
9735
|
+
},
|
|
9736
|
+
{
|
|
9737
|
+
key: "failure-reason",
|
|
9738
|
+
value: asset.classification.failureReason ?? "processing-error"
|
|
9739
|
+
}
|
|
9740
|
+
]
|
|
9741
|
+
});
|
|
9742
|
+
return;
|
|
9171
9743
|
}
|
|
9172
|
-
|
|
9744
|
+
const flaggedCategories = decision.report()?.flaggedCategories ?? [];
|
|
9745
|
+
this.pushEvent({
|
|
9746
|
+
...event,
|
|
9747
|
+
type: "asset.classified",
|
|
9748
|
+
severity: "low",
|
|
9749
|
+
facts: [
|
|
9750
|
+
{
|
|
9751
|
+
key: "flagged-categories",
|
|
9752
|
+
value: flaggedCategories
|
|
9753
|
+
},
|
|
9754
|
+
{
|
|
9755
|
+
key: "moderation-status",
|
|
9756
|
+
value: decision.status
|
|
9757
|
+
},
|
|
9758
|
+
{
|
|
9759
|
+
key: "asset-type",
|
|
9760
|
+
value: asset.type
|
|
9761
|
+
},
|
|
9762
|
+
assetNameFact(asset)
|
|
9763
|
+
]
|
|
9764
|
+
});
|
|
9765
|
+
this.pushEvent({
|
|
9766
|
+
...event,
|
|
9767
|
+
type: `asset.${decision.status}`,
|
|
9768
|
+
severity: decision.status === "allowed" || decision.status === "limited" ? "low" : "high",
|
|
9769
|
+
facts: [
|
|
9770
|
+
{
|
|
9771
|
+
key: "decided-by",
|
|
9772
|
+
value: "automated"
|
|
9773
|
+
},
|
|
9774
|
+
{
|
|
9775
|
+
key: "flagged-categories",
|
|
9776
|
+
value: flaggedCategories
|
|
9777
|
+
},
|
|
9778
|
+
{
|
|
9779
|
+
key: "asset-type",
|
|
9780
|
+
value: asset.type
|
|
9781
|
+
},
|
|
9782
|
+
assetNameFact(asset)
|
|
9783
|
+
]
|
|
9784
|
+
});
|
|
9173
9785
|
}
|
|
9174
9786
|
/**
|
|
9175
|
-
* Moves an asset to `outcome` like the pipeline would: a decision finishes processing,
|
|
9176
|
-
*
|
|
9787
|
+
* Moves an asset to `outcome` like the pipeline would: a decision finishes processing, `purged`
|
|
9788
|
+
* deletes the content (with `purgeReason`, `policy` for the pipeline), and finished processing
|
|
9789
|
+
* removes content kept only temporarily (`keepContentAfterProcessing` off). A classified image
|
|
9790
|
+
* gets a ThumbHash for its placeholder, which goes with purged content.
|
|
9177
9791
|
*/
|
|
9178
|
-
async applyOutcome(asset, outcome) {
|
|
9792
|
+
async applyOutcome(asset, outcome, purgeReason = "policy", report) {
|
|
9179
9793
|
const now = this.now();
|
|
9180
|
-
const decided = outcome
|
|
9181
|
-
const
|
|
9182
|
-
const
|
|
9794
|
+
const decided = outcome !== "pending" && outcome !== "failed";
|
|
9795
|
+
const classificationStatus = decided ? "completed" : outcome === "failed" ? "failed" : "processing";
|
|
9796
|
+
const purge = outcome === "purged" && asset.content.status !== "removed";
|
|
9797
|
+
const removeContent = purge || classificationStatus === "completed" && asset.content.status === "temporary";
|
|
9183
9798
|
if (removeContent) await this.imageStorage.deleteContent(asset.id);
|
|
9799
|
+
const image = decided && asset.type === "image" && asset.content.status !== "removed" && !purge ? {
|
|
9800
|
+
width: null,
|
|
9801
|
+
height: null,
|
|
9802
|
+
perceptualHash: null,
|
|
9803
|
+
perceptualHashQuality: null,
|
|
9804
|
+
...asset.content.image,
|
|
9805
|
+
thumbhash: MOCK_THUMBHASH,
|
|
9806
|
+
averageColor: asset.content.image?.averageColor ?? "#7f7f7f"
|
|
9807
|
+
} : outcome === "purged" ? withoutPlaceholder(asset.content.image) : asset.content.image;
|
|
9184
9808
|
return {
|
|
9185
9809
|
...asset,
|
|
9186
9810
|
updatedAt: now,
|
|
9187
|
-
|
|
9188
|
-
...asset.processing,
|
|
9189
|
-
status: processingStatus,
|
|
9190
|
-
updatedAt: now
|
|
9191
|
-
},
|
|
9192
|
-
moderation: {
|
|
9193
|
-
...asset.moderation,
|
|
9194
|
-
status: decided ? outcome : "pending",
|
|
9195
|
-
decidedAt: decided ? now : null
|
|
9196
|
-
},
|
|
9811
|
+
...removeContent && { context: null },
|
|
9197
9812
|
content: removeContent ? {
|
|
9198
9813
|
...asset.content,
|
|
9199
9814
|
status: "removed",
|
|
9200
|
-
removalReason: "retention",
|
|
9815
|
+
removalReason: purge ? purgeReason : "retention",
|
|
9201
9816
|
updatedAt: now,
|
|
9202
|
-
extractedText: null
|
|
9203
|
-
|
|
9817
|
+
extractedText: null,
|
|
9818
|
+
fileMetadata: null,
|
|
9819
|
+
image: withoutPlaceholder(image)
|
|
9820
|
+
} : {
|
|
9821
|
+
...asset.content,
|
|
9822
|
+
image
|
|
9823
|
+
},
|
|
9824
|
+
upload: {
|
|
9825
|
+
...asset.upload,
|
|
9826
|
+
status: "completed",
|
|
9827
|
+
updatedAt: now
|
|
9828
|
+
},
|
|
9829
|
+
classification: {
|
|
9830
|
+
...asset.classification,
|
|
9831
|
+
status: classificationStatus,
|
|
9832
|
+
failureReason: classificationStatus === "failed" ? "processing-error" : null,
|
|
9833
|
+
updatedAt: now,
|
|
9834
|
+
completedAt: decided ? now : null,
|
|
9835
|
+
moderation: {
|
|
9836
|
+
...asset.classification.moderation,
|
|
9837
|
+
status: decided ? outcome : "pending",
|
|
9838
|
+
...report
|
|
9839
|
+
}
|
|
9840
|
+
}
|
|
9841
|
+
};
|
|
9842
|
+
}
|
|
9843
|
+
/** Adds an `asset.deleted`, `asset.content-removed` or `asset.retried` event, like the API. */
|
|
9844
|
+
recordLifecycleEvent(type, asset) {
|
|
9845
|
+
this.pushEvent({
|
|
9846
|
+
type,
|
|
9847
|
+
severity: "low",
|
|
9848
|
+
assetId: asset.id,
|
|
9849
|
+
asset: toEventAsset(asset),
|
|
9850
|
+
facts: [{
|
|
9851
|
+
key: "asset-name",
|
|
9852
|
+
value: asset.name,
|
|
9853
|
+
...type === "asset.deleted" ? {} : { url: `https://calmlens.com/go/asset/${asset.id}` }
|
|
9854
|
+
}, {
|
|
9855
|
+
key: "asset-type",
|
|
9856
|
+
value: asset.type
|
|
9857
|
+
}]
|
|
9858
|
+
});
|
|
9859
|
+
}
|
|
9860
|
+
/**
|
|
9861
|
+
* `/upload-presets/:id/assets[/:assetId[/part]]`: uploads through the preset, with its rules
|
|
9862
|
+
* applied in the API's order (token, content type, size), and checks of its uploads.
|
|
9863
|
+
*/
|
|
9864
|
+
async presetRoute(request, uploadPresetId, rest, session) {
|
|
9865
|
+
if (!session) throw fail(API_ERROR_CODES.UPLOAD_PRESET_NOT_FOUND);
|
|
9866
|
+
const { preset } = session;
|
|
9867
|
+
const toUploaded = (asset) => ({
|
|
9868
|
+
id: asset.id,
|
|
9869
|
+
name: asset.name,
|
|
9870
|
+
uploadPresetId,
|
|
9871
|
+
createdAt: asset.createdAt,
|
|
9872
|
+
...preset.showDecisions !== false && { status: asset.classification.moderation.status }
|
|
9873
|
+
});
|
|
9874
|
+
const [assetId, action] = rest;
|
|
9875
|
+
if (assetId !== void 0) {
|
|
9876
|
+
if (!session.uploads.has(assetId)) throw fail(API_ERROR_CODES.ASSET_NOT_FOUND);
|
|
9877
|
+
const asset = await this.findOwnAsset(assetId);
|
|
9878
|
+
if (request.method === "POST" && action === "part") return toUploaded(await this.uploadPart(assetId, request));
|
|
9879
|
+
if (request.method === "GET" && action === void 0) return toUploaded(asset);
|
|
9880
|
+
throw new ApiFailure(404, "not-found", "No such route");
|
|
9881
|
+
}
|
|
9882
|
+
if (request.method !== "POST") throw new ApiFailure(404, "not-found", "No such route");
|
|
9883
|
+
const fields = this.uploadFields(request);
|
|
9884
|
+
const token = request.headers.get("authorization");
|
|
9885
|
+
if (preset.requireToken && !token) throw fail(API_ERROR_CODES.UPLOAD_PRESET_TOKEN_REQUIRED);
|
|
9886
|
+
if (preset.allowedContentTypes && !isFileAccepted({
|
|
9887
|
+
name: fields.name,
|
|
9888
|
+
type: fields.contentType ?? ""
|
|
9889
|
+
}, preset.allowedContentTypes.join(","))) throw fail(API_ERROR_CODES.UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED);
|
|
9890
|
+
const size = fields.part?.totalSizeInBytes ?? fields.bytes?.byteLength;
|
|
9891
|
+
if (preset.maxFileSizeInBytes !== void 0 && size !== void 0 && size > preset.maxFileSizeInBytes) throw fail(API_ERROR_CODES.UPLOAD_PRESET_TOO_LARGE);
|
|
9892
|
+
const asset = await this.storeAsset({
|
|
9893
|
+
...fields,
|
|
9894
|
+
uploadPresetId,
|
|
9895
|
+
visibility: preset.visibility ?? "public",
|
|
9896
|
+
folderId: preset.folderId ?? void 0,
|
|
9897
|
+
processingOptions: { keepContentAfterProcessing: preset.keepContentAfterProcessing ?? true }
|
|
9898
|
+
}, request.query.get("queue") === "true");
|
|
9899
|
+
session.uploads.add(asset.id);
|
|
9900
|
+
return toUploaded(asset);
|
|
9901
|
+
}
|
|
9902
|
+
/**
|
|
9903
|
+
* Reads the feed like the API: without `cursor`, no events and a `nextCursor` for "now"; with
|
|
9904
|
+
* `cursor`, up to `pageSize` events added since, oldest first. `nextCursor` is never null: it
|
|
9905
|
+
* is where to keep polling from.
|
|
9906
|
+
*/
|
|
9907
|
+
listEvents(query) {
|
|
9908
|
+
const pageSize = this.integer(query, "pageSize", 50);
|
|
9909
|
+
if (pageSize < 1) throw invalidField("validation/too-small", "pageSize");
|
|
9910
|
+
if (pageSize > MAX_PAGE_SIZE) throw invalidField("validation/too-big", "pageSize");
|
|
9911
|
+
const cursor = query.get("cursor");
|
|
9912
|
+
if (!cursor) return {
|
|
9913
|
+
items: [],
|
|
9914
|
+
nextCursor: `mock_${this.eventFeed.length}`,
|
|
9915
|
+
hasMore: false
|
|
9916
|
+
};
|
|
9917
|
+
const match = /^mock_(\d+)$/.exec(cursor);
|
|
9918
|
+
const position = match ? Number(match[1]) : NaN;
|
|
9919
|
+
if (!Number.isSafeInteger(position) || position > this.eventFeed.length) throw fail(API_ERROR_CODES.INVALID_CURSOR);
|
|
9920
|
+
const items = this.eventFeed.slice(position, position + pageSize);
|
|
9921
|
+
const next = position + items.length;
|
|
9922
|
+
return {
|
|
9923
|
+
items,
|
|
9924
|
+
nextCursor: `mock_${next}`,
|
|
9925
|
+
hasMore: next < this.eventFeed.length
|
|
9204
9926
|
};
|
|
9205
9927
|
}
|
|
9928
|
+
/**
|
|
9929
|
+
* Lists folders like the API: one keyset page of a parent's children, optionally matching
|
|
9930
|
+
* `search`, by name unless `ordering` says otherwise, with the folders above them.
|
|
9931
|
+
*/
|
|
9932
|
+
async listFolders(query) {
|
|
9933
|
+
const all = await this.databaseStorage.listFolders?.(this.projectId) ?? [];
|
|
9934
|
+
const parentId = query.get("parentId");
|
|
9935
|
+
const search = query.get("search")?.trim() || null;
|
|
9936
|
+
const ordering = this.folderOrdering(query);
|
|
9937
|
+
const matching = all.filter((folder) => (folder.parentId ?? null) === parentId && (!search || folder.name.toLowerCase().includes(search.toLowerCase())));
|
|
9938
|
+
const page = createPage(matching, {
|
|
9939
|
+
cursor: query.get("cursor") ?? void 0,
|
|
9940
|
+
pageSize: this.integer(query, "pageSize", 50),
|
|
9941
|
+
ordering
|
|
9942
|
+
}, {
|
|
9943
|
+
scope: JSON.stringify([
|
|
9944
|
+
this.projectId,
|
|
9945
|
+
parentId,
|
|
9946
|
+
search
|
|
9947
|
+
]),
|
|
9948
|
+
fail: this.pageFailure,
|
|
9949
|
+
defaultOrdering: [{
|
|
9950
|
+
field: "name",
|
|
9951
|
+
direction: "asc"
|
|
9952
|
+
}]
|
|
9953
|
+
});
|
|
9954
|
+
const folders = page.items;
|
|
9955
|
+
const ids = /* @__PURE__ */ new Set([...parentId ? [parentId] : [], ...folders.map((folder) => folder.id)]);
|
|
9956
|
+
const byId = new Map(all.map((folder) => [folder.id, folder]));
|
|
9957
|
+
for (const id of ids) {
|
|
9958
|
+
const parent = byId.get(id)?.parentId;
|
|
9959
|
+
if (parent) ids.add(parent);
|
|
9960
|
+
}
|
|
9961
|
+
return {
|
|
9962
|
+
items: folders,
|
|
9963
|
+
ancestors: all.filter((folder) => ids.has(folder.id) && !folders.some((item) => item.id === folder.id)),
|
|
9964
|
+
nextCursor: page.nextCursor,
|
|
9965
|
+
pageSize: page.pageSize,
|
|
9966
|
+
hasMore: page.hasMore,
|
|
9967
|
+
ordering: page.ordering
|
|
9968
|
+
};
|
|
9969
|
+
}
|
|
9970
|
+
/** The folder list's `ordering`: at most one rule, by `name` or `createdAt`. */
|
|
9971
|
+
folderOrdering(query) {
|
|
9972
|
+
const ordering = this.ordering(query);
|
|
9973
|
+
if (ordering.length > 1) throw invalidField("validation/too-big", "ordering");
|
|
9974
|
+
for (const [index, rule] of ordering.entries()) {
|
|
9975
|
+
if (rule.field !== "name" && rule.field !== "createdAt") throw invalidField("validation/invalid-value", `ordering.${index}.field`);
|
|
9976
|
+
if (rule.direction !== "asc" && rule.direction !== "desc") throw invalidField("validation/invalid-value", `ordering.${index}.direction`);
|
|
9977
|
+
}
|
|
9978
|
+
return ordering;
|
|
9979
|
+
}
|
|
9980
|
+
async createFolder(body) {
|
|
9981
|
+
const name = body.name;
|
|
9982
|
+
if (typeof name !== "string" || !name) throw fail(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Folder name is required" });
|
|
9983
|
+
const parentId = body.parentId ?? null;
|
|
9984
|
+
if (parentId) await this.findOwnFolder(parentId);
|
|
9985
|
+
await this.assertUniqueFolderName(name, parentId);
|
|
9986
|
+
const now = this.now();
|
|
9987
|
+
const folder = {
|
|
9988
|
+
id: this.createId("fldr"),
|
|
9989
|
+
projectId: this.projectId,
|
|
9990
|
+
parentId,
|
|
9991
|
+
name,
|
|
9992
|
+
createdAt: now,
|
|
9993
|
+
updatedAt: now
|
|
9994
|
+
};
|
|
9995
|
+
return this.databaseStorage.createFolder ? await this.databaseStorage.createFolder(folder) : folder;
|
|
9996
|
+
}
|
|
9997
|
+
async updateFolder(folderId, body) {
|
|
9998
|
+
const folder = await this.findOwnFolder(folderId);
|
|
9999
|
+
const name = body.name;
|
|
10000
|
+
if (name !== void 0 && name !== folder.name) await this.assertUniqueFolderName(name, folder.parentId ?? null, folderId);
|
|
10001
|
+
const updated = {
|
|
10002
|
+
...folder,
|
|
10003
|
+
...name !== void 0 ? { name } : {},
|
|
10004
|
+
updatedAt: this.now()
|
|
10005
|
+
};
|
|
10006
|
+
return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
|
|
10007
|
+
}
|
|
10008
|
+
async deleteFolder(folderId) {
|
|
10009
|
+
await this.findOwnFolder(folderId);
|
|
10010
|
+
const hasAssets = (await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.folderId === folderId);
|
|
10011
|
+
const hasChildren = (await this.databaseStorage.listFolders?.(this.projectId) ?? []).some((folder) => folder.parentId === folderId);
|
|
10012
|
+
if (hasAssets || hasChildren) throw fail(API_ERROR_CODES.FOLDER_NOT_EMPTY, { details: {
|
|
10013
|
+
hasAssets,
|
|
10014
|
+
hasChildren
|
|
10015
|
+
} });
|
|
10016
|
+
await this.databaseStorage.deleteFolder?.(folderId);
|
|
10017
|
+
}
|
|
10018
|
+
async moveFolder(folderId, body) {
|
|
10019
|
+
const folder = await this.findOwnFolder(folderId);
|
|
10020
|
+
const targetParentId = body.parentId ?? null;
|
|
10021
|
+
if (targetParentId) {
|
|
10022
|
+
let candidate = await this.findOwnFolder(targetParentId);
|
|
10023
|
+
const visited = /* @__PURE__ */ new Set();
|
|
10024
|
+
while (candidate) {
|
|
10025
|
+
if (candidate.id === folderId || visited.has(candidate.id)) throw fail(API_ERROR_CODES.FOLDER_PARENT_CYCLE);
|
|
10026
|
+
visited.add(candidate.id);
|
|
10027
|
+
candidate = candidate.parentId ? await this.resolveFolder(candidate.parentId) : void 0;
|
|
10028
|
+
}
|
|
10029
|
+
}
|
|
10030
|
+
if (targetParentId !== folder.parentId) await this.assertUniqueFolderName(folder.name, targetParentId, folderId);
|
|
10031
|
+
const updated = {
|
|
10032
|
+
...folder,
|
|
10033
|
+
parentId: targetParentId,
|
|
10034
|
+
updatedAt: this.now()
|
|
10035
|
+
};
|
|
10036
|
+
return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
|
|
10037
|
+
}
|
|
9206
10038
|
async resolveFolder(folderId) {
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
if (!folder || folder.projectId !== this.projectId) return;
|
|
9210
|
-
return folder;
|
|
10039
|
+
const folder = await this.databaseStorage.getFolder?.(folderId);
|
|
10040
|
+
return folder && folder.projectId === this.projectId ? folder : void 0;
|
|
9211
10041
|
}
|
|
9212
10042
|
async findOwnFolder(folderId) {
|
|
9213
|
-
encodePathSegment(folderId, "Folder ID");
|
|
9214
10043
|
const folder = await this.resolveFolder(folderId);
|
|
9215
|
-
if (!folder) throw
|
|
10044
|
+
if (!folder) throw fail(API_ERROR_CODES.FOLDER_NOT_FOUND);
|
|
9216
10045
|
return folder;
|
|
9217
10046
|
}
|
|
9218
10047
|
async assertUniqueFolderName(name, parentId, excludeFolderId) {
|
|
9219
|
-
if ((
|
|
10048
|
+
if ((await this.databaseStorage.listFolders?.(this.projectId) ?? []).find((folder) => folder.id !== excludeFolderId && (folder.parentId ?? null) === parentId && folder.name === name)) throw fail(API_ERROR_CODES.DUPLICATE_FOLDER_NAME, { message: `A folder named "${name}" already exists in this location.` });
|
|
9220
10049
|
}
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
10050
|
+
/** Saves each lens in order, like the API: new ones, or changes with a greater version. */
|
|
10051
|
+
ensureLenses(body) {
|
|
10052
|
+
const lenses = Array.isArray(body.lenses) ? body.lenses : [];
|
|
10053
|
+
if (lenses.length === 0) throw invalidField("validation/too-small", "lenses");
|
|
10054
|
+
return { lenses: lenses.map((lens) => this.ensureLens(lens)) };
|
|
10055
|
+
}
|
|
10056
|
+
ensureLens(options) {
|
|
10057
|
+
const existing = this.lenses.get(options.identifier);
|
|
10058
|
+
if (!existing) {
|
|
10059
|
+
const now = this.now();
|
|
10060
|
+
const lens = {
|
|
10061
|
+
id: this.createId("lens"),
|
|
10062
|
+
projectId: this.projectId,
|
|
10063
|
+
...options,
|
|
10064
|
+
createdAt: now,
|
|
10065
|
+
updatedAt: now
|
|
10066
|
+
};
|
|
10067
|
+
this.lenses.set(lens.identifier, lens);
|
|
10068
|
+
return lens;
|
|
10069
|
+
}
|
|
10070
|
+
if (existing.name === options.name && (existing.description ?? null) === (options.description ?? null) && existing.type === options.type && existing.active === options.active && stringifyJsonCanonical(existing.transforms ?? null) === stringifyJsonCanonical(options.transforms ?? null) && existing.version === options.version) return existing;
|
|
10071
|
+
if (options.version > existing.version) {
|
|
10072
|
+
const lens = {
|
|
10073
|
+
...existing,
|
|
10074
|
+
...options,
|
|
10075
|
+
updatedAt: this.now()
|
|
10076
|
+
};
|
|
10077
|
+
this.lenses.set(lens.identifier, lens);
|
|
10078
|
+
return lens;
|
|
10079
|
+
}
|
|
10080
|
+
throw fail(API_ERROR_CODES.LENS_VERSION_CONFLICT, { details: {
|
|
10081
|
+
currentVersion: existing.version,
|
|
10082
|
+
minimumVersion: existing.version + 1,
|
|
10083
|
+
requestedVersion: options.version
|
|
10084
|
+
} });
|
|
9225
10085
|
}
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
10086
|
+
/**
|
|
10087
|
+
* Serves stored content like the CDN (`/assets/:projectId/:asset/:resource`): `200` with
|
|
10088
|
+
* `Content-Type`, `Content-Length`, `Cache-Control`, `Cache-Tag`, the CDN's protection headers
|
|
10089
|
+
* and, for images, `ETag` and `Expires`; or a JSON error body with `404`/`403`. A
|
|
10090
|
+
* `placeholder` is the asset's ThumbHash as a PNG.
|
|
10091
|
+
*/
|
|
10092
|
+
async serveCdn(request) {
|
|
10093
|
+
const { url, segments, query } = request;
|
|
10094
|
+
const [root, projectId, assetIdentifier, resource] = segments;
|
|
10095
|
+
const notFound = () => this.jsonResponse(404, {
|
|
10096
|
+
code: "asset/not-found",
|
|
10097
|
+
message: "Asset not found"
|
|
10098
|
+
}, url);
|
|
10099
|
+
if (root !== "assets" || projectId !== this.projectId || !assetIdentifier) return notFound();
|
|
10100
|
+
const asset = (await this.databaseStorage.listAssets(this.projectId)).find((asset) => asset.id === assetIdentifier || asset.externalId === assetIdentifier);
|
|
10101
|
+
if (!asset) return notFound();
|
|
10102
|
+
const notAvailable = () => this.jsonResponse(403, {
|
|
10103
|
+
code: "asset/not-available",
|
|
10104
|
+
message: "Asset is not available"
|
|
10105
|
+
}, url);
|
|
10106
|
+
const grants = request.headers.get("x-api-key") === this.apiKey ? this.grants : [];
|
|
10107
|
+
const folders = await this.databaseStorage.listFolders?.(this.projectId) ?? [];
|
|
10108
|
+
const canDownload = () => permits(grants, "assets:download", asset.folderId ?? null, folders);
|
|
10109
|
+
const status = asset.classification.moderation.status;
|
|
10110
|
+
const isHead = request.method === "HEAD";
|
|
10111
|
+
if (resource === "placeholder") {
|
|
10112
|
+
const thumbhash = asset.content.image?.thumbhash;
|
|
10113
|
+
if (!thumbhash || status === "purged") return notAvailable();
|
|
10114
|
+
const png = Uint8Array.from(atob(placeholderHashToDataURL(thumbhash).split(",", 2)[1]), (char) => char.charCodeAt(0));
|
|
10115
|
+
return this.cdnResponse(isHead, png, url, {
|
|
10116
|
+
"Content-Type": "image/png",
|
|
10117
|
+
"Cache-Control": "public, max-age=3600",
|
|
10118
|
+
"Cache-Tag": `asset-${asset.id}`,
|
|
10119
|
+
ETag: `"${thumbhash}"`
|
|
10120
|
+
});
|
|
9230
10121
|
}
|
|
9231
|
-
|
|
10122
|
+
const storedContent = await this.imageStorage.getContent(asset.id);
|
|
10123
|
+
if (asset.upload.status === "uploading" || status === "purged" || status === "blocked" || asset.classification.status === "failed" || asset.content.status === "removed" || !storedContent) return notAvailable();
|
|
10124
|
+
let claims = {};
|
|
10125
|
+
try {
|
|
10126
|
+
claims = JSON.parse(query.get("viewerClaims") ?? request.headers.get("X-CalmLens-Viewer-Claims") ?? "{}");
|
|
10127
|
+
} catch {
|
|
10128
|
+
return this.jsonResponse(400, { code: "viewer/invalid-claims" }, url);
|
|
10129
|
+
}
|
|
10130
|
+
if (status === "age-restricted" && claims.ageConfirmed !== true) return this.jsonResponse(403, { code: "viewer/age-confirmation-required" }, url);
|
|
10131
|
+
const isPrivate = asset.visibility !== "public";
|
|
10132
|
+
if (isPrivate && !canDownload()) return this.jsonResponse(403, {
|
|
10133
|
+
code: "asset/api-key-missing-permission",
|
|
10134
|
+
message: "This API key needs assets:download permission for this asset’s folder."
|
|
10135
|
+
}, url);
|
|
10136
|
+
const parts = [...storedContent.parts.entries()].sort(([left], [right]) => left - right).map(([, value]) => value);
|
|
10137
|
+
const bytes = new Uint8Array(parts.reduce((sum, value) => sum + value.byteLength, 0));
|
|
10138
|
+
let offset = 0;
|
|
10139
|
+
for (const part of parts) {
|
|
10140
|
+
bytes.set(part, offset);
|
|
10141
|
+
offset += part.byteLength;
|
|
10142
|
+
}
|
|
10143
|
+
const contentType = storedContent.type ?? "application/octet-stream";
|
|
10144
|
+
const isImage = asset.type === "image";
|
|
10145
|
+
return this.cdnResponse(isHead, bytes, url, {
|
|
10146
|
+
"Content-Type": contentType,
|
|
10147
|
+
"Cache-Control": Object.keys(claims).length ? "private, no-store" : `${isPrivate ? "private" : "public"}, ${isImage ? "max-age=31536000, immutable" : "max-age=3600"}`,
|
|
10148
|
+
"Cache-Tag": `asset-${asset.id}`,
|
|
10149
|
+
...isImage ? {
|
|
10150
|
+
ETag: `"${asset.id}"`,
|
|
10151
|
+
Expires: new Date(this.now() + 31536e6).toUTCString()
|
|
10152
|
+
} : {},
|
|
10153
|
+
"X-Content-Type-Options": "nosniff",
|
|
10154
|
+
"Content-Security-Policy": "sandbox; default-src 'none'",
|
|
10155
|
+
...isActiveContentType(contentType) ? { "Content-Disposition": "attachment" } : {}
|
|
10156
|
+
});
|
|
10157
|
+
}
|
|
10158
|
+
cdnResponse(isHead, bytes, url, headers) {
|
|
10159
|
+
return this.response(200, isHead ? null : bytes, url, {
|
|
10160
|
+
...headers,
|
|
10161
|
+
"Content-Length": String(bytes.byteLength),
|
|
10162
|
+
"X-Mock-CalmLens-CDN-URL": url
|
|
10163
|
+
});
|
|
9232
10164
|
}
|
|
9233
10165
|
};
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
10166
|
+
// src/testing/MockStorage.ts
|
|
10167
|
+
var InMemoryMockImageStorage = class {
|
|
10168
|
+
constructor() {
|
|
10169
|
+
this.content = /* @__PURE__ */ new Map();
|
|
10170
|
+
}
|
|
10171
|
+
async putContent(assetId, content) {
|
|
10172
|
+
const validation = validateAssetPayload(content);
|
|
10173
|
+
if (!validation.ok) throw new Error(validation.error);
|
|
10174
|
+
const bytes = await assetDataToUint8Array(assetToData(content));
|
|
10175
|
+
this.content.set(assetId, {
|
|
10176
|
+
assetId,
|
|
10177
|
+
parts: /* @__PURE__ */ new Map([[1, bytes]]),
|
|
10178
|
+
type: content.type
|
|
10179
|
+
});
|
|
10180
|
+
}
|
|
10181
|
+
async putPart(assetId, part) {
|
|
10182
|
+
const validation = validateAssetPayload(part, {
|
|
10183
|
+
multipart: true,
|
|
10184
|
+
isFinal: part.isFinal
|
|
10185
|
+
});
|
|
10186
|
+
if (!validation.ok) throw new Error(validation.error);
|
|
10187
|
+
const bytes = await assetDataToUint8Array(assetToData(part));
|
|
10188
|
+
const existing = this.content.get(assetId) ?? {
|
|
10189
|
+
assetId,
|
|
10190
|
+
parts: /* @__PURE__ */ new Map(),
|
|
10191
|
+
type: part.type
|
|
10192
|
+
};
|
|
10193
|
+
existing.parts.set(part.partNumber ?? 1, bytes);
|
|
10194
|
+
existing.type = part.type ?? existing.type;
|
|
10195
|
+
this.content.set(assetId, existing);
|
|
10196
|
+
}
|
|
10197
|
+
async getContent(assetId) {
|
|
10198
|
+
return this.content.get(assetId);
|
|
10199
|
+
}
|
|
10200
|
+
async deleteContent(assetId) {
|
|
10201
|
+
this.content.delete(assetId);
|
|
10202
|
+
}
|
|
10203
|
+
};
|
|
10204
|
+
var InMemoryMockDatabaseStorage = class {
|
|
10205
|
+
constructor() {
|
|
10206
|
+
this.assets = /* @__PURE__ */ new Map();
|
|
10207
|
+
this.folders = /* @__PURE__ */ new Map();
|
|
10208
|
+
}
|
|
10209
|
+
async createAsset(asset) {
|
|
10210
|
+
this.assets.set(asset.id, asset);
|
|
10211
|
+
return asset;
|
|
10212
|
+
}
|
|
10213
|
+
async updateAsset(asset) {
|
|
10214
|
+
this.assets.set(asset.id, asset);
|
|
10215
|
+
return asset;
|
|
10216
|
+
}
|
|
10217
|
+
async getAsset(assetId) {
|
|
10218
|
+
return this.assets.get(assetId);
|
|
10219
|
+
}
|
|
10220
|
+
async listAssets(projectId) {
|
|
10221
|
+
return [...this.assets.values()].filter((asset) => asset.projectId === projectId);
|
|
10222
|
+
}
|
|
10223
|
+
async deleteAsset(assetId) {
|
|
10224
|
+
this.assets.delete(assetId);
|
|
10225
|
+
}
|
|
10226
|
+
async createFolder(folder) {
|
|
10227
|
+
this.folders.set(folder.id, folder);
|
|
10228
|
+
return folder;
|
|
10229
|
+
}
|
|
10230
|
+
async updateFolder(folder) {
|
|
10231
|
+
this.folders.set(folder.id, folder);
|
|
10232
|
+
return folder;
|
|
10233
|
+
}
|
|
10234
|
+
async getFolder(folderId) {
|
|
10235
|
+
return this.folders.get(folderId);
|
|
10236
|
+
}
|
|
10237
|
+
async listFolders(projectId) {
|
|
10238
|
+
return [...this.folders.values()].filter((folder) => folder.projectId === projectId).sort((a, b) => a.name.localeCompare(b.name));
|
|
10239
|
+
}
|
|
10240
|
+
async deleteFolder(folderId) {
|
|
10241
|
+
this.folders.delete(folderId);
|
|
10242
|
+
}
|
|
10243
|
+
};
|
|
10244
|
+
// src/testing/MockCalmLensServerClient.ts
|
|
10245
|
+
const apis = /* @__PURE__ */ new WeakMap();
|
|
10246
|
+
/** The in-memory API a mock server client runs against, for `MockCalmLensAppClient`. */
|
|
10247
|
+
const getMockApi = (client) => {
|
|
10248
|
+
const api = apis.get(client);
|
|
10249
|
+
if (!api) throw invalidConfig("mockOptions.server must be a MockCalmLensServerClient");
|
|
10250
|
+
return api;
|
|
10251
|
+
};
|
|
10252
|
+
/**
|
|
10253
|
+
* Options that make a real client send its requests to `api`: waits only move the mock's clock,
|
|
10254
|
+
* multipart size checks are the in-memory API's, and nothing is retried unless the options say so.
|
|
10255
|
+
*/
|
|
10256
|
+
const mockClientOptions = (api, endpoints, session) => {
|
|
10257
|
+
const fetch = api.createFetch(endpoints, session);
|
|
10258
|
+
const hooks = {
|
|
10259
|
+
sleep: async (ms, signal) => {
|
|
10260
|
+
await sleep(0, signal);
|
|
10261
|
+
api.waitedMs += ms;
|
|
10262
|
+
},
|
|
10263
|
+
now: () => api.waitedMs,
|
|
10264
|
+
uploadTokenTime: () => ({
|
|
10265
|
+
now: api.now(),
|
|
10266
|
+
nonce: randomUuid()
|
|
10267
|
+
}),
|
|
10268
|
+
deferUploadChecks: true,
|
|
10269
|
+
defaultCdnBaseUrl: "mock://calmlens/v1"
|
|
10270
|
+
};
|
|
10271
|
+
return {
|
|
10272
|
+
fetch,
|
|
10273
|
+
uploadFetch: fetch,
|
|
10274
|
+
cdnFetch: fetch,
|
|
10275
|
+
[MOCK_HOOKS]: hooks
|
|
10276
|
+
};
|
|
10277
|
+
};
|
|
10278
|
+
/**
|
|
10279
|
+
* The real `CalmLensServerClient`, answered by an in-memory CalmLens API and CDN instead of the
|
|
10280
|
+
* network: every method runs the real client's code, and the in-memory API answers its requests
|
|
10281
|
+
* with the API's statuses, bodies, error bodies and headers. Mock helpers decide assets, add
|
|
10282
|
+
* events, change the API key's grants, fail requests and list the requests sent.
|
|
10283
|
+
*/
|
|
10284
|
+
var MockCalmLensServerClient = class extends CalmLensServerClient {
|
|
10285
|
+
#api;
|
|
10286
|
+
#projectId;
|
|
10287
|
+
/**
|
|
10288
|
+
* Accepts the same options as `CalmLensServerClient` (validated the same way), plus
|
|
10289
|
+
* `mockOptions`. `maxRetries` defaults to 0, so a failed request fails once; retry backoff and
|
|
10290
|
+
* `assets.waitForDecision` take no real time.
|
|
10291
|
+
*/
|
|
10292
|
+
constructor(options) {
|
|
10293
|
+
validateServerClientOptions(options);
|
|
10294
|
+
const { mockOptions = {}, ...clientOptions } = options;
|
|
10295
|
+
const api = new MockCalmLensApi({
|
|
10296
|
+
projectId: options.projectId,
|
|
10297
|
+
apiKey: options.apiKey,
|
|
10298
|
+
grants: getMockApiKeyGrants(options.apiKey),
|
|
10299
|
+
databaseStorage: mockOptions.databaseStorage ?? new InMemoryMockDatabaseStorage(),
|
|
10300
|
+
imageStorage: mockOptions.imageStorage ?? new InMemoryMockImageStorage(),
|
|
10301
|
+
createId: mockOptions.createId ?? newId,
|
|
10302
|
+
now: mockOptions.now ?? createMonotonicClock(),
|
|
10303
|
+
moderate: mockOptions.moderate ?? defaultModerate,
|
|
10304
|
+
signedUrlBaseUrl: mockOptions.signedUrlBaseUrl ?? "mock://calmlens"
|
|
10305
|
+
});
|
|
10306
|
+
let endpoints;
|
|
10307
|
+
super({
|
|
10308
|
+
maxRetries: 0,
|
|
10309
|
+
...clientOptions,
|
|
10310
|
+
dangerouslyAllowBrowser: true,
|
|
10311
|
+
...mockClientOptions(api, () => endpoints)
|
|
10312
|
+
});
|
|
10313
|
+
const { baseUrl, cdnBaseUrl } = this.toJSON();
|
|
10314
|
+
endpoints = {
|
|
10315
|
+
baseUrl,
|
|
10316
|
+
cdnBaseUrl
|
|
10317
|
+
};
|
|
10318
|
+
this.#api = api;
|
|
10319
|
+
this.#projectId = options.projectId;
|
|
10320
|
+
this.databaseStorage = api.databaseStorage;
|
|
10321
|
+
this.imageStorage = api.imageStorage;
|
|
10322
|
+
apis.set(this, api);
|
|
10323
|
+
}
|
|
10324
|
+
/** Keeps the API key out of `console.log` and `util.inspect`. */
|
|
10325
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
10326
|
+
return `MockCalmLensServerClient { projectId: ${JSON.stringify(this.#projectId)} }`;
|
|
10327
|
+
}
|
|
10328
|
+
/**
|
|
10329
|
+
* Every request sent to the mock's API and CDN, oldest first, by this client and the
|
|
10330
|
+
* `MockCalmLensAppClient`s that use it, each with the client method that sent it.
|
|
10331
|
+
*/
|
|
10332
|
+
get calls() {
|
|
10333
|
+
return this.#api.calls.map(({ call }) => call);
|
|
10334
|
+
}
|
|
10335
|
+
/**
|
|
10336
|
+
* Requests `operation` sends, oldest first. A request several methods send matches each of
|
|
10337
|
+
* them: `callsTo("text.classify")` also lists `assets.classify` text uploads, and
|
|
10338
|
+
* `callsTo("assets.waitForDecision")` the `assets.retrieve` requests it polls with.
|
|
10339
|
+
*/
|
|
10340
|
+
callsTo(operation) {
|
|
10341
|
+
return this.#api.calls.filter(({ operations }) => operations.includes(operation)).map(({ call }) => call);
|
|
10342
|
+
}
|
|
10343
|
+
/** Forgets the requests recorded so far, so `calls` lists only later ones. */
|
|
10344
|
+
clearCalls() {
|
|
10345
|
+
this.#api.calls.length = 0;
|
|
10346
|
+
}
|
|
10347
|
+
/**
|
|
10348
|
+
* Makes the next request (or the next one `operation` sends) fail instead of reaching the
|
|
10349
|
+
* in-memory API. Each queued failure fails one request, so a retry takes the next queued
|
|
10350
|
+
* failure or reaches the API. An API error code answers with that error and its HTTP status,
|
|
10351
|
+
* with `details` and a `Retry-After` of `retryAfter` seconds when given.
|
|
10352
|
+
*/
|
|
10353
|
+
failNextRequest(failure = "transport-error", options = {}) {
|
|
10354
|
+
this.#api.failNextRequest(failure, options);
|
|
10355
|
+
}
|
|
10356
|
+
/**
|
|
10357
|
+
* Changes the grants of the mock's API key, as editing the key in the portal would: later
|
|
10358
|
+
* requests, such as `cdn.serve`, use them. The key itself stays the same, so idempotency keys
|
|
10359
|
+
* it used still replay.
|
|
10360
|
+
*/
|
|
10361
|
+
setApiKeyGrants(grants) {
|
|
10362
|
+
this.#api.grants = getMockApiKeyGrants(createMockApiKey(grants));
|
|
10363
|
+
}
|
|
10364
|
+
/**
|
|
10365
|
+
* Adds an event to the feed `events.list` reads, as if CalmLens had recorded it. Returns the
|
|
10366
|
+
* stored event; pass it to `toWebhookEventPayload` to build a matching webhook body.
|
|
10367
|
+
*/
|
|
10368
|
+
enqueueEvent(input) {
|
|
10369
|
+
return this.#api.enqueueEvent(input);
|
|
10370
|
+
}
|
|
10371
|
+
/**
|
|
10372
|
+
* Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
|
|
10373
|
+
* status changes (`purged` also removes the content, with reason `requested` and an
|
|
10374
|
+
* `asset.content-removed` event), the review joins `manualReviews`, and an `asset.<decision>`
|
|
10375
|
+
* event with a `decided-by: reviewer` fact joins the events feed. Rejects with
|
|
10376
|
+
* `asset/not-found` for an unknown asset. It sends no request.
|
|
10377
|
+
*/
|
|
10378
|
+
decide(assetId, decision) {
|
|
10379
|
+
return this.#api.decide(assetId, decision);
|
|
10380
|
+
}
|
|
10381
|
+
/**
|
|
10382
|
+
* Finishes processing a pending asset as the moderation pipeline would: with `decision`, or
|
|
10383
|
+
* else what `mockOptions.moderate` returns for the `complete` trigger (`allowed` by default).
|
|
10384
|
+
* The asset gets the decision's report, and `asset.classified` and the automated
|
|
10385
|
+
* `asset.<status>` (or `asset.failed`) join the events feed. No review is recorded, so the
|
|
10386
|
+
* asset can still be moderated again. Rejects with `asset/not-found` for an unknown asset and
|
|
10387
|
+
* `sdk/invalid-argument` for one that isn't processing. It sends no request.
|
|
10388
|
+
*/
|
|
10389
|
+
completeProcessing(assetId, decision) {
|
|
10390
|
+
return this.#api.completeProcessing(assetId, decision);
|
|
10391
|
+
}
|
|
10392
|
+
};
|
|
10393
|
+
// src/testing/MockCalmLensAppClient.ts
|
|
10394
|
+
const MOCK_PROJECT_ID = "proj_0000000000000000c1a5";
|
|
10395
|
+
/**
|
|
10396
|
+
* The real `CalmLensAppClient`, answered by the in-memory API of a `MockCalmLensServerClient`.
|
|
10397
|
+
* The API applies `mockOptions.preset` to this client's uploads like CalmLens applies an upload
|
|
10398
|
+
* preset: its token, content type and size rules with the API's error codes, and its visibility,
|
|
10399
|
+
* folder, content retention and decision display. The client can check and continue only the
|
|
10400
|
+
* uploads it made.
|
|
10401
|
+
*/
|
|
10402
|
+
var MockCalmLensAppClient = class extends CalmLensAppClient {
|
|
10403
|
+
#uploadPresetId;
|
|
10404
|
+
constructor(options) {
|
|
10405
|
+
validateAppClientOptions(options);
|
|
10406
|
+
const { mockOptions = {}, ...clientOptions } = options;
|
|
10407
|
+
const server = mockOptions.server ?? new MockCalmLensServerClient({
|
|
10408
|
+
apiKey: createMockApiKey([{
|
|
10409
|
+
actions: [
|
|
10410
|
+
"assets:create",
|
|
10411
|
+
"assets:read",
|
|
10412
|
+
"assets:update"
|
|
10413
|
+
],
|
|
10414
|
+
folderId: null,
|
|
10415
|
+
includeSubfolders: true
|
|
10416
|
+
}]),
|
|
10417
|
+
projectId: MOCK_PROJECT_ID,
|
|
10418
|
+
mockOptions: { moderate: mockOptions.moderate }
|
|
10419
|
+
});
|
|
10420
|
+
let endpoints;
|
|
10421
|
+
const { cdnFetch: _cdnFetch, ...requestOptions } = mockClientOptions(getMockApi(server), () => endpoints, {
|
|
10422
|
+
preset: mockOptions.preset ?? {},
|
|
10423
|
+
uploads: /* @__PURE__ */ new Set()
|
|
10424
|
+
});
|
|
10425
|
+
super({
|
|
10426
|
+
maxRetries: 0,
|
|
10427
|
+
...clientOptions,
|
|
10428
|
+
...requestOptions
|
|
10429
|
+
});
|
|
10430
|
+
endpoints = {
|
|
10431
|
+
baseUrl: this.toJSON().baseUrl,
|
|
10432
|
+
cdnBaseUrl: server.toJSON().cdnBaseUrl
|
|
10433
|
+
};
|
|
10434
|
+
this.server = server;
|
|
10435
|
+
this.#uploadPresetId = options.uploadPresetId;
|
|
10436
|
+
}
|
|
10437
|
+
/** Keeps tokens out of `console.log` and `util.inspect`. */
|
|
10438
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
10439
|
+
return `MockCalmLensAppClient { uploadPresetId: ${JSON.stringify(this.#uploadPresetId)} }`;
|
|
10440
|
+
}
|
|
10441
|
+
};
|
|
10442
|
+
// src/testing/MockWebhooks.ts
|
|
10443
|
+
/**
|
|
10444
|
+
* The JSON body of a webhook delivery for an event, such as one from `events.list`: the event in
|
|
10445
|
+
* the same shape `events.list` returns it. Fields that are not part of that shape are left out.
|
|
10446
|
+
*/
|
|
9237
10447
|
const toWebhookEventPayload = (event) => ({
|
|
9238
10448
|
id: event.id,
|
|
9239
|
-
type: event.type,
|
|
9240
|
-
createdAt: event.createdAt,
|
|
9241
10449
|
projectId: event.projectId,
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
10450
|
+
type: event.type,
|
|
10451
|
+
severity: event.severity,
|
|
10452
|
+
title: event.title,
|
|
10453
|
+
description: event.description ?? null,
|
|
10454
|
+
assetId: event.assetId ?? null,
|
|
10455
|
+
asset: event.asset ?? null,
|
|
10456
|
+
facts: event.facts,
|
|
10457
|
+
createdAt: event.createdAt
|
|
9250
10458
|
});
|
|
9251
10459
|
/** `v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`, the `x-calmlens-signature` header value. */
|
|
9252
10460
|
const createWebhookSignatureHeader = async (secret, timestamp, body) => `v1=${await hmacSha256Hex(secret, `${timestamp}.${body}`)}`;
|
|
@@ -9260,5 +10468,4 @@ const createWebhookHeaders = async (options) => {
|
|
|
9260
10468
|
[WEBHOOK_SIGNATURE_HEADER]: await createWebhookSignatureHeader(options.secret, timestamp, options.body)
|
|
9261
10469
|
};
|
|
9262
10470
|
};
|
|
9263
|
-
|
|
9264
|
-
export { InMemoryMockDatabaseStorage, InMemoryMockImageStorage, MEDIA_TEST_SIGNALS, MOCK_API_KEY_PREFIX, MockCalmLensServerClient, createMockApiKey, createWebhookHeaders, createWebhookSignatureHeader, generateMediaTestCode, toWebhookEventPayload };
|
|
10471
|
+
export { InMemoryMockDatabaseStorage, InMemoryMockImageStorage, MEDIA_TEST_SIGNALS, MOCK_API_KEY_PREFIX, MockCalmLensAppClient, MockCalmLensServerClient, MockDecision, createMockApiKey, createWebhookHeaders, createWebhookSignatureHeader, generateMediaTestCode, mockDecision, signUploadTokenWithApiKey as signUploadToken, toWebhookEventPayload };
|