@calmlens/sdk 0.16.3 → 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.
Files changed (112) hide show
  1. package/README.md +398 -163
  2. package/cjs/BrowserUpload-DZ0Vbmxi.js +10 -0
  3. package/cjs/BrowserUpload-uZjVFGfP.d.ts +11 -0
  4. package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  5. package/cjs/CalmLensAppClient-DBGmaDlG.js +89 -0
  6. package/cjs/CalmLensConnection-K1TAD1Z-.js +659 -0
  7. package/cjs/CalmLensServerClient-C-pxiz0C.js +645 -0
  8. package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
  9. package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  10. package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  11. package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
  12. package/cjs/FileIcons-Ob003MdS.js +175 -0
  13. package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
  14. package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  15. package/cjs/ModerationContextLimits-Dj1YAl3G.js +1311 -0
  16. package/cjs/ReactUpload-DxtFMr06.js +54 -0
  17. package/cjs/ReactUpload-nZahpmyg.d.ts +17 -0
  18. package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  19. package/cjs/TestTriggers-DcArt0Nu-CvoaONUB.js +1326 -0
  20. package/cjs/UploadController-BBkN4Ni6.js +618 -0
  21. package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
  22. package/cjs/app.d.ts +7 -44
  23. package/cjs/app.js +11 -89
  24. package/cjs/elements.d.ts +88 -0
  25. package/cjs/elements.js +555 -0
  26. package/cjs/file-icons.d.ts +67 -0
  27. package/cjs/file-icons.js +10 -0
  28. package/cjs/index-CI4BzN4W.d.ts +252 -0
  29. package/cjs/index.d.ts +6 -4
  30. package/cjs/index.js +9 -4
  31. package/cjs/react-native.d.ts +56 -0
  32. package/cjs/react-native.js +495 -0
  33. package/cjs/react.d.ts +30 -0
  34. package/cjs/react.js +370 -0
  35. package/cjs/server.d.ts +12 -191
  36. package/cjs/server.js +18 -378
  37. package/cjs/test-inputs.d.ts +2 -2
  38. package/cjs/test-inputs.js +9 -264
  39. package/cjs/testing.d.ts +285 -190
  40. package/cjs/testing.js +2393 -1203
  41. package/cjs/ui-CgZpgpNo.d.ts +526 -0
  42. package/cjs/ui-KceuoVCM.js +583 -0
  43. package/cjs/ui.d.ts +2 -0
  44. package/cjs/ui.js +27 -0
  45. package/cjs/vue.d.ts +490 -0
  46. package/cjs/vue.js +419 -0
  47. package/esm/BrowserUpload-D7-zNmc0.js +5 -0
  48. package/esm/BrowserUpload-uZjVFGfP.d.ts +11 -0
  49. package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  50. package/esm/CalmLensAppClient-FVMwrHlH.js +84 -0
  51. package/esm/CalmLensConnection-iyNQHA8L.js +582 -0
  52. package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
  53. package/esm/CalmLensServerClient-fQEYKLw4.js +586 -0
  54. package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  55. package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  56. package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
  57. package/esm/FileIcons-M6dzjFxd.js +128 -0
  58. package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
  59. package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  60. package/esm/ModerationContextLimits-D1x_WAtP.js +1114 -0
  61. package/esm/ReactUpload-D6yBCkcw.js +37 -0
  62. package/esm/ReactUpload-nZahpmyg.d.ts +17 -0
  63. package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  64. package/esm/TestTriggers-DcArt0Nu-s0AQZuWE.js +1266 -0
  65. package/esm/UploadController-txp5NuzH.js +589 -0
  66. package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
  67. package/esm/app.d.ts +7 -44
  68. package/esm/app.js +3 -86
  69. package/esm/elements.d.ts +88 -0
  70. package/esm/elements.js +528 -0
  71. package/esm/file-icons.d.ts +67 -0
  72. package/esm/file-icons.js +1 -0
  73. package/esm/index-CI4BzN4W.d.ts +252 -0
  74. package/esm/index.d.ts +6 -4
  75. package/esm/index.js +1 -1
  76. package/esm/react-native.d.ts +56 -0
  77. package/esm/react-native.js +467 -0
  78. package/esm/react.d.ts +30 -0
  79. package/esm/react.js +343 -0
  80. package/esm/server.d.ts +12 -191
  81. package/esm/server.js +3 -369
  82. package/esm/styles.css +147 -0
  83. package/esm/svelte/CalmLensMedia.svelte +134 -0
  84. package/esm/svelte/CalmLensUploader.svelte +175 -0
  85. package/esm/svelte/Icon.svelte +7 -0
  86. package/esm/svelte/index.d.ts +37 -0
  87. package/esm/svelte/index.js +6 -0
  88. package/esm/test-inputs.d.ts +2 -2
  89. package/esm/test-inputs.js +1 -256
  90. package/esm/testing.d.ts +285 -190
  91. package/esm/testing.js +2329 -1122
  92. package/esm/ui-Ca_SpJXf.js +458 -0
  93. package/esm/ui-CgZpgpNo.d.ts +526 -0
  94. package/esm/ui.d.ts +2 -0
  95. package/esm/ui.js +3 -0
  96. package/esm/vue.d.ts +490 -0
  97. package/esm/vue.js +392 -0
  98. package/package.json +110 -11
  99. package/cjs/CalmLensConnection-BNUHp_AQ.js +0 -234
  100. package/cjs/CalmLensUtils-o171ownK.js +0 -699
  101. package/cjs/CalmLensVersion-DwhgxQK5.js +0 -480
  102. package/cjs/CalmLensWebhooks-BKrq30Yp.js +0 -201
  103. package/cjs/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  104. package/cjs/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  105. package/cjs/WebhookPayload-CJoIkGBD.d.ts +0 -11753
  106. package/esm/CalmLensConnection-XTqmlwzG.js +0 -193
  107. package/esm/CalmLensUtils-DsF8Aa_x.js +0 -574
  108. package/esm/CalmLensVersion-DcifRKsx.js +0 -415
  109. package/esm/CalmLensWebhooks-BvGyk5d3.js +0 -148
  110. package/esm/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  111. package/esm/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  112. package/esm/WebhookPayload-CJoIkGBD.d.ts +0 -11753
package/esm/testing.js CHANGED
@@ -1,10 +1,39 @@
1
- import { c as invalidArgument, d as API_ERROR_DEFINITIONS, o as CalmLensError, s as SDK_ERROR_CODES, u as API_ERROR_CODES } from "./CalmLensVersion-DcifRKsx.js";
2
- import { c as hmacSha256Hex, i as WEBHOOK_TIMESTAMP_HEADER, l as stringifyJsonCanonical, n as WEBHOOK_SIGNATURE_HEADER, s as signUploadTokenWithApiKey, t as WEBHOOK_DELIVERY_HEADER } from "./CalmLensWebhooks-BvGyk5d3.js";
3
- import { b as getRejectedUploadMimeTypeError, f as sleep, g as validateAssetPayload, h as getAssetPayloadByteLength, m as assetToData, n as encodePathSegment, o as toTextAssetOptions, p as assetDataToUint8Array, r as pollForDecision, s as transformSelector, t as appendCdnSelector, u as validateServerClientOptions, x as isRejectedUploadMimeType } from "./CalmLensUtils-DsF8Aa_x.js";
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
- //#region src/contract/Classification-I_opp8t0.js
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, {
@@ -14,98 +43,6 @@ var __exportAll = (all, no_symbols) => {
14
43
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
15
44
  return target;
16
45
  };
17
- const MODERATION_CATEGORY_DEFINITIONS = {
18
- sexual: {
19
- summary: "Sexual acts, explicit sexual anatomy, sexual services, sexual assault depictions and clearly erotic material.",
20
- 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.",
21
- 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.",
22
- docsHref: "/docs/guides/moderation-categories#sexual-content"
23
- },
24
- violence: {
25
- summary: "Physical attacks, injury, death, threats against others, and planning or encouraging physical violence.",
26
- 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.",
27
- 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.",
28
- docsHref: "/docs/guides/moderation-categories#violence"
29
- },
30
- "self-harm": {
31
- summary: "Intentional self-injury, suicide, preparation, methods or encouragement; text also includes current or recurring self-harm thoughts.",
32
- 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.",
33
- 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.",
34
- docsHref: "/docs/guides/moderation-categories#self-harm"
35
- },
36
- harassment: {
37
- summary: "Targeted personal abuse, humiliation, bullying or serious intimidation; dislike, ordinary criticism and neutral reporting alone are excluded.",
38
- 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.",
39
- 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.",
40
- docsHref: "/docs/guides/moderation-categories#harassment-and-bullying"
41
- },
42
- hate: {
43
- summary: "Identity-based abuse, dehumanization, discriminatory exclusion or threats against people because of protected characteristics.",
44
- 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.",
45
- 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.",
46
- docsHref: "/docs/guides/moderation-categories#hate-and-identity-abuse"
47
- },
48
- "criminal-activity": {
49
- summary: "Planning, assistance, instructions, encouragement or facilitation of a concrete criminal or seriously dangerous act, fraud and scams, and illegal drugs or contraband.",
50
- 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.",
51
- 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.",
52
- docsHref: "/docs/guides/moderation-categories#criminal-and-dangerous-activity"
53
- }
54
- };
55
- const MODERATION_QUALIFIERS = [
56
- "sexual-nudity",
57
- "sexual-activity",
58
- "sexual-explicit-language",
59
- "sexual-solicitation",
60
- "sexual-historical",
61
- "sexual-documentary",
62
- "sexual-educational",
63
- "sexual-fictional",
64
- "sexual-support-prevention",
65
- "violence-attack",
66
- "violence-threat",
67
- "violence-preparation",
68
- "violence-encouragement",
69
- "violence-injury",
70
- "violence-violent-death",
71
- "violence-natural-death",
72
- "violence-graphic",
73
- "violence-historical",
74
- "violence-documentary",
75
- "violence-educational",
76
- "violence-fictional",
77
- "violence-support-prevention",
78
- "self-harm-thoughts",
79
- "self-harm-preparation",
80
- "self-harm-act",
81
- "self-harm-methods",
82
- "self-harm-encouragement",
83
- "self-harm-documentary",
84
- "self-harm-educational",
85
- "self-harm-fictional",
86
- "self-harm-support-prevention",
87
- "harassment-abuse",
88
- "harassment-intimidation",
89
- "harassment-threat",
90
- "harassment-sexual",
91
- "harassment-encouragement",
92
- "harassment-fictional",
93
- "hate-abuse",
94
- "hate-dehumanization",
95
- "hate-inferiority",
96
- "hate-exclusion",
97
- "hate-threat",
98
- "hate-historical",
99
- "hate-fictional",
100
- "criminal-activity-demonstration",
101
- "criminal-activity-preparation",
102
- "criminal-activity-request",
103
- "criminal-activity-instructions",
104
- "criminal-activity-promotion",
105
- "criminal-activity-substances",
106
- "criminal-activity-fraud",
107
- "criminal-activity-fictional"
108
- ];
109
46
  function getEnumValues(entries) {
110
47
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
111
48
  return Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v);
@@ -438,6 +375,17 @@ function prefixIssues(path, issues) {
438
375
  function unwrapMessage(message) {
439
376
  return typeof message === "string" ? message : message?.message;
440
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
+ }
441
389
  function attachSchema(issues, start, inst) {
442
390
  var _a;
443
391
  for (let i = start; i < issues.length; i++) (_a = issues[i]).schema ?? (_a.schema = inst);
@@ -536,6 +484,7 @@ function hide(inst, key, value) {
536
484
  return own(inst, key, value, false);
537
485
  }
538
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__ */
539
488
  function derived(computes, table) {
540
489
  for (const key in computes) {
541
490
  const compute = computes[key];
@@ -685,6 +634,7 @@ function newError(Definition) {
685
634
  }
686
635
  return new Definition();
687
636
  }
637
+ /* @__NO_SIDE_EFFECTS__ */
688
638
  function $constructor(name, initializer, proto, params) {
689
639
  const zodProto = {};
690
640
  function Internals(def) {
@@ -813,8 +763,8 @@ const initializer$1 = (inst, def) => {
813
763
  });
814
764
  }
815
765
  };
816
- const $ZodError = $constructor("$ZodError", initializer$1);
817
- 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 });
818
768
  /** Get-or-create `obj[key]` as an own data property. A path segment naming an inherited member
819
769
  * ("toString", "constructor") would otherwise read through to the prototype, and assigning
820
770
  * "__proto__" would hit the setter instead of creating a key. */
@@ -1748,7 +1698,7 @@ var Doc = class {
1748
1698
  const version = {
1749
1699
  major: 4,
1750
1700
  minor: 6,
1751
- patch: 5
1701
+ patch: 7
1752
1702
  };
1753
1703
  const $ZodType = /*@__PURE__*/ $constructor("$ZodType", (inst, def) => {
1754
1704
  var _a;
@@ -2440,6 +2390,28 @@ const $ZodDate = /*@__PURE__*/ $constructor("$ZodDate", (inst, def) => {
2440
2390
  return payload;
2441
2391
  };
2442
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;
2443
2415
  function handleArrayResult(result, final, index) {
2444
2416
  if (result.issues.length) final.issues.push(...prefixIssues(index, result.issues));
2445
2417
  final.value[index] = result.value;
@@ -2477,6 +2449,7 @@ const $ZodArray = /*@__PURE__*/ $constructor("$ZodArray", (inst, def) => {
2477
2449
  if (proms.length) return Promise.all(proms).then(() => payload);
2478
2450
  return payload;
2479
2451
  };
2452
+ capSize(inst, arraySize);
2480
2453
  });
2481
2454
  function handlePropertyResult(result, final, key, input, optin, optout) {
2482
2455
  const isPresent = key in input;
@@ -2585,7 +2558,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
2585
2558
  }
2586
2559
  return propValues;
2587
2560
  });
2588
- const isObject$2 = isObject;
2561
+ const isObject$1 = isObject;
2589
2562
  const catchall = def.catchall;
2590
2563
  let value;
2591
2564
  const memo = globalConfig.memoizer;
@@ -2593,7 +2566,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
2593
2566
  inst._zod.parse = (payload, ctx) => {
2594
2567
  value ?? (value = _normalized.value);
2595
2568
  const input = payload.value;
2596
- if (!isObject$2(input)) {
2569
+ if (!isObject$1(input)) {
2597
2570
  payload.issues.push({
2598
2571
  expected: "object",
2599
2572
  code: "invalid_type",
@@ -2723,7 +2696,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
2723
2696
  return doc.compile();
2724
2697
  };
2725
2698
  let fastpass;
2726
- const isObject$1 = isObject;
2699
+ const isObject$2 = isObject;
2727
2700
  const jit = !globalConfig.jitless;
2728
2701
  const fastEnabled = jit && allowsEval.value;
2729
2702
  const catchall = def.catchall;
@@ -2731,7 +2704,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
2731
2704
  inst._zod.parse = (payload, ctx) => {
2732
2705
  value ?? (value = _normalized.value);
2733
2706
  const input = payload.value;
2734
- if (!isObject$1(input)) {
2707
+ if (!isObject$2(input)) {
2735
2708
  payload.issues.push({
2736
2709
  expected: "object",
2737
2710
  code: "invalid_type",
@@ -3309,6 +3282,7 @@ const $ZodMap = /*@__PURE__*/ $constructor("$ZodMap", (inst, def) => {
3309
3282
  if (proms.length) return Promise.all(proms).then(() => payload);
3310
3283
  return payload;
3311
3284
  };
3285
+ capSize(inst, mapSize);
3312
3286
  });
3313
3287
  function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
3314
3288
  if (keyResult.issues.length) {
@@ -3368,6 +3342,7 @@ const $ZodSet = /*@__PURE__*/ $constructor("$ZodSet", (inst, def) => {
3368
3342
  if (proms.length) return Promise.all(proms).then(() => payload);
3369
3343
  return payload;
3370
3344
  };
3345
+ capSize(inst, setSize);
3371
3346
  });
3372
3347
  function handleSetResult(result, final) {
3373
3348
  if (result.issues.length) final.issues.push(...result.issues);
@@ -7853,122 +7828,6 @@ ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
7853
7828
  ...schemas_exports,
7854
7829
  ...checks_exports
7855
7830
  });
7856
- const ZOD_META_KEY = "__zod_meta__";
7857
- const createMetaType = (type) => {
7858
- return Object.assign((value) => {
7859
- return {
7860
- type,
7861
- data: value ?? {}
7862
- };
7863
- }, type);
7864
- };
7865
- const metaStore = (meta) => {
7866
- const metaMap = meta.reduce((acc, meta) => {
7867
- if (meta) acc[meta.type.id] = meta;
7868
- return acc;
7869
- }, {});
7870
- return { [ZOD_META_KEY]: {
7871
- itemMap: metaMap,
7872
- get itemList() {
7873
- return Object.values(metaMap);
7874
- }
7875
- } };
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 text onto the image asset and run an additional text moderation pass. 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
- const SIZE_LIMITS = {
8029
- small: 64,
8030
- medium: 200,
8031
- large: 1500,
8032
- xlarge: 1e4,
8033
- token: 1e4
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 saneStringField = (options) => {
8036
- return zod.string().transform((value) => value.trim()).refine((value) => {
8037
- if (options?.minWidth && value.length < options.minWidth) return false;
8038
- return true;
8039
- }, "Not long enough").refine((value) => {
8040
- const sizeLimit = options?.maxLength ?? SIZE_LIMITS[options?.type ?? "medium"];
8041
- return typeof value === "string" && value.length < sizeLimit;
8042
- }, "Too long");
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
- const CLASSIFICATION_INPUT_TYPE_SCHEMA = zod.enum(["text", "image"]);
8045
- const CLASSIFICATION_CATEGORIES = [
8046
- "sexual",
8047
- "violence",
8048
- "self-harm",
8049
- "harassment",
8050
- "hate",
8051
- "criminal-activity"
8052
- ];
8053
- const CLASSIFICATION_CATEGORY_SCHEMA = zod.enum(CLASSIFICATION_CATEGORIES);
8054
- zod.object({
8055
- category: CLASSIFICATION_CATEGORY_SCHEMA,
8056
- displayName: saneStringField(),
8057
- description: saneStringField(),
8058
- supportedInputTypes: zod.array(CLASSIFICATION_INPUT_TYPE_SCHEMA).min(1)
8059
- });
8060
- MODERATION_CATEGORY_DEFINITIONS.sexual.summary, MODERATION_CATEGORY_DEFINITIONS.violence.summary, MODERATION_CATEGORY_DEFINITIONS["self-harm"].summary, MODERATION_CATEGORY_DEFINITIONS.harassment.summary, MODERATION_CATEGORY_DEFINITIONS.hate.summary, MODERATION_CATEGORY_DEFINITIONS["criminal-activity"].summary;
8061
- const LIKELIHOOD_SCHEMA = zod.enum([
8062
- "very-unlikely",
8063
- "unlikely",
8064
- "possible",
8065
- "likely",
8066
- "very-likely"
8067
- ]);
8068
- const CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA = zod.enum(["not-supported", "unavailable"]);
8069
- const CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA = zod.partialRecord(zod.enum(MODERATION_QUALIFIERS), zod.discriminatedUnion("status", [zod.object({
8070
- status: zod.literal("evaluated"),
8071
- /** Evidence that some of the category's qualifying content is this qualifier. */
8072
- someScore: zod.number().min(0).max(1),
8073
- /** Evidence that only this qualifier is in it: all of the qualifying content is one. */
8074
- allScore: zod.number().min(0).max(1).optional(),
8075
- likelihood: LIKELIHOOD_SCHEMA
8076
- }).strict().refine(({ someScore, allScore }) => allScore === void 0 || allScore <= someScore, {
8077
- message: "The All score cannot exceed the Some score",
8078
- path: ["allScore"]
8079
- }), zod.object({
8080
- status: zod.literal("not-evaluated"),
8081
- reason: CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA
8082
- }).strict()]));
8083
- const CLASSIFICATION_CATEGORY_REPORT_SCHEMA = zod.discriminatedUnion("status", [
8084
- zod.object({
8085
- status: zod.literal("evaluated"),
8086
- likelihood: LIKELIHOOD_SCHEMA,
8087
- score: zod.number().min(0).max(1),
8088
- qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
8089
- }).strict(),
8090
- zod.object({
8091
- status: zod.literal("partial"),
8092
- likelihood: LIKELIHOOD_SCHEMA,
8093
- score: zod.number().min(0).max(1),
8094
- qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
8095
- }).strict(),
8096
- zod.object({
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: zod.string().uuid().nullable(),
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
- const SYSTEM_PERMISSION_SCHEMA = zod.enum([
8256
- "system:admin",
8257
- "system:assets:read-all",
8258
- "system:uploads:unlimited"
8259
- ]);
8260
- zod.array(SYSTEM_PERMISSION_SCHEMA);
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
- //#endregion
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
- //#endregion
8280
- //#region src/testing/MockStorage.ts
8281
- var InMemoryMockImageStorage = class {
8282
- constructor() {
8283
- this.content = /* @__PURE__ */ new Map();
8284
- }
8285
- async putContent(assetId, content) {
8286
- const validation = validateAssetPayload(content);
8287
- if (!validation.ok) throw new Error(validation.error);
8288
- const bytes = await assetDataToUint8Array(assetToData(content));
8289
- this.content.set(assetId, {
8290
- assetId,
8291
- parts: /* @__PURE__ */ new Map([[1, bytes]]),
8292
- type: content.type
8293
- });
8294
- }
8295
- async putPart(assetId, part) {
8296
- const validation = validateAssetPayload(part, {
8297
- multipart: true,
8298
- isFinal: part.isFinal
8299
- });
8300
- if (!validation.ok) throw new Error(validation.error);
8301
- const bytes = await assetDataToUint8Array(assetToData(part));
8302
- const existing = this.content.get(assetId) ?? {
8303
- assetId,
8304
- parts: /* @__PURE__ */ new Map(),
8305
- type: part.type
8306
- };
8307
- existing.parts.set(part.partNumber ?? 1, bytes);
8308
- existing.type = part.type ?? existing.type;
8309
- this.content.set(assetId, existing);
8310
- }
8311
- async getContent(assetId) {
8312
- return this.content.get(assetId);
8313
- }
8314
- async deleteContent(assetId) {
8315
- this.content.delete(assetId);
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
- var InMemoryMockDatabaseStorage = class {
8319
- constructor() {
8320
- this.assets = /* @__PURE__ */ new Map();
8321
- this.folders = /* @__PURE__ */ new Map();
8322
- }
8323
- async createAsset(asset) {
8324
- this.assets.set(asset.id, asset);
8325
- return asset;
8326
- }
8327
- async updateAsset(asset) {
8328
- this.assets.set(asset.id, asset);
8329
- return asset;
8330
- }
8331
- async getAsset(assetId) {
8332
- return this.assets.get(assetId);
8333
- }
8334
- async listAssets(projectId) {
8335
- return [...this.assets.values()].filter((asset) => asset.projectId === projectId);
8336
- }
8337
- async deleteAsset(assetId) {
8338
- this.assets.delete(assetId);
8339
- }
8340
- async createFolder(folder) {
8341
- this.folders.set(folder.id, folder);
8342
- return folder;
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
- async updateFolder(folder) {
8345
- this.folders.set(folder.id, folder);
8346
- return folder;
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
- async getFolder(folderId) {
8349
- return this.folders.get(folderId);
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
- async listFolders(projectId) {
8352
- return [...this.folders.values()].filter((folder) => folder.projectId === projectId).sort((a, b) => a.name.localeCompare(b.name));
8310
+ #checkCategory(category) {
8311
+ if (!MOCK_CATEGORIES.includes(category)) throw invalidArgument(`${category} is not a CalmLens category`);
8353
8312
  }
8354
- async deleteFolder(folderId) {
8355
- this.folders.delete(folderId);
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
- //#endregion
8359
- //#region src/testing/MockUtils.ts
8360
- const inferAssetType = (options) => {
8361
- if ("url" in options) return "website";
8362
- const type = "content" in options ? options.content.type : options.part.type;
8363
- if (type.startsWith("image/") || [
8364
- "jpg",
8365
- "jpeg",
8366
- "png",
8367
- "gif",
8368
- "webp",
8369
- "avif"
8370
- ].includes(type)) return "image";
8371
- if (type.startsWith("video/")) return "video";
8372
- if (type.startsWith("audio/")) return "audio";
8373
- if (type === "text/plain" || type === "txt" || type.startsWith("text/")) return "plaintext-document";
8374
- if (type.includes("pdf") || type.includes("document") || [
8375
- "pdf",
8376
- "doc",
8377
- "docx"
8378
- ].includes(type)) return "multimedia-document";
8379
- return "file";
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
- values = JSON.parse(atob(query.cursor));
8398
- if (!Array.isArray(values) || values.length !== ordering.length || values.some((value, i) => ordering[i].field === "id" ? typeof value !== "string" : typeof value !== "number" || !Number.isFinite(value))) throw new Error();
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 invalidArgument("Invalid cursor");
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 === "DESC" ? result < 0 : result > 0;
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 ? btoa(JSON.stringify(ordering.map(({ field }) => last[field]))) : null
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
- return String(left).localeCompare(String(right));
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: "DESC"
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 === "DESC" ? -result : result;
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), without grouping parentheses.`);
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; grouping parentheses are refused. */
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 for simple expressions: comparisons joined by `;`
8501
- * (and) and `,` (or). Anything it can't evaluate throws instead of returning wrong results.
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
- const alternatives = splitTopLevel(filter, ",").map((group) => splitTopLevel(group, ";"));
8506
- for (const condition of alternatives.flat()) if (condition.trim().startsWith("(")) throw unsupportedFilter(condition);
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
- //#endregion
8510
- //#region src/testing/MockCalmLensServerClient.ts
8511
- const DEFAULT_EVENTS_LIMIT = 50;
8512
- /** Stands in for binary payloads when comparing idempotent requests, like the API's size check. */
8513
- const describeBinary = (value) => {
8514
- if (value instanceof Blob) return `[blob:${value.size}]`;
8515
- if (value instanceof ArrayBuffer) return `[bytes:${value.byteLength}]`;
8516
- if (ArrayBuffer.isView(value)) return `[bytes:${value.byteLength}]`;
8517
- return value;
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
- const MAX_EVENTS_LIMIT = 100;
8520
- const toFailureError = (failure) => {
8521
- if (failure === "transport-error") return new CalmLensError(SDK_ERROR_CODES.TRANSPORT_ERROR, {
8522
- message: "Request failed",
8523
- cause: /* @__PURE__ */ new TypeError("Simulated network failure")
8524
- });
8525
- if (failure === "timeout") {
8526
- const cause = /* @__PURE__ */ new Error("The operation timed out.");
8527
- cause.name = "TimeoutError";
8528
- return new CalmLensError(SDK_ERROR_CODES.TIMEOUT, {
8529
- message: "Request timed out",
8530
- cause
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
- return failure;
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
- * An in-memory stand-in for `CalmLensServerClient` with the same methods, validation and error
8537
- * codes. API errors carry the HTTP status, a `requestId` and response headers like the real
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 MockCalmLensServerClient = class {
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.uploadQueues = /* @__PURE__ */ new Map();
8738
+ this.openUploads = /* @__PURE__ */ new Map();
8546
8739
  this.idempotentResults = /* @__PURE__ */ new Map();
8547
- this.requestCount = 0;
8548
8740
  this.pendingFailures = [];
8549
- this.calls = [];
8550
- validateServerClientOptions(options);
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.databaseStorage = options.mockOptions?.databaseStorage ?? new InMemoryMockDatabaseStorage();
8554
- this.imageStorage = options.mockOptions?.imageStorage ?? new InMemoryMockImageStorage();
8555
- this.createId = options.mockOptions?.createId ?? crypto.randomUUID.bind(crypto);
8556
- this.now = options.mockOptions?.now ?? Date.now;
8557
- this.queuedOutcome = options.mockOptions?.queuedOutcome ?? "pending";
8558
- this.analyzedOutcome = options.mockOptions?.analyzedOutcome ?? "approved";
8559
- this.apiKeyGrants = getMockApiKeyGrants(options.apiKey);
8560
- this.signedUrlBaseUrl = options.mockOptions?.signedUrlBaseUrl ?? "mock://calmlens";
8561
- this.cdnBaseUrl = (options.cdnBaseUrl ?? "mock://calmlens/v1").replace(/\/+$/, "");
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
- operation
8760
+ ...options
8575
8761
  });
8576
8762
  }
8577
- /** Calls recorded for one operation, oldest first. */
8578
- callsTo(operation) {
8579
- return this.calls.filter((call) => call.operation === operation);
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. Returns the
8583
- * stored event; pass it to `toWebhookEventPayload` to build a matching webhook body.
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
- /** An API error as the real client throws it: status, default message and request ID. */
8601
- apiError(code, options = {}) {
8602
- const definition = API_ERROR_DEFINITIONS[code];
8603
- const requestId = `mock-request-${++this.requestCount}`;
8604
- return new CalmLensError(code, {
8605
- message: options.message ?? definition.message,
8606
- status: definition.status,
8607
- details: options.details,
8608
- requestId,
8609
- headers: new Headers({
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
- assertValidUploadMimeType(mimeType) {
8616
- if (isRejectedUploadMimeType(mimeType)) throw this.apiError(API_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
8617
- message: getRejectedUploadMimeTypeError(mimeType),
8618
- details: { mimeType }
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
- invalidPayload(message) {
8622
- return this.apiError(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message });
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
- async findOwnAsset(assetId) {
8625
- encodePathSegment(assetId, "Asset ID");
8626
- const asset = await this.databaseStorage.getAsset(assetId);
8627
- if (!asset || asset.projectId !== this.projectId) throw this.apiError(API_ERROR_CODES.ASSET_NOT_FOUND);
8628
- return asset;
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
- begin(operation, options) {
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
- operation,
8633
- options
8952
+ call,
8953
+ operations
8634
8954
  });
8635
- const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || pending.operation === operation);
8955
+ const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || operations.includes(pending.operation));
8636
8956
  if (index !== -1) {
8637
- const [pending] = this.pendingFailures.splice(index, 1);
8638
- throw toFailureError(pending.failure);
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
- jsonResponse(status, body, url) {
8642
- const requestId = `mock-request-${++this.requestCount}`;
8643
- const response = new Response(JSON.stringify(body), {
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
- "content-type": "application/json",
8647
- "x-request-id": requestId
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
- get cdn() {
8654
- return {
8655
- getAssetUrl: ({ assetIdentifier, ...options }) => {
8656
- const resource = options.resource ?? "content";
8657
- const url = `${this.cdnBaseUrl}/assets/${encodeURIComponent(this.projectId)}/${encodePathSegment(assetIdentifier, "Asset identifier")}/${resource}`;
8658
- return appendCdnSelector(url, options);
8659
- },
8660
- /**
8661
- * Serves stored content like the CDN: `200` with `Content-Type`, `Content-Length`,
8662
- * `ETag` and `X-Request-Id`, or a JSON error body with `404`/`403`. Never throws for
8663
- * HTTP errors, like the real client.
8664
- */
8665
- serve: async (serveOptions) => {
8666
- this.begin("cdn.serve", serveOptions);
8667
- const { assetIdentifier, init, ...options } = serveOptions;
8668
- const url = this.cdn.getAssetUrl({
8669
- assetIdentifier,
8670
- ...options
8671
- });
8672
- const asset = (await this.databaseStorage.listAssets(this.projectId)).find((asset) => asset.id === assetIdentifier || asset.externalId === assetIdentifier);
8673
- const notFound = () => this.jsonResponse(404, {
8674
- code: "asset/not-found",
8675
- message: "Asset not found"
8676
- }, url);
8677
- if (!asset) return notFound();
8678
- if (asset.visibility !== "public" && !permits(this.apiKeyGrants, "assets:download", asset.folderId ?? null, await this.databaseStorage.listFolders?.(this.projectId) ?? [])) return this.jsonResponse(403, {
8679
- code: "asset/api-key-missing-permission",
8680
- message: "This API key needs assets:download permission for this asset’s folder."
8681
- }, url);
8682
- const storedContent = await this.imageStorage.getContent(asset.id);
8683
- if (!storedContent) return notFound();
8684
- const parts = [...storedContent.parts.entries()].sort(([left], [right]) => left - right).map(([, value]) => value);
8685
- const byteLength = parts.reduce((sum, value) => sum + value.byteLength, 0);
8686
- const bytes = new Uint8Array(byteLength);
8687
- let offset = 0;
8688
- for (const part of parts) {
8689
- bytes.set(part, offset);
8690
- offset += part.byteLength;
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
- get events() {
8711
- return {
8712
- /**
8713
- * Reads the mock feed like the API: without `after`, no events and a cursor for "now";
8714
- * with `after`, up to `limit` events added since, oldest first. Add events with
8715
- * `enqueueEvent`.
8716
- */
8717
- list: async (options = {}) => {
8718
- this.begin("events.list", options);
8719
- return this.listEvents(options);
8720
- },
8721
- pages: (options = {}) => this.iterateEventPages(options)
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
- listEvents(options) {
8725
- const limit = options.limit ?? DEFAULT_EVENTS_LIMIT;
8726
- if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENTS_LIMIT) throw this.apiError(API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: `limit must be an integer from 1 to ${MAX_EVENTS_LIMIT}` });
8727
- if (!options.after) return {
8728
- events: [],
8729
- cursor: `mock_${this.eventFeed.length}`,
8730
- hasMore: false
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
- const match = /^mock_(\d+)$/.exec(options.after);
8733
- const position = match ? Number(match[1]) : NaN;
8734
- if (!Number.isSafeInteger(position) || position > this.eventFeed.length) throw this.apiError(API_ERROR_CODES.INVALID_EVENT_CURSOR);
8735
- const events = this.eventFeed.slice(position, position + limit);
8736
- const next = position + events.length;
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
- events,
8739
- cursor: `mock_${next}`,
8740
- hasMore: next < this.eventFeed.length
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 *iterateEventPages(options) {
8744
- let after = options.after;
8745
- for (;;) {
8746
- const page = await this.events.list({
8747
- ...options,
8748
- after
8749
- });
8750
- yield page;
8751
- if (!page.hasMore || page.cursor === after) return;
8752
- after = page.cursor;
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
- async *iteratePages(listPage) {
8756
- let cursor;
8757
- for (;;) {
8758
- const page = await listPage(cursor);
8759
- yield* page.items;
8760
- if (!page.nextCursor || !page.hasMore || page.items.length === 0) return;
8761
- cursor = page.nextCursor;
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
- get text() {
8765
- return { moderate: async (options) => {
8766
- this.begin("text.moderate", options);
8767
- return this.moderateAsset(toTextAssetOptions(options), "text.moderate");
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
- get assets() {
9240
+ pageQuery(query) {
9241
+ const ordering = this.ordering(query);
8771
9242
  return {
8772
- /**
8773
- * Matches `query` (case-insensitively) against each asset's name, ID, external ID,
8774
- * description and tags, best match first. Mock scores are 1 for an exact match and 0.5
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
- /** Signs real upload tokens with the mock's API key, like the real client, without a request. */
8949
- get uploadTokens() {
8950
- return { sign: async (options) => {
8951
- this.begin("uploadTokens.sign", options);
8952
- return signUploadTokenWithApiKey(this.apiKey, options, {
8953
- now: this.now(),
8954
- nonce: this.createId()
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
- get folders() {
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
- list: async () => {
8961
- this.begin("folders.list", void 0);
8962
- return [...this.databaseStorage.listFolders ? await this.databaseStorage.listFolders(this.projectId) : []].sort((a, b) => a.name.localeCompare(b.name));
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 ensureLens(options) {
9032
- this.begin("ensureLens", options);
9033
- if (!options?.identifier) throw invalidArgument("Lens identifier is required");
9034
- return this.ensureLensDefinition(options);
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
- ensureLensDefinition(options) {
9037
- const existing = this.lenses.get(options.identifier);
9038
- if (!existing) {
9039
- const lens = {
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
- 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;
9051
- if (options.version > existing.version) {
9052
- const lens = {
9053
- ...existing,
9054
- ...options,
9055
- updatedAt: this.now()
9056
- };
9057
- this.lenses.set(lens.identifier, lens);
9058
- return lens;
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 this.apiError(API_ERROR_CODES.LENS_VERSION_CONFLICT, { details: {
9061
- currentVersion: existing.version,
9062
- minimumVersion: existing.version + 1,
9063
- requestedVersion: options.version
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
- async ensureLenses(options) {
9067
- this.begin("ensureLenses", options);
9068
- if (!Array.isArray(options?.lenses) || options.lenses.length === 0) throw invalidArgument("At least one lens is required");
9069
- if (options.lenses.some((lens) => !lens?.identifier)) throw invalidArgument("Lens identifier is required");
9070
- return options.lenses.map((option) => this.ensureLensDefinition(option));
9071
- }
9072
- /** Replays the first result for a reused idempotency key, like the API. */
9073
- async withIdempotency(operation, options, run) {
9074
- const key = options?.idempotencyKey;
9075
- if (key === void 0) return run();
9076
- if (!key || key.length > 255 || !/^[\x21-\x7e]+$/.test(key)) throw this.apiError(API_ERROR_CODES.INVALID_IDEMPOTENCY_KEY);
9077
- const fingerprint = `${operation}:${stringifyJsonCanonical(JSON.parse(JSON.stringify(options, (_key, value) => describeBinary(value))))}`;
9078
- const saved = this.idempotentResults.get(key);
9079
- if (saved) {
9080
- if (saved.fingerprint !== fingerprint) throw this.apiError(API_ERROR_CODES.IDEMPOTENCY_KEY_REUSED);
9081
- return saved.asset;
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
- return asset;
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
- async moderateAsset(options, operation) {
9091
- if (options && "content" in options && "plainText" in options.content && (typeof options.content.plainText !== "string" || !options.content.plainText.trim())) throw this.invalidPayload("Text to moderate must be a non-empty string");
9092
- return this.withIdempotency(operation, options, () => this.createAsset(options, false));
9093
- }
9094
- async createAsset(options, queue) {
9095
- if (!options || typeof options !== "object") throw invalidArgument("Asset options are required");
9096
- if (!("content" in options) && !("url" in options) && !("part" in options)) throw this.invalidPayload("Either file content, URL, or multipart part is required");
9097
- if (!options.name) throw this.invalidPayload("Asset name is required");
9098
- if (!("part" in options) && "isFinal" in options) throw this.invalidPayload("isFinal is only valid for multipart uploads");
9099
- if ("part" in options && options.part.partNumber && options.part.partNumber > 1) throw this.apiError(API_ERROR_CODES.USE_UPLOAD_PART);
9100
- if ("content" in options) {
9101
- this.assertValidUploadMimeType(options.content.type);
9102
- if (!("plainText" in options.content)) {
9103
- const validation = validateAssetPayload(options.content);
9104
- if (!validation.ok) throw this.invalidPayload(validation.error);
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
- if ("part" in options) {
9108
- this.assertValidUploadMimeType(options.part.type);
9109
- const validation = validateAssetPayload(options.part, {
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: options.part.isFinal
9494
+ isFinal: part.isFinal
9112
9495
  });
9113
- if (!validation.ok) throw this.invalidPayload(validation.error);
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 (options.externalId) {
9116
- if ((await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.externalId === options.externalId)) throw this.apiError(API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
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 = "part" in options && !options.part.isFinal;
9121
- const keepContent = options.processingOptions?.keepContentAfterProcessing === true;
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
- folderId: options.folderId ?? null,
9516
+ parentId: null,
9517
+ folderId: fields.folderId ?? null,
9518
+ uploadPresetId: fields.uploadPresetId ?? null,
9126
9519
  createdAt: now,
9127
9520
  updatedAt: now,
9128
- name: options.name,
9129
- description: options.description,
9130
- externalId: options.externalId,
9131
- externalUrl: "url" in options ? options.url : "content" in options ? options.externalUrl : void 0,
9132
- type: inferAssetType(options),
9133
- metadata: options.metadata,
9134
- processingOptions: options.processingOptions,
9135
- visibility: options.visibility ?? "private",
9136
- processing: {
9137
- status: isOpenUpload ? "uploading" : "processing",
9138
- updatedAt: now,
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: this.getContentFormat(options),
9537
+ format: hasContent ? contentType ?? null : null,
9158
9538
  extension: null,
9159
- sizeInBytes: this.getSizeInBytes(options) ?? null,
9539
+ sizeInBytes: hasContent ? bytes.byteLength : null,
9160
9540
  checksums: null,
9161
9541
  image: null,
9162
- previewImage: null,
9163
- extractedText: null
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 ("content" in options) {
9167
- if (!("plainText" in options.content)) await this.imageStorage.putContent(id, options.content);
9168
- } else if ("part" in options) {
9169
- await this.imageStorage.putPart(id, options.part);
9170
- if (isOpenUpload) this.uploadQueues.set(id, queue);
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
- return this.databaseStorage.createAsset(isOpenUpload ? asset : await this.applyOutcome(asset, queue ? this.queuedOutcome : this.analyzedOutcome));
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, and
9176
- * finished processing removes content kept only temporarily (`keepContentAfterProcessing` off).
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 === "approved" || outcome === "rejected";
9181
- const processingStatus = decided ? "done" : outcome === "failed" ? "failed" : "processing";
9182
- const removeContent = processingStatus === "done" && asset.content.status === "temporary";
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
- processing: {
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
- } : asset.content
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
- if (!this.databaseStorage.getFolder) return void 0;
9208
- const folder = await this.databaseStorage.getFolder(folderId);
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 this.apiError(API_ERROR_CODES.FOLDER_NOT_FOUND);
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 ((this.databaseStorage.listFolders ? await this.databaseStorage.listFolders(this.projectId) : []).find((f) => f.id !== excludeFolderId && f.parentId === (parentId ?? null) && f.name === name)) throw this.apiError(API_ERROR_CODES.DUPLICATE_FOLDER_NAME, { message: `A folder named "${name}" already exists in this location.` });
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
- getContentFormat(options) {
9222
- if ("content" in options) return options.content.type;
9223
- if ("part" in options) return options.part.type;
9224
- return null;
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
- getSizeInBytes(options) {
9227
- if ("content" in options) {
9228
- if ("plainText" in options.content) return new TextEncoder().encode(options.content.plainText).byteLength;
9229
- return getAssetPayloadByteLength(options.content);
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
- if ("part" in options) return options.part.sizeInBytes ?? options.sizeInBytes ?? getAssetPayloadByteLength(options.part);
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
- //#endregion
9235
- //#region src/testing/MockWebhooks.ts
9236
- /** The JSON body of a webhook delivery for a stored event (or an event from `events.list`). */
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
- data: {
9243
- severity: event.severity,
9244
- title: event.title,
9245
- description: event.description ?? null,
9246
- assetId: event.assetId ?? null,
9247
- asset: event.asset ?? null,
9248
- facts: event.facts
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
- //#endregion
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 };