@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/cjs/testing.js CHANGED
@@ -1,34 +1,42 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region \0rolldown/runtime.js
3
- var __create = Object.create;
4
- var __defProp$1 = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __copyProps = (to, from, except, desc) => {
10
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
- key = keys[i];
12
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp$1(to, key, {
13
- get: ((k) => from[k]).bind(null, key),
14
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
- });
16
- }
17
- return to;
18
- };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp$1(target, "default", {
20
- value: mod,
21
- enumerable: true
22
- }) : target, mod));
23
- //#endregion
24
- const require_CalmLensVersion = require("./CalmLensVersion-DwhgxQK5.js");
25
- const require_CalmLensWebhooks = require("./CalmLensWebhooks-BKrq30Yp.js");
26
- const require_CalmLensUtils = require("./CalmLensUtils-o171ownK.js");
2
+ const require_Jurisdiction_FusEXb4U = require("./Jurisdiction-FusEXb4U-zVs21tDv.js");
3
+ const require_ModerationContextLimits = require("./ModerationContextLimits-Dj1YAl3G.js");
4
+ const require_CalmLensServerClient = require("./CalmLensServerClient-C-pxiz0C.js");
5
+ const require_CalmLensConnection = require("./CalmLensConnection-K1TAD1Z-.js");
6
+ const require_CryptoUtils_B_x8lMIx = require("./CryptoUtils-B_x8lMIx-YBW1-bvL.js");
7
+ const require_CalmLensAppClient = require("./CalmLensAppClient-DBGmaDlG.js");
8
+ const require_UploadController = require("./UploadController-BBkN4Ni6.js");
9
+ const require_TestTriggers_DcArt0Nu = require("./TestTriggers-DcArt0Nu-CvoaONUB.js");
27
10
  let zod = require("zod");
28
- zod = __toESM(zod, 1);
29
- //#region src/contract/Classification-I_opp8t0.js
11
+ zod = require_Jurisdiction_FusEXb4U.__toESM(zod, 1);
12
+ // src/contract/MediaTestTriggers.js
13
+ const MEDIA_TEST_SIGNALS = [...require_TestTriggers_DcArt0Nu.CLASSIFICATION_CATEGORIES, "violence.graphic"];
14
+ const HEX_WORDS = [
15
+ "zero",
16
+ "one",
17
+ "two",
18
+ "three",
19
+ "four",
20
+ "five",
21
+ "six",
22
+ "seven",
23
+ "eight",
24
+ "nine",
25
+ "alpha",
26
+ "bravo",
27
+ "charlie",
28
+ "delta",
29
+ "echo",
30
+ "foxtrot"
31
+ ];
32
+ /** Read this aloud, or render it as large, high-contrast text in a video frame. */
33
+ async function generateMediaTestCode(signal, testInputKey) {
34
+ if (!MEDIA_TEST_SIGNALS.includes(signal)) throw new RangeError("Unsupported media test signal");
35
+ return `Calm Lens test ${[...await require_TestTriggers_DcArt0Nu.getTestInputTag("media", signal, testInputKey)].map((character) => HEX_WORDS[Number.parseInt(character, 16)]).join(" ")} ${signal.replace(/[.-]/g, " ")} end test`;
36
+ }
37
+ // src/contract/Ids-aHyP9ZcF.js
30
38
  var __defProp = Object.defineProperty;
31
- var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
39
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod, mod.exports), cb = null), mod.exports);
32
40
  var __exportAll = (all, no_symbols) => {
33
41
  let target = {};
34
42
  for (var name in all) __defProp(target, name, {
@@ -38,98 +46,6 @@ var __exportAll = (all, no_symbols) => {
38
46
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
39
47
  return target;
40
48
  };
41
- const MODERATION_CATEGORY_DEFINITIONS = {
42
- sexual: {
43
- summary: "Sexual acts, explicit sexual anatomy, sexual services, sexual assault depictions and clearly erotic material.",
44
- 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.",
45
- 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.",
46
- docsHref: "/docs/guides/moderation-categories#sexual-content"
47
- },
48
- violence: {
49
- summary: "Physical attacks, injury, death, threats against others, and planning or encouraging physical violence.",
50
- 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.",
51
- 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.",
52
- docsHref: "/docs/guides/moderation-categories#violence"
53
- },
54
- "self-harm": {
55
- summary: "Intentional self-injury, suicide, preparation, methods or encouragement; text also includes current or recurring self-harm thoughts.",
56
- 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.",
57
- 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.",
58
- docsHref: "/docs/guides/moderation-categories#self-harm"
59
- },
60
- harassment: {
61
- summary: "Targeted personal abuse, humiliation, bullying or serious intimidation; dislike, ordinary criticism and neutral reporting alone are excluded.",
62
- 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.",
63
- 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.",
64
- docsHref: "/docs/guides/moderation-categories#harassment-and-bullying"
65
- },
66
- hate: {
67
- summary: "Identity-based abuse, dehumanization, discriminatory exclusion or threats against people because of protected characteristics.",
68
- 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.",
69
- 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.",
70
- docsHref: "/docs/guides/moderation-categories#hate-and-identity-abuse"
71
- },
72
- "criminal-activity": {
73
- summary: "Planning, assistance, instructions, encouragement or facilitation of a concrete criminal or seriously dangerous act, fraud and scams, and illegal drugs or contraband.",
74
- 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.",
75
- 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.",
76
- docsHref: "/docs/guides/moderation-categories#criminal-and-dangerous-activity"
77
- }
78
- };
79
- const MODERATION_QUALIFIERS = [
80
- "sexual-nudity",
81
- "sexual-activity",
82
- "sexual-explicit-language",
83
- "sexual-solicitation",
84
- "sexual-historical",
85
- "sexual-documentary",
86
- "sexual-educational",
87
- "sexual-fictional",
88
- "sexual-support-prevention",
89
- "violence-attack",
90
- "violence-threat",
91
- "violence-preparation",
92
- "violence-encouragement",
93
- "violence-injury",
94
- "violence-violent-death",
95
- "violence-natural-death",
96
- "violence-graphic",
97
- "violence-historical",
98
- "violence-documentary",
99
- "violence-educational",
100
- "violence-fictional",
101
- "violence-support-prevention",
102
- "self-harm-thoughts",
103
- "self-harm-preparation",
104
- "self-harm-act",
105
- "self-harm-methods",
106
- "self-harm-encouragement",
107
- "self-harm-documentary",
108
- "self-harm-educational",
109
- "self-harm-fictional",
110
- "self-harm-support-prevention",
111
- "harassment-abuse",
112
- "harassment-intimidation",
113
- "harassment-threat",
114
- "harassment-sexual",
115
- "harassment-encouragement",
116
- "harassment-fictional",
117
- "hate-abuse",
118
- "hate-dehumanization",
119
- "hate-inferiority",
120
- "hate-exclusion",
121
- "hate-threat",
122
- "hate-historical",
123
- "hate-fictional",
124
- "criminal-activity-demonstration",
125
- "criminal-activity-preparation",
126
- "criminal-activity-request",
127
- "criminal-activity-instructions",
128
- "criminal-activity-promotion",
129
- "criminal-activity-substances",
130
- "criminal-activity-fraud",
131
- "criminal-activity-fictional"
132
- ];
133
49
  function getEnumValues(entries) {
134
50
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
135
51
  return Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v);
@@ -462,6 +378,17 @@ function prefixIssues(path, issues) {
462
378
  function unwrapMessage(message) {
463
379
  return typeof message === "string" ? message : message?.message;
464
380
  }
381
+ /** The leading size checks of `defChecks` that set an upper bound, each paired with it. */
382
+ function sizeCaps(defChecks) {
383
+ const caps = [];
384
+ for (const ch of defChecks ?? []) {
385
+ const d = ch._zod.def;
386
+ if (!/_(length|size)$|_equals$/.test(d.check)) break;
387
+ const max = d.maximum ?? d.length ?? d.size;
388
+ if (max >= 0) caps.push([max, ch]);
389
+ }
390
+ return caps;
391
+ }
465
392
  function attachSchema(issues, start, inst) {
466
393
  var _a;
467
394
  for (let i = start; i < issues.length; i++) (_a = issues[i]).schema ?? (_a.schema = inst);
@@ -560,6 +487,7 @@ function hide(inst, key, value) {
560
487
  return own(inst, key, value, false);
561
488
  }
562
489
  /** 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. */
490
+ /* @__NO_SIDE_EFFECTS__ */
563
491
  function derived(computes, table) {
564
492
  for (const key in computes) {
565
493
  const compute = computes[key];
@@ -709,6 +637,7 @@ function newError(Definition) {
709
637
  }
710
638
  return new Definition();
711
639
  }
640
+ /* @__NO_SIDE_EFFECTS__ */
712
641
  function $constructor(name, initializer, proto, params) {
713
642
  const zodProto = {};
714
643
  function Internals(def) {
@@ -837,8 +766,8 @@ const initializer$1 = (inst, def) => {
837
766
  });
838
767
  }
839
768
  };
840
- const $ZodError = $constructor("$ZodError", initializer$1);
841
- const $ZodRealError = $constructor("$ZodError", initializer$1, void 0, { Parent: Error });
769
+ const $ZodError = /* @__PURE__ */ $constructor("$ZodError", initializer$1);
770
+ const $ZodRealError = /* @__PURE__ */ $constructor("$ZodError", initializer$1, void 0, { Parent: Error });
842
771
  /** Get-or-create `obj[key]` as an own data property. A path segment naming an inherited member
843
772
  * ("toString", "constructor") would otherwise read through to the prototype, and assigning
844
773
  * "__proto__" would hit the setter instead of creating a key. */
@@ -1772,7 +1701,7 @@ var Doc = class {
1772
1701
  const version = {
1773
1702
  major: 4,
1774
1703
  minor: 6,
1775
- patch: 5
1704
+ patch: 7
1776
1705
  };
1777
1706
  const $ZodType = /*@__PURE__*/ $constructor("$ZodType", (inst, def) => {
1778
1707
  var _a;
@@ -2464,6 +2393,28 @@ const $ZodDate = /*@__PURE__*/ $constructor("$ZodDate", (inst, def) => {
2464
2393
  return payload;
2465
2394
  };
2466
2395
  });
2396
+ function capSize(inst, size) {
2397
+ const caps = sizeCaps(inst._zod.def.checks);
2398
+ if (!caps.length) return;
2399
+ const cap = Math.min(...caps.map((c) => c[0]));
2400
+ const parse = inst._zod.parse;
2401
+ inst._zod.parse = (payload, ctx) => {
2402
+ const n = size(payload.value);
2403
+ if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
2404
+ for (const [max, ch] of sizeCaps(inst._zod.def.checks)) if (n > max && ch._zod.def.when(payload)) {
2405
+ const start = payload.issues.length;
2406
+ ch._zod.check(payload);
2407
+ payload.issues[start].continue = false;
2408
+ attachSchema(payload.issues, start, inst);
2409
+ return payload;
2410
+ }
2411
+ }
2412
+ return parse(payload, ctx);
2413
+ };
2414
+ }
2415
+ const arraySize = (input) => Array.isArray(input) ? input.length : -1;
2416
+ const mapSize = (input) => input instanceof Map ? input.size : -1;
2417
+ const setSize = (input) => input instanceof Set ? input.size : -1;
2467
2418
  function handleArrayResult(result, final, index) {
2468
2419
  if (result.issues.length) final.issues.push(...prefixIssues(index, result.issues));
2469
2420
  final.value[index] = result.value;
@@ -2501,6 +2452,7 @@ const $ZodArray = /*@__PURE__*/ $constructor("$ZodArray", (inst, def) => {
2501
2452
  if (proms.length) return Promise.all(proms).then(() => payload);
2502
2453
  return payload;
2503
2454
  };
2455
+ capSize(inst, arraySize);
2504
2456
  });
2505
2457
  function handlePropertyResult(result, final, key, input, optin, optout) {
2506
2458
  const isPresent = key in input;
@@ -2596,8 +2548,8 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
2596
2548
  Object.defineProperty(def, "shape", { get });
2597
2549
  }
2598
2550
  const _normalized = cached(() => normalizeDef(def));
2599
- defineLazyInternal(inst, "propValues", (zod$1) => {
2600
- const shape = zod$1.def.shape;
2551
+ defineLazyInternal(inst, "propValues", (zod) => {
2552
+ const shape = zod.def.shape;
2601
2553
  const propValues = {};
2602
2554
  for (const key in shape) {
2603
2555
  const field = shape[key]._zod;
@@ -2609,7 +2561,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
2609
2561
  }
2610
2562
  return propValues;
2611
2563
  });
2612
- const isObject$2 = isObject;
2564
+ const isObject$1 = isObject;
2613
2565
  const catchall = def.catchall;
2614
2566
  let value;
2615
2567
  const memo = globalConfig.memoizer;
@@ -2617,7 +2569,7 @@ const $ZodObject = /*@__PURE__*/ $constructor("$ZodObject", (inst, def) => {
2617
2569
  inst._zod.parse = (payload, ctx) => {
2618
2570
  value ?? (value = _normalized.value);
2619
2571
  const input = payload.value;
2620
- if (!isObject$2(input)) {
2572
+ if (!isObject$1(input)) {
2621
2573
  payload.issues.push({
2622
2574
  expected: "object",
2623
2575
  code: "invalid_type",
@@ -2747,7 +2699,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
2747
2699
  return doc.compile();
2748
2700
  };
2749
2701
  let fastpass;
2750
- const isObject$1 = isObject;
2702
+ const isObject$2 = isObject;
2751
2703
  const jit = !globalConfig.jitless;
2752
2704
  const fastEnabled = jit && allowsEval.value;
2753
2705
  const catchall = def.catchall;
@@ -2755,7 +2707,7 @@ const $ZodObjectJIT = /*@__PURE__*/ $constructor("$ZodObjectJIT", (inst, def) =>
2755
2707
  inst._zod.parse = (payload, ctx) => {
2756
2708
  value ?? (value = _normalized.value);
2757
2709
  const input = payload.value;
2758
- if (!isObject$1(input)) {
2710
+ if (!isObject$2(input)) {
2759
2711
  payload.issues.push({
2760
2712
  expected: "object",
2761
2713
  code: "invalid_type",
@@ -2793,14 +2745,14 @@ function handleUnionResults(results, final, inst, ctx) {
2793
2745
  }
2794
2746
  const $ZodUnion = /*@__PURE__*/ $constructor("$ZodUnion", (inst, def) => {
2795
2747
  $ZodType.init(inst, def);
2796
- defineLazyInternal(inst, "optin", (zod$2) => zod$2.def.options.some((o) => o._zod.optin === "defaulted") ? "defaulted" : zod$2.def.options.some((o) => o._zod.optin !== void 0) ? "optional" : void 0);
2797
- defineLazyInternal(inst, "optout", (zod$3) => zod$3.def.options.some((o) => o._zod.optout === "optional") ? "optional" : void 0);
2798
- defineLazyInternal(inst, "values", (zod$4) => {
2799
- if (zod$4.def.options.every((o) => o._zod.values)) return new Set(zod$4.def.options.flatMap((option) => Array.from(option._zod.values)));
2800
- });
2801
- defineLazyInternal(inst, "pattern", (zod$5) => {
2802
- if (zod$5.def.options.every((o) => o._zod.pattern)) {
2803
- const patterns = zod$5.def.options.map((o) => o._zod.pattern);
2748
+ defineLazyInternal(inst, "optin", (zod) => zod.def.options.some((o) => o._zod.optin === "defaulted") ? "defaulted" : zod.def.options.some((o) => o._zod.optin !== void 0) ? "optional" : void 0);
2749
+ defineLazyInternal(inst, "optout", (zod) => zod.def.options.some((o) => o._zod.optout === "optional") ? "optional" : void 0);
2750
+ defineLazyInternal(inst, "values", (zod) => {
2751
+ if (zod.def.options.every((o) => o._zod.values)) return new Set(zod.def.options.flatMap((option) => Array.from(option._zod.values)));
2752
+ });
2753
+ defineLazyInternal(inst, "pattern", (zod) => {
2754
+ if (zod.def.options.every((o) => o._zod.pattern)) {
2755
+ const patterns = zod.def.options.map((o) => o._zod.pattern);
2804
2756
  return new RegExp(`^(${patterns.map((p) => cleanRegex(p.source)).join("|")})$`);
2805
2757
  }
2806
2758
  });
@@ -2891,19 +2843,19 @@ const $ZodDiscriminatedUnion = /*@__PURE__*/ $constructor("$ZodDiscriminatedUnio
2891
2843
  def.inclusive = false;
2892
2844
  $ZodUnion.init(inst, def);
2893
2845
  const _super = inst._zod.parse;
2894
- defineLazyInternal(inst, "propValues", (zod$6) => {
2846
+ defineLazyInternal(inst, "propValues", (zod) => {
2895
2847
  const propValues = {};
2896
2848
  let undefinedCount = 0;
2897
- for (const option of zod$6.def.options) {
2849
+ for (const option of zod.def.options) {
2898
2850
  const pv = option._zod.propValues;
2899
- if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${zod$6.def.options.indexOf(option)}"`);
2900
- if (pv[zod$6.def.discriminator]?.has(void 0)) undefinedCount++;
2851
+ if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${zod.def.options.indexOf(option)}"`);
2852
+ if (pv[zod.def.discriminator]?.has(void 0)) undefinedCount++;
2901
2853
  for (const [k, v] of Object.entries(pv)) {
2902
2854
  if (!Object.prototype.hasOwnProperty.call(propValues, k)) assignProp(propValues, k, /* @__PURE__ */ new Set());
2903
2855
  for (const val of v) propValues[k].add(val);
2904
2856
  }
2905
2857
  }
2906
- if (!zod$6.def.unionFallback && undefinedCount > 1) propValues[zod$6.def.discriminator]?.delete(void 0);
2858
+ if (!zod.def.unionFallback && undefinedCount > 1) propValues[zod.def.discriminator]?.delete(void 0);
2907
2859
  return propValues;
2908
2860
  });
2909
2861
  def.options.forEach((option, i) => {
@@ -3333,6 +3285,7 @@ const $ZodMap = /*@__PURE__*/ $constructor("$ZodMap", (inst, def) => {
3333
3285
  if (proms.length) return Promise.all(proms).then(() => payload);
3334
3286
  return payload;
3335
3287
  };
3288
+ capSize(inst, mapSize);
3336
3289
  });
3337
3290
  function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
3338
3291
  if (keyResult.issues.length) {
@@ -3392,6 +3345,7 @@ const $ZodSet = /*@__PURE__*/ $constructor("$ZodSet", (inst, def) => {
3392
3345
  if (proms.length) return Promise.all(proms).then(() => payload);
3393
3346
  return payload;
3394
3347
  };
3348
+ capSize(inst, setSize);
3395
3349
  });
3396
3350
  function handleSetResult(result, final) {
3397
3351
  if (result.issues.length) final.issues.push(...result.issues);
@@ -3402,8 +3356,8 @@ const $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
3402
3356
  const values = getEnumValues(def.entries);
3403
3357
  const valuesSet = new Set(values);
3404
3358
  inst._zod.values = valuesSet;
3405
- defineLazyInternal(inst, "pattern", (zod$7) => {
3406
- const patternValues = getEnumValues(zod$7.def.entries).filter((k) => propertyKeyTypes.has(typeof k));
3359
+ defineLazyInternal(inst, "pattern", (zod) => {
3360
+ const patternValues = getEnumValues(zod.def.entries).filter((k) => propertyKeyTypes.has(typeof k));
3407
3361
  return new RegExp(patternValues.length ? `^(${patternValues.map((o) => escapeRegex(o.toString())).join("|")})$` : "^[^\\s\\S]$");
3408
3362
  });
3409
3363
  inst._zod.parse = (payload, _ctx) => {
@@ -3422,8 +3376,8 @@ const $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
3422
3376
  $ZodType.init(inst, def);
3423
3377
  const values = new Set(def.values);
3424
3378
  inst._zod.values = values;
3425
- defineLazyInternal(inst, "pattern", (zod$8) => {
3426
- const vals = zod$8.def.values;
3379
+ defineLazyInternal(inst, "pattern", (zod) => {
3380
+ const vals = zod.def.values;
3427
3381
  return new RegExp(vals.length ? `^(${vals.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$` : "^[^\\s\\S]$");
3428
3382
  });
3429
3383
  inst._zod.parse = (payload, _ctx) => {
@@ -3474,14 +3428,14 @@ function handleOptionalResult(payload, result) {
3474
3428
  }
3475
3429
  const $ZodOptional = /*@__PURE__*/ $constructor("$ZodOptional", (inst, def) => {
3476
3430
  $ZodType.init(inst, def);
3477
- defineLazyInternal(inst, "optin", (zod$9) => zod$9.def.innerType._zod.optin === "defaulted" ? "defaulted" : "optional");
3431
+ defineLazyInternal(inst, "optin", (zod) => zod.def.innerType._zod.optin === "defaulted" ? "defaulted" : "optional");
3478
3432
  inst._zod.optout = "optional";
3479
- defineLazyInternal(inst, "values", (zod$10) => {
3480
- const values = zod$10.def.innerType._zod.values;
3433
+ defineLazyInternal(inst, "values", (zod) => {
3434
+ const values = zod.def.innerType._zod.values;
3481
3435
  return values ? /* @__PURE__ */ new Set([...values, void 0]) : void 0;
3482
3436
  });
3483
- defineLazyInternal(inst, "pattern", (zod$11) => {
3484
- const pattern = zod$11.def.innerType._zod.pattern;
3437
+ defineLazyInternal(inst, "pattern", (zod) => {
3438
+ const pattern = zod.def.innerType._zod.pattern;
3485
3439
  return pattern ? new RegExp(`^(${cleanRegex(pattern.source)})?$`) : void 0;
3486
3440
  });
3487
3441
  inst._zod.parse = (payload, ctx) => {
@@ -3499,22 +3453,22 @@ const $ZodOptional = /*@__PURE__*/ $constructor("$ZodOptional", (inst, def) => {
3499
3453
  });
3500
3454
  const $ZodExactOptional = /*@__PURE__*/ $constructor("$ZodExactOptional", (inst, def) => {
3501
3455
  $ZodOptional.init(inst, def);
3502
- defineLazyInternal(inst, "values", (zod$12) => zod$12.def.innerType._zod.values);
3503
- defineLazyInternal(inst, "pattern", (zod$13) => zod$13.def.innerType._zod.pattern);
3456
+ defineLazyInternal(inst, "values", (zod) => zod.def.innerType._zod.values);
3457
+ defineLazyInternal(inst, "pattern", (zod) => zod.def.innerType._zod.pattern);
3504
3458
  inst._zod.parse = (payload, ctx) => {
3505
3459
  return def.innerType._zod.run(payload, ctx);
3506
3460
  };
3507
3461
  });
3508
3462
  const $ZodNullable = /*@__PURE__*/ $constructor("$ZodNullable", (inst, def) => {
3509
3463
  $ZodType.init(inst, def);
3510
- defineLazyInternal(inst, "optin", (zod$14) => zod$14.def.innerType._zod.optin);
3511
- defineLazyInternal(inst, "optout", (zod$15) => zod$15.def.innerType._zod.optout);
3512
- defineLazyInternal(inst, "pattern", (zod$16) => {
3513
- const pattern = zod$16.def.innerType._zod.pattern;
3464
+ defineLazyInternal(inst, "optin", (zod) => zod.def.innerType._zod.optin);
3465
+ defineLazyInternal(inst, "optout", (zod) => zod.def.innerType._zod.optout);
3466
+ defineLazyInternal(inst, "pattern", (zod) => {
3467
+ const pattern = zod.def.innerType._zod.pattern;
3514
3468
  return pattern ? new RegExp(`^(${cleanRegex(pattern.source)}|null)$`) : void 0;
3515
3469
  });
3516
- defineLazyInternal(inst, "values", (zod$17) => {
3517
- return zod$17.def.innerType._zod.values ? /* @__PURE__ */ new Set([...zod$17.def.innerType._zod.values, null]) : void 0;
3470
+ defineLazyInternal(inst, "values", (zod) => {
3471
+ return zod.def.innerType._zod.values ? /* @__PURE__ */ new Set([...zod.def.innerType._zod.values, null]) : void 0;
3518
3472
  });
3519
3473
  inst._zod.parse = (payload, ctx) => {
3520
3474
  if (payload.value === null) return payload;
@@ -3524,7 +3478,7 @@ const $ZodNullable = /*@__PURE__*/ $constructor("$ZodNullable", (inst, def) => {
3524
3478
  const $ZodDefault = /*@__PURE__*/ $constructor("$ZodDefault", (inst, def) => {
3525
3479
  $ZodType.init(inst, def);
3526
3480
  inst._zod.optin = "defaulted";
3527
- defineLazyInternal(inst, "values", (zod$18) => zod$18.def.innerType._zod.values);
3481
+ defineLazyInternal(inst, "values", (zod) => zod.def.innerType._zod.values);
3528
3482
  inst._zod.parse = (payload, ctx) => {
3529
3483
  if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx);
3530
3484
  if (payload.value === void 0) {
@@ -3546,7 +3500,7 @@ function handleDefaultResult(payload, def) {
3546
3500
  const $ZodPrefault = /*@__PURE__*/ $constructor("$ZodPrefault", (inst, def) => {
3547
3501
  $ZodType.init(inst, def);
3548
3502
  inst._zod.optin = "defaulted";
3549
- defineLazyInternal(inst, "values", (zod$19) => zod$19.def.innerType._zod.values);
3503
+ defineLazyInternal(inst, "values", (zod) => zod.def.innerType._zod.values);
3550
3504
  inst._zod.parse = (payload, ctx) => {
3551
3505
  if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx);
3552
3506
  if (payload.value === void 0) payload.value = def.defaultValue;
@@ -3555,8 +3509,8 @@ const $ZodPrefault = /*@__PURE__*/ $constructor("$ZodPrefault", (inst, def) => {
3555
3509
  });
3556
3510
  const $ZodNonOptional = /*@__PURE__*/ $constructor("$ZodNonOptional", (inst, def) => {
3557
3511
  $ZodType.init(inst, def);
3558
- defineLazyInternal(inst, "values", (zod$20) => {
3559
- const v = zod$20.def.innerType._zod.values;
3512
+ defineLazyInternal(inst, "values", (zod) => {
3513
+ const v = zod.def.innerType._zod.values;
3560
3514
  return v ? new Set([...v].filter((x) => x !== void 0)) : void 0;
3561
3515
  });
3562
3516
  inst._zod.parse = (payload, ctx) => {
@@ -3603,9 +3557,9 @@ function handleCatchResult(payload, result, def, ctx) {
3603
3557
  }
3604
3558
  const $ZodCatch = /*@__PURE__*/ $constructor("$ZodCatch", (inst, def) => {
3605
3559
  $ZodType.init(inst, def);
3606
- defineLazyInternal(inst, "optin", (zod$21) => zod$21.def.innerType._zod.optin === "defaulted" ? "defaulted" : "optional");
3607
- defineLazyInternal(inst, "optout", (zod$22) => zod$22.def.innerType._zod.optout);
3608
- defineLazyInternal(inst, "values", (zod$23) => zod$23.def.innerType._zod.values);
3560
+ defineLazyInternal(inst, "optin", (zod) => zod.def.innerType._zod.optin === "defaulted" ? "defaulted" : "optional");
3561
+ defineLazyInternal(inst, "optout", (zod) => zod.def.innerType._zod.optout);
3562
+ defineLazyInternal(inst, "values", (zod) => zod.def.innerType._zod.values);
3609
3563
  inst._zod.parse = (payload, ctx) => {
3610
3564
  if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx);
3611
3565
  const result = def.innerType._zod.run({
@@ -3633,10 +3587,10 @@ const $ZodNaN = /*@__PURE__*/ $constructor("$ZodNaN", (inst, def) => {
3633
3587
  });
3634
3588
  const $ZodPipe = /*@__PURE__*/ $constructor("$ZodPipe", (inst, def) => {
3635
3589
  $ZodType.init(inst, def);
3636
- defineLazyInternal(inst, "values", (zod$24) => zod$24.def.in._zod.values);
3637
- defineLazyInternal(inst, "optin", (zod$25) => zod$25.def.in._zod.optin);
3638
- defineLazyInternal(inst, "optout", (zod$26) => zod$26.def.out._zod.optout);
3639
- defineLazyInternal(inst, "propValues", (zod$27) => zod$27.def.in._zod.propValues);
3590
+ defineLazyInternal(inst, "values", (zod) => zod.def.in._zod.values);
3591
+ defineLazyInternal(inst, "optin", (zod) => zod.def.in._zod.optin);
3592
+ defineLazyInternal(inst, "optout", (zod) => zod.def.out._zod.optout);
3593
+ defineLazyInternal(inst, "propValues", (zod) => zod.def.in._zod.propValues);
3640
3594
  inst._zod.parse = (payload, ctx) => {
3641
3595
  if (ctx.direction === "backward") {
3642
3596
  const right = def.out._zod.run(payload, ctx);
@@ -3660,10 +3614,10 @@ function handlePipeResult(left, next, ctx) {
3660
3614
  }
3661
3615
  const $ZodCodec = /*@__PURE__*/ $constructor("$ZodCodec", (inst, def) => {
3662
3616
  $ZodType.init(inst, def);
3663
- defineLazyInternal(inst, "values", (zod$28) => zod$28.def.in._zod.values);
3664
- defineLazyInternal(inst, "optin", (zod$29) => zod$29.def.in._zod.optin);
3665
- defineLazyInternal(inst, "optout", (zod$30) => zod$30.def.out._zod.optout);
3666
- defineLazyInternal(inst, "propValues", (zod$31) => zod$31.def.in._zod.propValues);
3617
+ defineLazyInternal(inst, "values", (zod) => zod.def.in._zod.values);
3618
+ defineLazyInternal(inst, "optin", (zod) => zod.def.in._zod.optin);
3619
+ defineLazyInternal(inst, "optout", (zod) => zod.def.out._zod.optout);
3620
+ defineLazyInternal(inst, "propValues", (zod) => zod.def.in._zod.propValues);
3667
3621
  inst._zod.parse = (payload, ctx) => {
3668
3622
  if ((ctx.direction || "forward") === "forward") {
3669
3623
  const left = def.in._zod.run(payload, ctx);
@@ -3706,10 +3660,10 @@ const $ZodPreprocess = /*@__PURE__*/ $constructor("$ZodPreprocess", (inst, def)
3706
3660
  });
3707
3661
  const $ZodReadonly = /*@__PURE__*/ $constructor("$ZodReadonly", (inst, def) => {
3708
3662
  $ZodType.init(inst, def);
3709
- defineLazyInternal(inst, "propValues", (zod$32) => zod$32.def.innerType._zod.propValues);
3710
- defineLazyInternal(inst, "values", (zod$33) => zod$33.def.innerType._zod.values);
3711
- defineLazyInternal(inst, "optin", (zod$34) => zod$34.def.innerType?._zod?.optin);
3712
- defineLazyInternal(inst, "optout", (zod$35) => zod$35.def.innerType?._zod?.optout);
3663
+ defineLazyInternal(inst, "propValues", (zod) => zod.def.innerType._zod.propValues);
3664
+ defineLazyInternal(inst, "values", (zod) => zod.def.innerType._zod.values);
3665
+ defineLazyInternal(inst, "optin", (zod) => zod.def.innerType?._zod?.optin);
3666
+ defineLazyInternal(inst, "optout", (zod) => zod.def.innerType?._zod?.optout);
3713
3667
  inst._zod.parse = (payload, ctx) => {
3714
3668
  if (ctx.direction === "backward") return def.innerType._zod.run(payload, ctx);
3715
3669
  const result = def.innerType._zod.run(payload, ctx);
@@ -3880,10 +3834,10 @@ const $ZodLazy = /*@__PURE__*/ $constructor("$ZodLazy", (inst, def) => {
3880
3834
  if (!d._cachedInner) d._cachedInner = def.getter();
3881
3835
  return d._cachedInner;
3882
3836
  });
3883
- defineLazyInternal(inst, "pattern", (zod$36) => zod$36.innerType?._zod?.pattern);
3884
- defineLazyInternal(inst, "propValues", (zod$37) => zod$37.innerType?._zod?.propValues);
3885
- defineLazyInternal(inst, "optin", (zod$38) => zod$38.innerType?._zod?.optin ?? void 0);
3886
- defineLazyInternal(inst, "optout", (zod$39) => zod$39.innerType?._zod?.optout ?? void 0);
3837
+ defineLazyInternal(inst, "pattern", (zod) => zod.innerType?._zod?.pattern);
3838
+ defineLazyInternal(inst, "propValues", (zod) => zod.innerType?._zod?.propValues);
3839
+ defineLazyInternal(inst, "optin", (zod) => zod.innerType?._zod?.optin ?? void 0);
3840
+ defineLazyInternal(inst, "optout", (zod) => zod.innerType?._zod?.optout ?? void 0);
3887
3841
  inst._zod.parse = (payload, ctx) => {
3888
3842
  return inst._zod.innerType._zod.run(payload, ctx);
3889
3843
  };
@@ -7877,122 +7831,6 @@ ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
7877
7831
  ...schemas_exports,
7878
7832
  ...checks_exports
7879
7833
  });
7880
- const ZOD_META_KEY = "__zod_meta__";
7881
- const createMetaType = (type) => {
7882
- return Object.assign((value) => {
7883
- return {
7884
- type,
7885
- data: value ?? {}
7886
- };
7887
- }, type);
7888
- };
7889
- const metaStore = (meta) => {
7890
- const metaMap = meta.reduce((acc, meta) => {
7891
- if (meta) acc[meta.type.id] = meta;
7892
- return acc;
7893
- }, {});
7894
- return { [ZOD_META_KEY]: {
7895
- itemMap: metaMap,
7896
- get itemList() {
7897
- return Object.values(metaMap);
7898
- }
7899
- } };
7900
- };
7901
- const docPropertyInfo = createMetaType({ id: "docPropertyInfo" });
7902
- /**
7903
- * Optional analyses a request can turn on with `processingOptions.features.<id>`. The catalog is
7904
- * the single source for the request and result schemas, cache keys and metering. Features are
7905
- * chosen per request only; there is no project-level default.
7906
- */
7907
- /** How mature a feature is. Beta results may change shape. */
7908
- const PROCESSING_FEATURE_STAGES = ["beta", "ga"];
7909
- const createFeatureOptionsSchemas = (booleanSchema) => {
7910
- const enabled = booleanSchema.meta(metaStore([docPropertyInfo({ description: "Whether to run the feature. Every feature is off unless this is true." })]));
7911
- return {
7912
- ocr: zod.object({ enabled }).strict().meta(metaStore([docPropertyInfo({
7913
- 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.",
7914
- namedType: "OcrFeatureOptions"
7915
- })])),
7916
- personDetection: zod.object({ enabled }).strict().meta(metaStore([docPropertyInfo({
7917
- description: "Beta. Locate people in images, including people whose faces are not visible. Results are informational and never change the moderation decision.",
7918
- namedType: "PersonDetectionFeatureOptions"
7919
- })])),
7920
- faceDetection: zod.object({
7921
- enabled,
7922
- ageEstimation: booleanSchema.optional().meta(metaStore([docPropertyInfo({
7923
- description: "Also estimate an age range for each face",
7924
- defaultValue: false
7925
- })]))
7926
- }).strict().meta(metaStore([docPropertyInfo({
7927
- 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.",
7928
- namedType: "FaceDetectionFeatureOptions"
7929
- })]))
7930
- };
7931
- };
7932
- const createProcessingFeaturesSchema = (booleanSchema) => {
7933
- const options = createFeatureOptionsSchemas(booleanSchema);
7934
- return zod.object({
7935
- ocr: options.ocr.nullish(),
7936
- personDetection: options.personDetection.nullish(),
7937
- faceDetection: options.faceDetection.nullish()
7938
- }).strict().meta(metaStore([docPropertyInfo({
7939
- description: "Optional analyses to run on this asset, keyed by feature. Unknown features are rejected.",
7940
- namedType: "ProcessingFeatures"
7941
- })]));
7942
- };
7943
- createProcessingFeaturesSchema(zod.boolean());
7944
- /** Position and size as fractions of the image width and height, from 0 to 1. */
7945
- const BOUNDING_BOX_SCHEMA = zod.object({
7946
- left: zod.number(),
7947
- top: zod.number(),
7948
- width: zod.number().min(0),
7949
- height: zod.number().min(0)
7950
- }).strict().meta(metaStore([docPropertyInfo({
7951
- 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.",
7952
- namedType: "BoundingBox"
7953
- })]));
7954
- const DETECTION_CONFIDENCE_SCHEMA = zod.number().min(0).max(1).describe("Detection confidence from 0 to 1");
7955
- const AGE_RANGE_SCHEMA = zod.object({
7956
- low: zod.number().int().nonnegative(),
7957
- high: zod.number().int().nonnegative()
7958
- }).strict().meta(metaStore([docPropertyInfo({
7959
- description: "Estimated age range in years. Estimates can be off by several years.",
7960
- namedType: "AgeRange"
7961
- })]));
7962
- const DETECTED_PERSON_SCHEMA = zod.object({
7963
- box: BOUNDING_BOX_SCHEMA,
7964
- confidence: DETECTION_CONFIDENCE_SCHEMA
7965
- }).strict().meta(metaStore([docPropertyInfo({
7966
- description: "A detected person",
7967
- namedType: "DetectedPerson"
7968
- })]));
7969
- const DETECTED_FACE_SCHEMA = zod.object({
7970
- box: BOUNDING_BOX_SCHEMA,
7971
- confidence: DETECTION_CONFIDENCE_SCHEMA,
7972
- ageRange: AGE_RANGE_SCHEMA.optional()
7973
- }).strict().meta(metaStore([docPropertyInfo({
7974
- description: "A detected face",
7975
- namedType: "DetectedFace"
7976
- })]));
7977
- const NOT_EVALUATED_FEATURE_RESULT_SCHEMA = zod.object({
7978
- status: zod.literal("not-evaluated"),
7979
- stage: zod.enum(PROCESSING_FEATURE_STAGES),
7980
- reason: zod.enum(["not-supported", "unavailable"])
7981
- }).strict();
7982
- const createFeatureResultSchema = (shape) => zod.discriminatedUnion("status", [zod.object({
7983
- status: zod.literal("evaluated"),
7984
- stage: zod.enum(PROCESSING_FEATURE_STAGES),
7985
- ...shape
7986
- }).strict(), NOT_EVALUATED_FEATURE_RESULT_SCHEMA]);
7987
- const PERSON_DETECTION_RESULT_SCHEMA = createFeatureResultSchema({ people: zod.array(DETECTED_PERSON_SCHEMA) });
7988
- const FACE_DETECTION_RESULT_SCHEMA = createFeatureResultSchema({ faces: zod.array(DETECTED_FACE_SCHEMA) });
7989
- const PROCESSING_FEATURE_RESULTS_SCHEMA = zod.object({
7990
- personDetection: PERSON_DETECTION_RESULT_SCHEMA.optional(),
7991
- faceDetection: FACE_DETECTION_RESULT_SCHEMA.optional()
7992
- }).strict().meta(metaStore([docPropertyInfo({
7993
- description: "Results of the optional features requested in `processingOptions.features`, keyed by feature",
7994
- namedType: "ProcessingFeatureResults"
7995
- })]));
7996
7834
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
7997
7835
  const CHARS_GLOBAL_BACKSLASH_SUPPORTED_RX = /[\0\b\t\n\r\x1a"'\\]/g;
7998
7836
  const CHARS_ESCAPE_BACKSLASH_SUPPORTED_MAP = {
@@ -8049,166 +7887,106 @@ const PROCESSING_FEATURE_RESULTS_SCHEMA = zod.object({
8049
7887
  }
8050
7888
  module.exports = escapeString;
8051
7889
  })))();
8052
- const SIZE_LIMITS = {
8053
- small: 64,
8054
- medium: 200,
8055
- large: 1500,
8056
- xlarge: 1e4,
8057
- token: 1e4
7890
+ /**
7891
+ * The prefix of every kind of object customers can see, so an ID says what it identifies:
7892
+ * `proj_8fK2mQx7Lp4NvB3tRz9Y` is a project. Rows that never leave the backend (usage meters, upload
7893
+ * token uses, retained variants, terms acceptances, notices) keep their own internal IDs.
7894
+ */
7895
+ const ID_PREFIXES = {
7896
+ organisation: "org",
7897
+ project: "proj",
7898
+ asset: "asset",
7899
+ folder: "fldr",
7900
+ lens: "lens",
7901
+ apiKey: "key",
7902
+ uploadPreset: "preset",
7903
+ connector: "conn",
7904
+ connectorDelivery: "dlv",
7905
+ hashList: "hl",
7906
+ hashListEntry: "hle",
7907
+ policy: "pol",
7908
+ event: "evt",
7909
+ user: "user",
7910
+ member: "mem",
7911
+ invitation: "inv",
7912
+ supportTicket: "tkt",
7913
+ supportTicketMessage: "msg",
7914
+ humanReview: "rev",
7915
+ appeal: "apl",
7916
+ complianceCase: "case",
7917
+ safetyHold: "hold",
7918
+ creditPurchase: "cp",
7919
+ oauthClient: "oac",
7920
+ waitlistEntry: "wl"
8058
7921
  };
8059
- const saneStringField = (options) => {
8060
- return zod.string().transform((value) => value.trim()).refine((value) => {
8061
- if (options?.minWidth && value.length < options.minWidth) return false;
8062
- return true;
8063
- }, "Not long enough").refine((value) => {
8064
- const sizeLimit = options?.maxLength ?? SIZE_LIMITS[options?.type ?? "medium"];
8065
- return typeof value === "string" && value.length < sizeLimit;
8066
- }, "Too long");
7922
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7923
+ const ID_BODY = `[0-9A-Za-z]{20}`;
7924
+ const ID_PREFIX_VALUES = new Set(Object.values(ID_PREFIXES));
7925
+ const patterns = /* @__PURE__ */ new Map();
7926
+ /** Matches one ID with `prefix`, or any registered prefix when none is given. */
7927
+ const idPattern = (prefix) => {
7928
+ const key = prefix ?? "";
7929
+ let pattern = patterns.get(key);
7930
+ if (!pattern) {
7931
+ const prefixes = prefix ? prefix : [...ID_PREFIX_VALUES].join("|");
7932
+ pattern = new RegExp(`^(?:${prefixes})_${ID_BODY}$`);
7933
+ patterns.set(key, pattern);
7934
+ }
7935
+ return pattern;
8067
7936
  };
8068
- const CLASSIFICATION_INPUT_TYPE_SCHEMA = zod.enum(["text", "image"]);
8069
- const CLASSIFICATION_CATEGORIES = [
8070
- "sexual",
8071
- "violence",
8072
- "self-harm",
8073
- "harassment",
8074
- "hate",
8075
- "criminal-activity"
8076
- ];
8077
- const CLASSIFICATION_CATEGORY_SCHEMA = zod.enum(CLASSIFICATION_CATEGORIES);
8078
- zod.object({
8079
- category: CLASSIFICATION_CATEGORY_SCHEMA,
8080
- displayName: saneStringField(),
8081
- description: saneStringField(),
8082
- supportedInputTypes: zod.array(CLASSIFICATION_INPUT_TYPE_SCHEMA).min(1)
8083
- });
8084
- 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;
8085
- const LIKELIHOOD_SCHEMA = zod.enum([
8086
- "very-unlikely",
8087
- "unlikely",
8088
- "possible",
8089
- "likely",
8090
- "very-likely"
8091
- ]);
8092
- const CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA = zod.enum(["not-supported", "unavailable"]);
8093
- const CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA = zod.partialRecord(zod.enum(MODERATION_QUALIFIERS), zod.discriminatedUnion("status", [zod.object({
8094
- status: zod.literal("evaluated"),
8095
- /** Evidence that some of the category's qualifying content is this qualifier. */
8096
- someScore: zod.number().min(0).max(1),
8097
- /** Evidence that only this qualifier is in it: all of the qualifying content is one. */
8098
- allScore: zod.number().min(0).max(1).optional(),
8099
- likelihood: LIKELIHOOD_SCHEMA
8100
- }).strict().refine(({ someScore, allScore }) => allScore === void 0 || allScore <= someScore, {
8101
- message: "The All score cannot exceed the Some score",
8102
- path: ["allScore"]
8103
- }), zod.object({
8104
- status: zod.literal("not-evaluated"),
8105
- reason: CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA
8106
- }).strict()]));
8107
- const CLASSIFICATION_CATEGORY_REPORT_SCHEMA = zod.discriminatedUnion("status", [
8108
- zod.object({
8109
- status: zod.literal("evaluated"),
8110
- likelihood: LIKELIHOOD_SCHEMA,
8111
- score: zod.number().min(0).max(1),
8112
- qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
8113
- }).strict(),
8114
- zod.object({
8115
- status: zod.literal("partial"),
8116
- likelihood: LIKELIHOOD_SCHEMA,
8117
- score: zod.number().min(0).max(1),
8118
- qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
8119
- }).strict(),
8120
- zod.object({
8121
- status: zod.literal("not-evaluated"),
8122
- reason: CLASSIFICATION_NOT_EVALUATED_REASON_SCHEMA,
8123
- qualifiers: CLASSIFICATION_QUALIFIER_RESULTS_SCHEMA.optional()
8124
- }).strict()
8125
- ]);
8126
- const CATEGORIES_SCHEMA = zod.object({
8127
- sexual: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
8128
- violence: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
8129
- "self-harm": CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
8130
- harassment: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
8131
- hate: CLASSIFICATION_CATEGORY_REPORT_SCHEMA,
8132
- "criminal-activity": CLASSIFICATION_CATEGORY_REPORT_SCHEMA
8133
- }).strict();
8134
- /** What a moderation policy does with a category: nothing, alert, hide or purge. */
8135
- const FLAGGED_CONTENT_ACTION_SCHEMA = zod.enum([
8136
- "ignore",
8137
- "alert",
8138
- "hide",
8139
- "purge"
8140
- ]);
8141
- /** What the project's policy made of the report when the asset was decided. */
8142
- const CLASSIFICATION_REPORT_POLICY_SCHEMA = zod.object({
8143
- score: zod.number().min(0).max(1).describe("The highest category policy score."),
8144
- categories: zod.partialRecord(CLASSIFICATION_CATEGORY_SCHEMA, zod.object({
8145
- 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."),
8146
- action: FLAGGED_CONTENT_ACTION_SCHEMA.describe("What the policy does with this category. `ignore` when the policy score is below the category threshold.")
8147
- }).strict()).describe("Each assessed category's policy score and action. Unassessed categories are left out.")
8148
- }).strict();
8149
- const CLASSIFICATION_LABEL_SCHEMA = zod.object({
8150
- description: saneStringField(),
8151
- score: zod.number(),
8152
- topicalityScore: zod.number().nullish(),
8153
- likelihood: LIKELIHOOD_SCHEMA,
8154
- knowledgeBaseId: saneStringField().nullish()
8155
- });
8156
- zod.object({
8157
- processedAt: zod.number(),
8158
- categories: CATEGORIES_SCHEMA,
8159
- score: zod.number(),
8160
- notes: saneStringField().nullish(),
8161
- media: zod.object({
8162
- durationSeconds: zod.number().positive(),
8163
- frameIntervalSeconds: zod.number().positive().optional(),
8164
- sampledFrames: zod.number().int().nonnegative(),
8165
- audioTranscribed: zod.boolean(),
8166
- segments: zod.array(zod.object({
8167
- source: zod.enum([
8168
- "frame",
8169
- "speech",
8170
- "frame-text",
8171
- "clip"
8172
- ]),
8173
- startSeconds: zod.number().nonnegative(),
8174
- endSeconds: zod.number().nonnegative(),
8175
- categories: CATEGORIES_SCHEMA
8176
- }).strict())
8177
- }).strict().optional(),
8178
- labels: zod.array(CLASSIFICATION_LABEL_SCHEMA).nullish(),
8179
- features: PROCESSING_FEATURE_RESULTS_SCHEMA.optional(),
8180
- 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.")
8181
- }).strict();
8182
- //#endregion
8183
- //#region src/contract/MediaTestTriggers.js
8184
- const MEDIA_TEST_SIGNALS = [...CLASSIFICATION_CATEGORIES, "violence.graphic"];
8185
- const HEX_WORDS = [
8186
- "zero",
8187
- "one",
8188
- "two",
8189
- "three",
8190
- "four",
8191
- "five",
8192
- "six",
8193
- "seven",
8194
- "eight",
8195
- "nine",
8196
- "alpha",
8197
- "bravo",
8198
- "charlie",
8199
- "delta",
8200
- "echo",
8201
- "foxtrot"
8202
- ];
8203
- /** Read this aloud, or render it as large, high-contrast text in a video frame. */
8204
- function generateMediaTestCode(signal, testInputKey) {
8205
- if (!MEDIA_TEST_SIGNALS.includes(signal)) throw new Error("Unsupported media test signal");
8206
- const suffix = testInputKey.toLowerCase();
8207
- if (!/^[a-f0-9]{8}$/.test(suffix)) throw new Error("Expected an eight-character hexadecimal test input key");
8208
- return `Calm Lens test ${[...suffix].map((character) => HEX_WORDS[Number.parseInt(character, 16)]).join(" ")} ${signal.replace(/[.-]/g, " ")} end test`;
8209
- }
8210
- //#endregion
8211
- //#region src/contract/Permissions-B5dqc8aj.js
7937
+ /** Base62 characters from a CSPRNG, without modulo bias (bytes 248-255 are redrawn). */
7938
+ const randomBase62 = (length) => {
7939
+ let result = "";
7940
+ while (result.length < length) {
7941
+ const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
7942
+ for (const byte of bytes) {
7943
+ if (byte >= 248) continue;
7944
+ result += ALPHABET[byte % 62];
7945
+ if (result.length === length) break;
7946
+ }
7947
+ }
7948
+ return result;
7949
+ };
7950
+ /** A new random ID, such as `newId("proj")` → `proj_8fK2mQx7Lp4NvB3tRz9Y`. */
7951
+ const newId = (prefix) => `${prefix}_${randomBase62(20)}`;
7952
+ /** Whether `value` is an ID with `prefix` (or with any registered prefix). */
7953
+ const isId = (value, prefix) => typeof value === "string" && idPattern(prefix).test(value);
7954
+ /** Example IDs for docs, OpenAPI and SDK fixtures: fixed, so generated output stays stable. */
7955
+ const EXAMPLE_IDS = {
7956
+ org: "org_3hQ7tLk2Vb9XpR4mWc8N",
7957
+ proj: "proj_8fK2mQx7Lp4NvB3tRz9Y",
7958
+ asset: "asset_5Gm2Rk8XqT3wLp9VbN4c",
7959
+ fldr: "fldr_7Nc4Wq2Lm8Xt5Rk9Bv3P",
7960
+ lens: "lens_2Xr9Lp4Vm7Qk3Tb8Nw5C",
7961
+ key: "key_9Pt3Wm6Rk2Lx8Qv4Nb7C",
7962
+ preset: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
7963
+ conn: "conn_6Rb2Xk9Lm4Qt7Wp3Nv8C",
7964
+ dlv: "dlv_3Vm8Qk2Xt7Lp9Rb4Nw6C",
7965
+ hl: "hl_5Xk3Rm9Lq2Vt8Wb4Np7C",
7966
+ hle: "hle_8Qm4Xk2Rt9Lv3Wb7Np5C",
7967
+ pol: "pol_2Wk7Rm4Xq9Lt3Vb8Np6C",
7968
+ evt: "evt_7Lm3Xq8Rk2Vt9Wb4Np5C",
7969
+ user: "user_4Rk9Xm2Lq7Vt3Wb8Np6C",
7970
+ mem: "mem_9Xm4Rk7Lq2Vt8Wb3Np5C",
7971
+ inv: "inv_3Qk8Xm2Rt9Lv4Wb7Np6C",
7972
+ tkt: "tkt_6Xm2Rk9Lq4Vt7Wb3Np8C",
7973
+ msg: "msg_2Rk7Xm4Lq9Vt3Wb8Np5C",
7974
+ rev: "rev_8Xm3Rk2Lq7Vt9Wb4Np6C",
7975
+ apl: "apl_4Qk9Xm7Rt2Lv8Wb3Np5C",
7976
+ case: "case_7Xm2Rk4Lq9Vt3Wb8Np6C",
7977
+ hold: "hold_3Rk8Xm2Lq7Vt4Wb9Np5C",
7978
+ cp: "cp_9Qk4Xm7Rt3Lv8Wb2Np6C",
7979
+ oac: "oac_5Xm9Rk3Lq2Vt7Wb4Np8C",
7980
+ wl: "wl_2Qk7Xm4Rt9Lv3Wb8Np5C"
7981
+ };
7982
+ /**
7983
+ * A string field holding an ID with `prefix`: a string schema with a pattern, so OpenAPI and the
7984
+ * docs show the pattern and the example. Parsed values are typed `string`, not `Id<P>`: typing
7985
+ * every stored model's IDs by prefix is a separate change (it surfaces about a thousand call sites
7986
+ * across the packages that build records from plain strings).
7987
+ */
7988
+ const idField = (prefix) => zod.string().regex(idPattern(prefix), { message: `Must be an ID starting with "${prefix}_"` }).meta({ examples: [EXAMPLE_IDS[prefix]] });
7989
+ // src/contract/Permissions-DjKQmOVp.js
8212
7990
  const ACTION_SCHEMA = zod.enum([
8213
7991
  "assets:read",
8214
7992
  "assets:download",
@@ -8234,6 +8012,8 @@ const ACTION_SCHEMA = zod.enum([
8234
8012
  "lenses:write",
8235
8013
  "upload-presets:read",
8236
8014
  "upload-presets:write",
8015
+ "hash-lists:read",
8016
+ "hash-lists:write",
8237
8017
  "reviews:read",
8238
8018
  "reviews:write",
8239
8019
  "project:manage"
@@ -8246,6 +8026,7 @@ const PROJECT_ACTIONS = [...[
8246
8026
  "metadata",
8247
8027
  "lenses",
8248
8028
  "upload-presets",
8029
+ "hash-lists",
8249
8030
  "reviews"
8250
8031
  ].flatMap((feature) => [`${feature}:read`, `${feature}:write`]), "project:manage"];
8251
8032
  const isProjectAction = (action) => PROJECT_ACTIONS.includes(action);
@@ -8253,7 +8034,7 @@ const isProjectAction = (action) => PROJECT_ACTIONS.includes(action);
8253
8034
  const actionsImplying = (action) => !isProjectAction(action) || action === "project:manage" ? [] : action.endsWith(":read") ? [action.replace(/:read$/, ":write"), "project:manage"] : ["project:manage"];
8254
8035
  const GRANT_SCHEMA = zod.object({
8255
8036
  actions: zod.array(ACTION_SCHEMA),
8256
- folderId: zod.string().uuid().nullable(),
8037
+ folderId: idField("fldr").nullable(),
8257
8038
  includeSubfolders: zod.boolean()
8258
8039
  }).strict().refine((g) => g.folderId === null || !g.actions.some(isProjectAction), "Project configuration permissions require project scope");
8259
8040
  const GRANTS_SCHEMA = zod.array(GRANT_SCHEMA).max(100);
@@ -8276,15 +8057,15 @@ const permits = (grants, action, folderId, folders) => grants.some((grant) => {
8276
8057
  }
8277
8058
  return false;
8278
8059
  });
8279
- const SYSTEM_PERMISSION_SCHEMA = zod.enum([
8280
- "system:admin",
8281
- "system:assets:read-all",
8282
- "system:uploads:unlimited"
8283
- ]);
8284
- zod.array(SYSTEM_PERMISSION_SCHEMA);
8060
+ /**
8061
+ * What a person can let an AI assistant or the CLI do for them. `read` looks at assets, folders,
8062
+ * hash lists and events; `write` also creates, changes and deletes assets, folders and hash lists.
8063
+ * Members, API keys, connectors, sharing, downloads and project settings are never delegated.
8064
+ */
8065
+ const AGENT_SCOPE_SCHEMA = zod.enum(["read", "write"]);
8066
+ zod.array(AGENT_SCOPE_SCHEMA).min(1).max(2);
8285
8067
  zod.enum(["active", "banned"]);
8286
- //#endregion
8287
- //#region src/testing/MockApiKey.ts
8068
+ // src/testing/MockApiKey.ts
8288
8069
  const MOCK_API_KEY_PREFIX = "sk_test_";
8289
8070
  const toBase64Url = (value) => btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
8290
8071
  const fromBase64Url = (value) => {
@@ -8300,133 +8081,322 @@ const getMockApiKeyGrants = (apiKey) => {
8300
8081
  return [];
8301
8082
  }
8302
8083
  };
8303
- //#endregion
8304
- //#region src/testing/MockStorage.ts
8305
- var InMemoryMockImageStorage = class {
8306
- constructor() {
8307
- this.content = /* @__PURE__ */ new Map();
8308
- }
8309
- async putContent(assetId, content) {
8310
- const validation = require_CalmLensUtils.validateAssetPayload(content);
8311
- if (!validation.ok) throw new Error(validation.error);
8312
- const bytes = await require_CalmLensUtils.assetDataToUint8Array(require_CalmLensUtils.assetToData(content));
8313
- this.content.set(assetId, {
8314
- assetId,
8315
- parts: /* @__PURE__ */ new Map([[1, bytes]]),
8316
- type: content.type
8317
- });
8318
- }
8319
- async putPart(assetId, part) {
8320
- const validation = require_CalmLensUtils.validateAssetPayload(part, {
8321
- multipart: true,
8322
- isFinal: part.isFinal
8323
- });
8324
- if (!validation.ok) throw new Error(validation.error);
8325
- const bytes = await require_CalmLensUtils.assetDataToUint8Array(require_CalmLensUtils.assetToData(part));
8326
- const existing = this.content.get(assetId) ?? {
8327
- assetId,
8328
- parts: /* @__PURE__ */ new Map(),
8329
- type: part.type
8330
- };
8331
- existing.parts.set(part.partNumber ?? 1, bytes);
8332
- existing.type = part.type ?? existing.type;
8333
- this.content.set(assetId, existing);
8334
- }
8335
- async getContent(assetId) {
8336
- return this.content.get(assetId);
8337
- }
8338
- async deleteContent(assetId) {
8339
- this.content.delete(assetId);
8340
- }
8084
+ // src/testing/MockClassification.ts
8085
+ /** The parent categories, in the API's order. */
8086
+ const MOCK_CATEGORIES = [
8087
+ "sexual",
8088
+ "violence",
8089
+ "self-harm",
8090
+ "harassment",
8091
+ "hate",
8092
+ "criminal-activity",
8093
+ "personal-information",
8094
+ "profanity"
8095
+ ];
8096
+ /** Each category's qualifiers. */
8097
+ const MOCK_QUALIFIERS = {
8098
+ "sexual": [
8099
+ "sexual-pornographic-media",
8100
+ "sexual-nudity",
8101
+ "sexual-activity",
8102
+ "sexual-explicit-language",
8103
+ "sexual-assault-media",
8104
+ "sexual-non-consensual",
8105
+ "sexual-intimate-image-abuse",
8106
+ "sexual-extreme",
8107
+ "sexual-minors",
8108
+ "sexual-solicitation",
8109
+ "sexual-paid",
8110
+ "sexual-adult-promotion",
8111
+ "sexual-revealing-clothing",
8112
+ "sexual-suggestive",
8113
+ "sexual-non-erotic-nudity",
8114
+ "sexual-text",
8115
+ "sexual-historical",
8116
+ "sexual-documentary",
8117
+ "sexual-educational",
8118
+ "sexual-discussion",
8119
+ "sexual-fictional",
8120
+ "sexual-support-prevention"
8121
+ ],
8122
+ "violence": [
8123
+ "violence-high-impact",
8124
+ "violence-attack",
8125
+ "violence-threat",
8126
+ "violence-preparation",
8127
+ "violence-encouragement",
8128
+ "violence-wish",
8129
+ "violence-coded-incitement",
8130
+ "violence-extremism",
8131
+ "violence-injury",
8132
+ "violence-violent-death",
8133
+ "violence-natural-death",
8134
+ "violence-graphic",
8135
+ "violence-decapitation-act",
8136
+ "violence-gore",
8137
+ "violence-weapon",
8138
+ "violence-sport",
8139
+ "violence-text",
8140
+ "violence-historical",
8141
+ "violence-documentary",
8142
+ "violence-educational",
8143
+ "violence-discussion",
8144
+ "violence-fictional",
8145
+ "violence-fantasy",
8146
+ "violence-support-prevention"
8147
+ ],
8148
+ "self-harm": [
8149
+ "self-harm-age-restricted",
8150
+ "self-harm-thoughts",
8151
+ "self-harm-preparation",
8152
+ "self-harm-act",
8153
+ "self-harm-methods",
8154
+ "self-harm-encouragement",
8155
+ "self-harm-historical",
8156
+ "self-harm-documentary",
8157
+ "self-harm-educational",
8158
+ "self-harm-fictional",
8159
+ "self-harm-support-prevention"
8160
+ ],
8161
+ "harassment": [
8162
+ "harassment-abuse",
8163
+ "harassment-mild",
8164
+ "harassment-bullying",
8165
+ "harassment-public-target",
8166
+ "harassment-threat",
8167
+ "harassment-sexual",
8168
+ "harassment-encouragement",
8169
+ "harassment-historical",
8170
+ "harassment-documentary",
8171
+ "harassment-educational",
8172
+ "harassment-fictional",
8173
+ "harassment-support-prevention"
8174
+ ],
8175
+ "hate": [
8176
+ "hate-nazi-symbols-public-display",
8177
+ "hate-nazi-insignia-public-display",
8178
+ "hate-nazi-genocide-public-peace",
8179
+ "hate-nazi-genocide-denial-or-approval",
8180
+ "hate-adjudicated-nazi-crimes-denial",
8181
+ "hate-abuse",
8182
+ "hate-slurs",
8183
+ "hate-dehumanization",
8184
+ "hate-inferiority",
8185
+ "hate-exclusion",
8186
+ "hate-symbol",
8187
+ "hate-hostility",
8188
+ "hate-threat",
8189
+ "hate-personal",
8190
+ "hate-historical",
8191
+ "hate-documentary",
8192
+ "hate-educational",
8193
+ "hate-fictional",
8194
+ "hate-counterspeech",
8195
+ "hate-support-prevention"
8196
+ ],
8197
+ "criminal-activity": [
8198
+ "criminal-activity-demonstration",
8199
+ "criminal-activity-preparation",
8200
+ "criminal-activity-request",
8201
+ "criminal-activity-instructions",
8202
+ "criminal-activity-promotion",
8203
+ "criminal-activity-coded-incitement",
8204
+ "criminal-activity-substances",
8205
+ "criminal-activity-grooming",
8206
+ "criminal-activity-csam",
8207
+ "criminal-activity-alcohol",
8208
+ "criminal-activity-tobacco",
8209
+ "criminal-activity-gambling",
8210
+ "criminal-activity-fraud",
8211
+ "criminal-activity-sexual-solicitation",
8212
+ "criminal-activity-graffiti",
8213
+ "criminal-activity-historical",
8214
+ "criminal-activity-documentary",
8215
+ "criminal-activity-educational",
8216
+ "criminal-activity-discussion",
8217
+ "criminal-activity-fictional",
8218
+ "criminal-activity-support-prevention"
8219
+ ],
8220
+ "personal-information": [
8221
+ "personal-information-contact",
8222
+ "personal-information-email",
8223
+ "personal-information-phone",
8224
+ "personal-information-government-id",
8225
+ "personal-information-financial",
8226
+ "personal-information-location",
8227
+ "personal-information-identifiers",
8228
+ "personal-information-credentials",
8229
+ "personal-information-request",
8230
+ "personal-information-self-disclosure",
8231
+ "personal-information-third-party",
8232
+ "personal-information-historical",
8233
+ "personal-information-documentary",
8234
+ "personal-information-educational",
8235
+ "personal-information-fictional",
8236
+ "personal-information-support-prevention"
8237
+ ],
8238
+ "profanity": [
8239
+ "profanity-mild",
8240
+ "profanity-strong",
8241
+ "profanity-slurs",
8242
+ "profanity-obfuscated",
8243
+ "profanity-historical",
8244
+ "profanity-documentary",
8245
+ "profanity-educational",
8246
+ "profanity-fictional",
8247
+ "profanity-support-prevention"
8248
+ ]
8341
8249
  };
8342
- var InMemoryMockDatabaseStorage = class {
8343
- constructor() {
8344
- this.assets = /* @__PURE__ */ new Map();
8345
- this.folders = /* @__PURE__ */ new Map();
8346
- }
8347
- async createAsset(asset) {
8348
- this.assets.set(asset.id, asset);
8349
- return asset;
8350
- }
8351
- async updateAsset(asset) {
8352
- this.assets.set(asset.id, asset);
8353
- return asset;
8354
- }
8355
- async getAsset(assetId) {
8356
- return this.assets.get(assetId);
8357
- }
8358
- async listAssets(projectId) {
8359
- return [...this.assets.values()].filter((asset) => asset.projectId === projectId);
8360
- }
8361
- async deleteAsset(assetId) {
8362
- this.assets.delete(assetId);
8363
- }
8364
- async createFolder(folder) {
8365
- this.folders.set(folder.id, folder);
8366
- return folder;
8250
+ // src/testing/MockDecision.ts
8251
+ const OUTCOMES = [
8252
+ "pending",
8253
+ "failed",
8254
+ "allowed",
8255
+ "limited",
8256
+ "age-restricted",
8257
+ "blocked",
8258
+ "purged"
8259
+ ];
8260
+ /** The policy action that gives each decided status. */
8261
+ const STATUS_ACTION = {
8262
+ allowed: "allow",
8263
+ limited: "limit",
8264
+ "age-restricted": "age-restrict",
8265
+ blocked: "block",
8266
+ purged: "purge"
8267
+ };
8268
+ /** The likelihood the API reports for a score (ClassificationCategories.ts, scoreToLikelihood). */
8269
+ const scoreToLikelihood = (score) => score >= .8 ? "very-likely" : score >= .6 ? "likely" : score >= .4 ? "possible" : score >= .2 ? "unlikely" : "very-unlikely";
8270
+ const isScore = (score) => typeof score === "number" && Number.isFinite(score) && score >= 0 && score <= 1;
8271
+ /**
8272
+ * A moderation decision for `mockOptions.moderate` or `completeProcessing`, built with
8273
+ * `mockDecision(status)`: flag the categories it found, with their scores and qualifiers, and
8274
+ * mark the categories it couldn't assess. Each call checks its names and scores and throws
8275
+ * `sdk/invalid-argument` for one CalmLens doesn't have.
8276
+ */
8277
+ var MockDecision = class {
8278
+ #flags = /* @__PURE__ */ new Map();
8279
+ #notEvaluated = /* @__PURE__ */ new Set();
8280
+ constructor(status) {
8281
+ if (!OUTCOMES.includes(status)) throw require_ModerationContextLimits.invalidArgument(`status must be one of ${OUTCOMES.join(", ")}`);
8282
+ this.status = status;
8367
8283
  }
8368
- async updateFolder(folder) {
8369
- this.folders.set(folder.id, folder);
8370
- return folder;
8284
+ /**
8285
+ * Records findings in `category` with its `score` (0 to 1) and, optionally, scores for its
8286
+ * qualifiers, such as `{ "violence-threat": 0.9 }`. The policy acts on a category at 0.5 and
8287
+ * above, so `flaggedCategories` lists it there unless the status is `allowed`.
8288
+ */
8289
+ flag(category, score, qualifiers = {}) {
8290
+ this.#checkCategory(category);
8291
+ if (this.status === "pending" || this.status === "failed") throw require_ModerationContextLimits.invalidArgument(`a ${this.status} decision has no findings`);
8292
+ if (this.#flags.has(category) || this.#notEvaluated.has(category)) throw require_ModerationContextLimits.invalidArgument(`${category} is already in this decision`);
8293
+ if (!isScore(score)) throw require_ModerationContextLimits.invalidArgument(`${category}'s score must be from 0 to 1`);
8294
+ const known = MOCK_QUALIFIERS[category];
8295
+ for (const [qualifier, qualifierScore] of Object.entries(qualifiers)) {
8296
+ if (!known.includes(qualifier)) throw require_ModerationContextLimits.invalidArgument(`${qualifier} is not a qualifier of ${category}`);
8297
+ if (!isScore(qualifierScore)) throw require_ModerationContextLimits.invalidArgument(`${qualifier}'s score must be from 0 to 1`);
8298
+ }
8299
+ this.#flags.set(category, {
8300
+ score,
8301
+ qualifiers: Object.entries(qualifiers)
8302
+ });
8303
+ return this;
8371
8304
  }
8372
- async getFolder(folderId) {
8373
- return this.folders.get(folderId);
8305
+ /** Marks `category` as not assessed (`not-evaluated` for reason `not-supported`). */
8306
+ notEvaluated(category) {
8307
+ this.#checkCategory(category);
8308
+ if (this.status === "pending" || this.status === "failed") throw require_ModerationContextLimits.invalidArgument(`a ${this.status} decision has no findings`);
8309
+ if (this.#flags.has(category) || this.#notEvaluated.has(category)) throw require_ModerationContextLimits.invalidArgument(`${category} is already in this decision`);
8310
+ this.#notEvaluated.add(category);
8311
+ return this;
8374
8312
  }
8375
- async listFolders(projectId) {
8376
- return [...this.folders.values()].filter((folder) => folder.projectId === projectId).sort((a, b) => a.name.localeCompare(b.name));
8313
+ #checkCategory(category) {
8314
+ if (!MOCK_CATEGORIES.includes(category)) throw require_ModerationContextLimits.invalidArgument(`${category} is not a CalmLens category`);
8377
8315
  }
8378
- async deleteFolder(folderId) {
8379
- this.folders.delete(folderId);
8316
+ /**
8317
+ * The report a decided asset gets, as the API sends it: every category (unflagged ones
8318
+ * evaluated with score 0), policy-adjusted results and the automated action and
8319
+ * `flaggedCategories`. `undefined` for `pending` and `failed`.
8320
+ */
8321
+ report() {
8322
+ const status = this.status;
8323
+ if (status === "pending" || status === "failed") return void 0;
8324
+ const action = (score) => status !== "allowed" && score >= .5 ? STATUS_ACTION[status] : "allow";
8325
+ const evaluated = MOCK_CATEGORIES.filter((category) => !this.#notEvaluated.has(category)).map((category) => [category, this.#flags.get(category)?.score ?? 0]);
8326
+ const categories = Object.fromEntries(MOCK_CATEGORIES.map((category) => {
8327
+ if (this.#notEvaluated.has(category)) return [category, {
8328
+ status: "not-evaluated",
8329
+ reason: "not-supported"
8330
+ }];
8331
+ const flag = this.#flags.get(category);
8332
+ const score = flag?.score ?? 0;
8333
+ return [category, {
8334
+ status: "evaluated",
8335
+ likelihood: scoreToLikelihood(score),
8336
+ score,
8337
+ ...flag?.qualifiers.length && { qualifiers: Object.fromEntries(flag.qualifiers.map(([qualifier, someScore]) => [qualifier, {
8338
+ status: "evaluated",
8339
+ someScore,
8340
+ likelihood: scoreToLikelihood(someScore)
8341
+ }])) }
8342
+ }];
8343
+ }));
8344
+ return {
8345
+ categories: Object.fromEntries(evaluated.map(([category, score]) => [category, {
8346
+ score,
8347
+ action: action(score)
8348
+ }])),
8349
+ explanationComplete: true,
8350
+ source: "policy",
8351
+ automatedAction: STATUS_ACTION[status],
8352
+ regions: [],
8353
+ flaggedCategories: evaluated.filter(([, score]) => action(score) !== "allow").map(([category]) => category),
8354
+ raw: { categories }
8355
+ };
8380
8356
  }
8381
8357
  };
8382
- //#endregion
8383
- //#region src/testing/MockUtils.ts
8384
- const inferAssetType = (options) => {
8385
- if ("url" in options) return "website";
8386
- const type = "content" in options ? options.content.type : options.part.type;
8387
- if (type.startsWith("image/") || [
8388
- "jpg",
8389
- "jpeg",
8390
- "png",
8391
- "gif",
8392
- "webp",
8393
- "avif"
8394
- ].includes(type)) return "image";
8395
- if (type.startsWith("video/")) return "video";
8396
- if (type.startsWith("audio/")) return "audio";
8397
- if (type === "text/plain" || type === "txt" || type.startsWith("text/")) return "plaintext-document";
8398
- if (type.includes("pdf") || type.includes("document") || [
8399
- "pdf",
8400
- "doc",
8401
- "docx"
8402
- ].includes(type)) return "multimedia-document";
8403
- return "file";
8404
- };
8405
- const createPage = (items, query) => {
8406
- const pageSize = query.pageSize ?? 10;
8407
- if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) throw require_CalmLensVersion.invalidArgument("Invalid page size");
8408
- const ordering = query.ordering?.length ? [...query.ordering] : [{
8409
- field: "createdAt",
8410
- direction: "DESC"
8411
- }];
8412
- if (ordering.some(({ field }) => field !== "createdAt" && field !== "id")) throw require_CalmLensVersion.invalidArgument("Invalid ordering");
8358
+ /** Starts a moderation decision with this status; see `MockDecision`. */
8359
+ const mockDecision = (status) => new MockDecision(status);
8360
+ /** The default `moderate`: queued work stays pending until `completeProcessing`, which allows it. */
8361
+ const defaultModerate = ({ trigger, queued }) => queued && trigger !== "complete" ? "pending" : "allowed";
8362
+ // src/testing/MockUtils.ts
8363
+ const toBase64Utf8 = (value) => btoa(Array.from(new TextEncoder().encode(value), (byte) => String.fromCharCode(byte)).join(""));
8364
+ const fromBase64Utf8 = (value) => new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0)));
8365
+ /**
8366
+ * One keyset page like the API's (packages/shared-backend/src/CursorPagination.ts): ordered by
8367
+ * `ordering` (newest first by default) with an `id` tie-breaker added, starting after the
8368
+ * cursor's values. The cursor is tied to `scope` (the filter, folder or parent) and the
8369
+ * ordering, so it only continues the query it came from. `name` and `id` are compared by code
8370
+ * point, like the database.
8371
+ */
8372
+ const createPage = (items, query, { scope, fail, defaultPageSize = 50, defaultOrdering = [{
8373
+ field: "createdAt",
8374
+ direction: "desc"
8375
+ }] }) => {
8376
+ const pageSize = query.pageSize ?? defaultPageSize;
8377
+ if (typeof pageSize !== "number" || !Number.isFinite(pageSize) || pageSize < 1) throw fail("validation/too-small", "pageSize");
8378
+ if (pageSize > 100) throw fail("validation/too-big", "pageSize");
8379
+ if (!Number.isInteger(pageSize)) throw require_ModerationContextLimits.invalidArgument("Invalid page size");
8380
+ const ordering = query.ordering?.length ? [...query.ordering] : [...defaultOrdering];
8413
8381
  if (!ordering.some(({ field }) => field === "id")) ordering.push({
8414
8382
  field: "id",
8415
8383
  direction: ordering[0].direction
8416
8384
  });
8385
+ const signature = JSON.stringify([scope, ordering]);
8417
8386
  let remaining = orderItems(items, ordering);
8418
8387
  if (query.cursor) {
8419
8388
  let values;
8420
8389
  try {
8421
- values = JSON.parse(atob(query.cursor));
8422
- 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();
8390
+ const parsed = JSON.parse(fromBase64Utf8(query.cursor));
8391
+ values = parsed?.values;
8392
+ 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();
8423
8393
  } catch {
8424
- throw require_CalmLensVersion.invalidArgument("Invalid cursor");
8394
+ throw fail("validation/invalid-cursor");
8425
8395
  }
8426
8396
  remaining = remaining.filter((item) => {
8427
8397
  for (const [i, rule] of ordering.entries()) {
8428
8398
  const result = compareValues(item[rule.field], values[i]);
8429
- if (result) return rule.direction === "DESC" ? result < 0 : result > 0;
8399
+ if (result) return rule.direction === "desc" ? result < 0 : result > 0;
8430
8400
  }
8431
8401
  return false;
8432
8402
  });
@@ -8439,26 +8409,31 @@ const createPage = (items, query) => {
8439
8409
  pageSize,
8440
8410
  hasMore,
8441
8411
  ordering,
8442
- nextCursor: hasMore && last ? btoa(JSON.stringify(ordering.map(({ field }) => last[field]))) : null
8412
+ nextCursor: hasMore && last ? toBase64Utf8(JSON.stringify({
8413
+ scope: signature,
8414
+ values: ordering.map(({ field }) => last[field])
8415
+ })) : null
8443
8416
  };
8444
8417
  };
8418
+ const STRING_FIELDS = /* @__PURE__ */ new Set(["id", "name"]);
8445
8419
  const compareValues = (left, right) => {
8446
8420
  if (left === right) return 0;
8447
8421
  if (left === void 0 || left === null) return -1;
8448
8422
  if (right === void 0 || right === null) return 1;
8449
8423
  if (typeof left === "number" && typeof right === "number") return left - right;
8450
- return String(left).localeCompare(String(right));
8424
+ const [a, b] = [String(left), String(right)];
8425
+ return a < b ? -1 : a > b ? 1 : 0;
8451
8426
  };
8452
8427
  /** Sorts like the API's `ordering`; without one, newest first. */
8453
8428
  const orderItems = (items, ordering) => {
8454
8429
  const rules = ordering?.length ? ordering : [{
8455
8430
  field: "createdAt",
8456
- direction: "DESC"
8431
+ direction: "desc"
8457
8432
  }];
8458
8433
  return [...items].sort((a, b) => {
8459
8434
  for (const rule of rules) {
8460
8435
  const result = compareValues(a[rule.field], b[rule.field]);
8461
- if (result !== 0) return rule.direction === "DESC" ? -result : result;
8436
+ if (result !== 0) return rule.direction === "desc" ? -result : result;
8462
8437
  }
8463
8438
  return 0;
8464
8439
  });
@@ -8474,7 +8449,7 @@ const parseFilterValue = (raw) => {
8474
8449
  return value;
8475
8450
  };
8476
8451
  const readField = (item, path) => path.split(".").reduce((value, key) => value && typeof value === "object" ? value[key] : void 0, item);
8477
- const unsupportedFilter = (expression) => require_CalmLensVersion.invalidArgument(`MockCalmLensServerClient cannot evaluate the filter "${expression}". It supports ==, !=, <, <=, >, >=, =in= and =out=, joined by ; (and) or , (or), without grouping parentheses.`);
8452
+ const unsupportedFilter = (expression) => require_ModerationContextLimits.invalidArgument(`MockCalmLensServerClient cannot evaluate the filter "${expression}". It supports ==, !=, <, <=, >, >=, =in= and =out=, joined by ; (and) or , (or) and grouped with parentheses.`);
8478
8453
  const matchesComparison = (item, comparison) => {
8479
8454
  const match = COMPARISON.exec(comparison.trim());
8480
8455
  if (!match) throw unsupportedFilter(comparison);
@@ -8498,7 +8473,7 @@ const matchesComparison = (item, comparison) => {
8498
8473
  default: return compareValues(actual, values[0]) <= 0;
8499
8474
  }
8500
8475
  };
8501
- /** Splits on `separator` outside quotes and parentheses; grouping parentheses are refused. */
8476
+ /** Splits on `separator` outside quotes and parentheses. */
8502
8477
  const splitTopLevel = (text, separator) => {
8503
8478
  const parts = [];
8504
8479
  let depth = 0;
@@ -8520,778 +8495,1993 @@ const splitTopLevel = (text, separator) => {
8520
8495
  parts.push(current);
8521
8496
  return parts;
8522
8497
  };
8498
+ /** The expression inside `( … )` when the parentheses enclose all of `text`, else undefined. */
8499
+ const unwrapGroup = (text) => {
8500
+ if (!text.startsWith("(") || !text.endsWith(")")) return void 0;
8501
+ let depth = 0;
8502
+ let quote = null;
8503
+ for (const [index, char] of [...text].entries()) if (quote) {
8504
+ if (char === quote) quote = null;
8505
+ } else if (char === "\"" || char === "'") quote = char;
8506
+ else if (char === "(") depth++;
8507
+ else if (char === ")" && --depth === 0) return index === text.length - 1 ? text.slice(1, -1) : void 0;
8508
+ };
8509
+ /** Evaluates `,` (or) of `;` (and) terms, where a term is a comparison or a `( … )` group. */
8510
+ const matchesExpression = (item, expression) => splitTopLevel(expression, ",").some((alternative) => splitTopLevel(alternative, ";").every((term) => {
8511
+ const trimmed = term.trim();
8512
+ if (!trimmed) throw unsupportedFilter(expression);
8513
+ const group = unwrapGroup(trimmed);
8514
+ return group === void 0 ? matchesComparison(item, trimmed) : matchesExpression(item, group);
8515
+ }));
8523
8516
  /**
8524
- * Applies an RSQL `filter` like the API for simple expressions: comparisons joined by `;`
8525
- * (and) and `,` (or). Anything it can't evaluate throws instead of returning wrong results.
8517
+ * Applies an RSQL `filter` like the API: comparisons joined by `;` (and), which binds tighter
8518
+ * than `,` (or), and grouped with parentheses. Anything it can't evaluate throws instead of
8519
+ * returning wrong results.
8526
8520
  */
8527
8521
  const filterAssets = (items, filter) => {
8528
8522
  if (!filter) return items;
8529
- const alternatives = splitTopLevel(filter, ",").map((group) => splitTopLevel(group, ";"));
8530
- for (const condition of alternatives.flat()) if (condition.trim().startsWith("(")) throw unsupportedFilter(condition);
8531
- return items.filter((item) => alternatives.some((conditions) => conditions.every((condition) => matchesComparison(item, condition))));
8523
+ if (items.length === 0) matchesExpression({}, filter);
8524
+ return items.filter((item) => matchesExpression(item, filter));
8532
8525
  };
8533
- //#endregion
8534
- //#region src/testing/MockCalmLensServerClient.ts
8535
- const DEFAULT_EVENTS_LIMIT = 50;
8536
- /** Stands in for binary payloads when comparing idempotent requests, like the API's size check. */
8537
- const describeBinary = (value) => {
8538
- if (value instanceof Blob) return `[blob:${value.size}]`;
8539
- if (value instanceof ArrayBuffer) return `[bytes:${value.byteLength}]`;
8540
- if (ArrayBuffer.isView(value)) return `[bytes:${value.byteLength}]`;
8541
- return value;
8526
+ const MAX_SEARCH_TOKENS = 8;
8527
+ /** The keyword fields the API searches, in the order it reports matches. */
8528
+ const searchFieldValues = (asset) => [
8529
+ ["name", asset.name],
8530
+ ["id", asset.id],
8531
+ ["externalId", asset.externalId ?? ""],
8532
+ ["description", asset.description ?? ""],
8533
+ ["tags", asset.tags?.join(" ") ?? ""],
8534
+ ["type", asset.type],
8535
+ ["content.format", asset.content?.format ?? ""],
8536
+ ["content.extension", asset.content?.extension ?? ""],
8537
+ ["classification.moderation.status", asset.classification.moderation.status]
8538
+ ];
8539
+ /** The API's keyword score for the whole lowercased query. */
8540
+ const keywordScore = (values, query) => {
8541
+ const id = values.get("id");
8542
+ const externalId = values.get("externalId");
8543
+ const name = values.get("name");
8544
+ if (id === query) return 1e3;
8545
+ if (externalId === query) return 950;
8546
+ if (name === query) return 900;
8547
+ if (id.startsWith(query)) return 800;
8548
+ if (externalId.startsWith(query)) return 750;
8549
+ if (name.startsWith(query)) return 700;
8550
+ if (name.includes(query)) return 600;
8551
+ if (values.get("tags").includes(query)) return 500;
8552
+ return 100;
8542
8553
  };
8543
- const MAX_EVENTS_LIMIT = 100;
8544
- const toFailureError = (failure) => {
8545
- if (failure === "transport-error") return new require_CalmLensVersion.CalmLensError(require_CalmLensVersion.SDK_ERROR_CODES.TRANSPORT_ERROR, {
8546
- message: "Request failed",
8547
- cause: /* @__PURE__ */ new TypeError("Simulated network failure")
8548
- });
8549
- if (failure === "timeout") {
8550
- const cause = /* @__PURE__ */ new Error("The operation timed out.");
8551
- cause.name = "TimeoutError";
8552
- return new require_CalmLensVersion.CalmLensError(require_CalmLensVersion.SDK_ERROR_CODES.TIMEOUT, {
8553
- message: "Request timed out",
8554
- cause
8555
- });
8554
+ /**
8555
+ * Keyword search as the API runs it: every whitespace-separated token (case-insensitive, at
8556
+ * most 8) must appear in some searchable field. Results are ranked by the API's score, then
8557
+ * newest first.
8558
+ */
8559
+ const searchAssets = (assets, query, limit) => {
8560
+ const normalized = query.trim().toLowerCase();
8561
+ const tokens = [...new Set(normalized.split(/\s+/).filter(Boolean))].slice(0, MAX_SEARCH_TOKENS);
8562
+ if (tokens.length === 0) return [];
8563
+ return assets.map((asset) => {
8564
+ const fields = searchFieldValues(asset).map(([field, value]) => [field, value.toLowerCase()]);
8565
+ return {
8566
+ asset,
8567
+ matches: tokens.every((token) => fields.some(([, value]) => value.includes(token))),
8568
+ score: keywordScore(new Map(fields), normalized),
8569
+ matchedFields: fields.filter(([, value]) => tokens.some((token) => value.includes(token))).map(([field]) => field)
8570
+ };
8571
+ }).filter((result) => result.matches).sort((a, b) => b.score - a.score || b.asset.createdAt - a.asset.createdAt).slice(0, limit).map(({ asset, score, matchedFields }) => ({
8572
+ asset,
8573
+ score,
8574
+ matchedFields
8575
+ }));
8576
+ };
8577
+ // src/testing/MockCalmLensApi.ts
8578
+ const REVIEW_DECISIONS = [
8579
+ "allowed",
8580
+ "limited",
8581
+ "age-restricted",
8582
+ "blocked",
8583
+ "purged"
8584
+ ];
8585
+ const MAX_PAGE_SIZE = 100;
8586
+ const MAX_DECISION_NOTE_LENGTH = 1499;
8587
+ /** How the API names a reviewer acting through an unnamed API key. */
8588
+ const API_KEY_REVIEWER_NAME = "An API key";
8589
+ const MAX_SEARCH_LIMIT = 20;
8590
+ const MAX_SEARCH_QUERY_LENGTH = 200;
8591
+ /**
8592
+ * The ThumbHash the mock gives every classified image, standing in for one computed from its
8593
+ * pixels. The `placeholder` resource serves it as a PNG, like the CDN.
8594
+ */
8595
+ const MOCK_THUMBHASH = "1QcSHQRnh493V4dIh4eXh1h4kJUI";
8596
+ /** Like the API: 1 to 255 printable ASCII characters, once HTTP has trimmed the header value. */
8597
+ const isValidIdempotencyKey = (key) => key.length > 0 && key.length <= 255 && /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(key);
8598
+ /** Like the CDN, content a browser could run as a document is served as a download. */
8599
+ const isActiveContentType = (type) => {
8600
+ const normalized = type?.toLowerCase().split(";", 1)[0]?.trim();
8601
+ return Boolean(normalized && ([
8602
+ "image/svg+xml",
8603
+ "application/xhtml+xml",
8604
+ "text/html",
8605
+ "application/xml",
8606
+ "text/xml"
8607
+ ].includes(normalized) || normalized.endsWith("+xml")));
8608
+ };
8609
+ /** The asset snapshot events carry (getEventAsset in the API). */
8610
+ const toEventAsset = (asset) => ({
8611
+ id: asset.id,
8612
+ externalId: asset.externalId ?? null,
8613
+ uploadPresetId: asset.uploadPresetId ?? null,
8614
+ upload: { status: asset.upload.status },
8615
+ classification: {
8616
+ status: asset.classification.status,
8617
+ moderation: { status: asset.classification.moderation.status }
8618
+ },
8619
+ content: { status: asset.content.status }
8620
+ });
8621
+ /** A `moderate` answer or `completeProcessing` decision as a checked `MockDecision`. */
8622
+ const toDecision = (value) => {
8623
+ if (value instanceof MockDecision) return value;
8624
+ if (typeof value === "string") return new MockDecision(value);
8625
+ throw require_ModerationContextLimits.invalidArgument("a decision must be a status or a mockDecision(...)");
8626
+ };
8627
+ /** An asset's portal link, as event facts carry it. */
8628
+ const assetNameFact = (asset) => ({
8629
+ key: "asset-name",
8630
+ value: asset.name,
8631
+ url: `https://calmlens.com/go/asset/${asset.id}`
8632
+ });
8633
+ /** Removing content removes the placeholder and average colour with it. */
8634
+ const withoutPlaceholder = (image) => image ? {
8635
+ ...image,
8636
+ thumbhash: null,
8637
+ averageColor: null
8638
+ } : image;
8639
+ /** An asset's type from its content type, or `website` for a URL. */
8640
+ const inferAssetType = (contentType) => {
8641
+ if (contentType === void 0) return "website";
8642
+ const type = contentType.toLowerCase();
8643
+ if (type.startsWith("image/")) return "image";
8644
+ if (type.startsWith("video/")) return "video";
8645
+ if (type.startsWith("audio/")) return "audio";
8646
+ if (type.startsWith("text/")) return "plaintext-document";
8647
+ if (type.includes("pdf") || type.includes("document")) return "multimedia-document";
8648
+ return "file";
8649
+ };
8650
+ /** The API's answer to a request it refuses: thrown by handlers, sent as a JSON error body. */
8651
+ var ApiFailure = class {
8652
+ constructor(status, code, message, details) {
8653
+ this.status = status;
8654
+ this.code = code;
8655
+ this.message = message;
8656
+ this.details = details;
8657
+ }
8658
+ };
8659
+ const fail = (code, { message, details } = {}) => new ApiFailure(require_ModerationContextLimits.API_ERROR_DEFINITIONS[code].status, code, message ?? require_ModerationContextLimits.API_ERROR_DEFINITIONS[code].message, details);
8660
+ /** A request the API's schema refuses: `validation/<zod issue>` with HTTP 400 and the field. */
8661
+ const invalidField = (code, path) => new ApiFailure(400, code, `Invalid ${path}`, { path });
8662
+ /** The client methods that send a request, the one it is recorded under first. */
8663
+ const operationsOf = (request) => {
8664
+ const { method, segments, query, cdn } = request;
8665
+ if (cdn) return ["cdn.serve"];
8666
+ const [root] = segments;
8667
+ if (root === "projects") {
8668
+ switch (segments.slice(2).join("/")) {
8669
+ case "events": return ["events.list", "events.pages"];
8670
+ case "lenses": return ["ensureLenses", "ensureLens"];
8671
+ case "folders": return method === "GET" ? ["folders.list"] : ["folders.create"];
8672
+ case "hash-lists": return method === "GET" ? ["hashLists.list"] : ["hashLists.create"];
8673
+ case "assets/search": return ["assets.search"];
8674
+ case "assets/batch": return ["assets.batch"];
8675
+ case "assets":
8676
+ if (method === "GET") return ["assets.list", "assets.iterate"];
8677
+ if (query.get("queue") === "true") return ["assets.create"];
8678
+ return isTextUpload(request) ? ["assets.classify", "text.classify"] : ["assets.classify"];
8679
+ }
8680
+ return [];
8556
8681
  }
8557
- return failure;
8682
+ if (root === "upload-presets") {
8683
+ if (segments[3] === void 0) return [query.get("queue") === "true" ? "assets.create" : "assets.classify"];
8684
+ return segments[4] === "part" ? ["assets.uploadPart"] : ["assets.retrieve"];
8685
+ }
8686
+ const id = segments[1];
8687
+ const action = segments[2];
8688
+ if (root === "folders" && id !== void 0) {
8689
+ if (action === "move") return ["folders.move"];
8690
+ return method === "GET" ? ["folders.retrieve"] : method === "PATCH" ? ["folders.update"] : ["folders.delete"];
8691
+ }
8692
+ if (root === "hash-lists" && id !== void 0) {
8693
+ if (action === "entries") return method === "GET" ? ["hashLists.listEntries"] : ["hashLists.addEntry"];
8694
+ return method === "GET" ? ["hashLists.retrieve"] : method === "PATCH" ? ["hashLists.update"] : ["hashLists.delete"];
8695
+ }
8696
+ if (root === "hash-list-entries" && id !== void 0) return ["hashLists.removeEntry"];
8697
+ if (root !== "assets" || id === void 0) return [];
8698
+ switch (action) {
8699
+ case void 0: return method === "GET" ? ["assets.retrieve", "assets.waitForDecision"] : method === "PATCH" ? ["assets.update"] : ["assets.delete"];
8700
+ case "content": return ["assets.deleteContent"];
8701
+ case "children": return ["assets.listChildren", "assets.iterateChildren"];
8702
+ case "signed-url": return ["assets.createSignedUrl"];
8703
+ case "part": return ["assets.uploadPart"];
8704
+ case "move": return ["assets.move"];
8705
+ case "retry": return ["assets.retry"];
8706
+ case "explain": return ["assets.explain"];
8707
+ case "decision": return ["assets.decide"];
8708
+ }
8709
+ return [];
8710
+ };
8711
+ const isTextUpload = (request) => request.headers.get("x-calmlens-upload") === "content" && request.headers.get("content-type") === "text/plain";
8712
+ /** Like the API: a new folder is 201 Created, a queued batch 202 Accepted, everything else 200. */
8713
+ const SUCCESS_STATUS = {
8714
+ "folders.create": 201,
8715
+ "hashLists.create": 201,
8716
+ "assets.batch": 202
8717
+ };
8718
+ const decodeSegment = (segment) => {
8719
+ try {
8720
+ return decodeURIComponent(segment);
8721
+ } catch {
8722
+ return segment;
8723
+ }
8724
+ };
8725
+ const createMonotonicClock = () => {
8726
+ let last = 0;
8727
+ return () => {
8728
+ last = Math.max(Date.now(), last + 1);
8729
+ return last;
8730
+ };
8558
8731
  };
8559
8732
  /**
8560
- * An in-memory stand-in for `CalmLensServerClient` with the same methods, validation and error
8561
- * codes. API errors carry the HTTP status, a `requestId` and response headers like the real
8562
- * client's, so error handling can be tested without a network.
8733
+ * The in-memory CalmLens API and CDN one or more mock clients share: storage, events, lenses,
8734
+ * idempotency keys, upload presets, the API key's grants, queued failures and recorded requests.
8563
8735
  */
8564
- var MockCalmLensServerClient = class {
8565
- /** Accepts the same options as `CalmLensServerClient` (validated the same way), plus `mockOptions`. */
8736
+ var MockCalmLensApi = class {
8566
8737
  constructor(options) {
8738
+ this.calls = [];
8567
8739
  this.lenses = /* @__PURE__ */ new Map();
8568
8740
  this.eventFeed = [];
8569
- this.uploadQueues = /* @__PURE__ */ new Map();
8741
+ this.openUploads = /* @__PURE__ */ new Map();
8570
8742
  this.idempotentResults = /* @__PURE__ */ new Map();
8571
- this.requestCount = 0;
8572
8743
  this.pendingFailures = [];
8573
- this.calls = [];
8574
- require_CalmLensUtils.validateServerClientOptions(options);
8744
+ this.requestCount = 0;
8745
+ this.waitedMs = 0;
8746
+ this.hashLists = /* @__PURE__ */ new Map();
8747
+ this.hashListEntries = /* @__PURE__ */ new Map();
8748
+ this.pageFailure = (code, path) => code === "validation/invalid-cursor" ? fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_CURSOR) : invalidField(code, path ?? "pageSize");
8749
+ this.options = options;
8575
8750
  this.projectId = options.projectId;
8576
8751
  this.apiKey = options.apiKey;
8577
- this.databaseStorage = options.mockOptions?.databaseStorage ?? new InMemoryMockDatabaseStorage();
8578
- this.imageStorage = options.mockOptions?.imageStorage ?? new InMemoryMockImageStorage();
8579
- this.createId = options.mockOptions?.createId ?? crypto.randomUUID.bind(crypto);
8580
- this.now = options.mockOptions?.now ?? Date.now;
8581
- this.queuedOutcome = options.mockOptions?.queuedOutcome ?? "pending";
8582
- this.analyzedOutcome = options.mockOptions?.analyzedOutcome ?? "approved";
8583
- this.apiKeyGrants = getMockApiKeyGrants(options.apiKey);
8584
- this.signedUrlBaseUrl = options.mockOptions?.signedUrlBaseUrl ?? "mock://calmlens";
8585
- this.cdnBaseUrl = (options.cdnBaseUrl ?? "mock://calmlens/v1").replace(/\/+$/, "");
8586
- }
8587
- /** Keeps the API key out of `console.log` and `util.inspect`. */
8588
- [Symbol.for("nodejs.util.inspect.custom")]() {
8589
- return `MockCalmLensServerClient { projectId: ${JSON.stringify(this.projectId)} }`;
8590
- }
8591
- /**
8592
- * Makes the next call (or the next call to `operation`) throw `failure` instead of running,
8593
- * as if the request never completed. Queue several to fail several calls in a row.
8594
- */
8595
- failNextRequest(failure = "transport-error", { operation } = {}) {
8752
+ this.grants = options.grants;
8753
+ this.databaseStorage = options.databaseStorage;
8754
+ this.imageStorage = options.imageStorage;
8755
+ this.createId = options.createId;
8756
+ this.now = options.now;
8757
+ }
8758
+ /** Queues a failure for the next request, or the next one `operation` sends. */
8759
+ failNextRequest(failure, options = {}) {
8760
+ if (options.retryAfter !== void 0 && !(Number.isInteger(options.retryAfter) && options.retryAfter >= 0)) throw require_ModerationContextLimits.invalidArgument("retryAfter must be a whole number of seconds");
8596
8761
  this.pendingFailures.push({
8597
8762
  failure,
8598
- operation
8763
+ ...options
8599
8764
  });
8600
8765
  }
8601
- /** Calls recorded for one operation, oldest first. */
8602
- callsTo(operation) {
8603
- return this.calls.filter((call) => call.operation === operation);
8766
+ /** A `fetch` that answers a client's requests from memory; app clients pass their session. */
8767
+ createFetch(endpoints, session) {
8768
+ return ((input, init) => this.handle(input, init, endpoints(), session));
8604
8769
  }
8605
8770
  /**
8606
- * Adds an event to the feed `events.list` reads, as if CalmLens had recorded it. Returns the
8607
- * stored event; pass it to `toWebhookEventPayload` to build a matching webhook body.
8771
+ * Adds an event to the feed `events.list` reads, as if CalmLens had recorded it, and returns
8772
+ * the stored event.
8608
8773
  */
8609
- enqueueEvent(input) {
8774
+ async enqueueEvent(input) {
8775
+ const asset = input.asset === void 0 && input.assetId ? await this.databaseStorage.getAsset(input.assetId) : void 0;
8776
+ return this.pushEvent(asset ? {
8777
+ ...input,
8778
+ asset: toEventAsset(asset)
8779
+ } : input);
8780
+ }
8781
+ /** Adds an event to the feed, filling the fields the API always sets. */
8782
+ pushEvent(input) {
8610
8783
  const now = this.now();
8611
8784
  const event = {
8612
- id: this.createId(),
8785
+ id: this.createId("evt"),
8613
8786
  projectId: this.projectId,
8614
8787
  severity: "low",
8615
8788
  title: input.type,
8789
+ description: null,
8790
+ assetId: null,
8791
+ asset: null,
8616
8792
  facts: [],
8617
8793
  createdAt: now,
8618
- updatedAt: now,
8619
8794
  ...input
8620
8795
  };
8621
8796
  this.eventFeed.push(event);
8622
8797
  return event;
8623
8798
  }
8624
- /** An API error as the real client throws it: status, default message and request ID. */
8625
- apiError(code, options = {}) {
8626
- const definition = require_CalmLensVersion.API_ERROR_DEFINITIONS[code];
8627
- const requestId = `mock-request-${++this.requestCount}`;
8628
- return new require_CalmLensVersion.CalmLensError(code, {
8629
- message: options.message ?? definition.message,
8630
- status: definition.status,
8631
- details: options.details,
8632
- requestId,
8633
- headers: new Headers({
8634
- "content-type": "application/json",
8635
- "x-request-id": requestId
8636
- })
8637
- });
8799
+ /** Applies a reviewer's decision to a stored asset (see `MockCalmLensServerClient.decide`). */
8800
+ async decide(assetId, decision) {
8801
+ if (!REVIEW_DECISIONS.includes(decision)) throw require_ModerationContextLimits.invalidArgument(`decision must be one of ${REVIEW_DECISIONS.join(", ")}`);
8802
+ let asset;
8803
+ try {
8804
+ asset = await this.findOwnAsset(assetId);
8805
+ } catch (failure) {
8806
+ throw this.toError(failure);
8807
+ }
8808
+ return structuredClone(await this.recordDecision(asset, decision));
8638
8809
  }
8639
- assertValidUploadMimeType(mimeType) {
8640
- if (require_CalmLensUtils.isRejectedUploadMimeType(mimeType)) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
8641
- message: require_CalmLensUtils.getRejectedUploadMimeTypeError(mimeType),
8642
- details: { mimeType }
8643
- });
8810
+ /**
8811
+ * Finishes processing a pending asset as the pipeline would (see
8812
+ * `MockCalmLensServerClient.completeProcessing`): with `decision`, or else what `moderate`
8813
+ * returns for the `complete` trigger.
8814
+ */
8815
+ async completeProcessing(assetId, decision) {
8816
+ let asset;
8817
+ try {
8818
+ asset = await this.findOwnAsset(assetId);
8819
+ } catch (failure) {
8820
+ throw this.toError(failure);
8821
+ }
8822
+ if (asset.classification.status !== "processing") throw require_ModerationContextLimits.invalidArgument(`asset ${assetId} isn't processing`);
8823
+ const settled = decision === void 0 ? void 0 : toDecision(decision);
8824
+ return structuredClone(await this.moderateStored(asset, "complete", settled));
8644
8825
  }
8645
- invalidPayload(message) {
8646
- return this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message });
8826
+ /**
8827
+ * `POST /assets/:assetId/decision`, like the API: an asset still uploading or processing can't
8828
+ * be decided, one pending moderation can only be blocked or purged, a decision must change the
8829
+ * status and purged content can only be blocked. Mock API keys have no name, so the reviewer is
8830
+ * "An API key".
8831
+ */
8832
+ async decideAsset(assetId, body) {
8833
+ const { decision, note: rawNote } = body;
8834
+ if (decision === "purged") {
8835
+ if (body.confirmPurge !== true) throw invalidField("validation/invalid-value", "confirmPurge");
8836
+ } else if (!REVIEW_DECISIONS.includes(decision)) throw invalidField("validation/invalid-value", "decision");
8837
+ if (typeof rawNote !== "string") throw invalidField("validation/invalid-type", "note");
8838
+ const note = rawNote.trim();
8839
+ if (!note) throw invalidField("validation/too-small", "note");
8840
+ if (note.length > MAX_DECISION_NOTE_LENGTH) throw invalidField("validation/too-big", "note");
8841
+ const asset = await this.findOwnAsset(assetId);
8842
+ if (asset.upload.status === "uploading" || asset.classification.status === "processing") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_PROCESSING);
8843
+ const status = asset.classification.moderation.status;
8844
+ if (decision !== "purged" && decision !== "blocked" && status === "pending") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_DECISION_NOT_ALLOWED);
8845
+ if (status === decision) throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_DECISION_UNCHANGED);
8846
+ if (decision !== "purged" && decision !== "blocked" && status === "purged") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_CONTENT_PURGED);
8847
+ return this.recordDecision(asset, decision, {
8848
+ note,
8849
+ reviewerName: API_KEY_REVIEWER_NAME
8850
+ });
8647
8851
  }
8648
- async findOwnAsset(assetId) {
8649
- require_CalmLensUtils.encodePathSegment(assetId, "Asset ID");
8650
- const asset = await this.databaseStorage.getAsset(assetId);
8651
- if (!asset || asset.projectId !== this.projectId) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.ASSET_NOT_FOUND);
8652
- return asset;
8852
+ /**
8853
+ * Sets the reviewer's decision: the moderation status, a manual review and the
8854
+ * `asset.<status>` event (after `asset.content-removed` when purging). The test hook records no
8855
+ * reviewer or note; the API's handler records both.
8856
+ */
8857
+ async recordDecision(asset, decision, review) {
8858
+ const decided = await this.applyOutcome(asset, decision, "requested");
8859
+ const now = this.now();
8860
+ decided.classification.moderation.manualReviews = [...decided.classification.moderation.manualReviews ?? [], {
8861
+ id: this.createId("rev"),
8862
+ decision,
8863
+ note: review?.note ?? null,
8864
+ ...review && {
8865
+ reviewerName: review.reviewerName,
8866
+ reviewerEmail: null
8867
+ },
8868
+ reviewedAt: now,
8869
+ appliedToAsset: true
8870
+ }];
8871
+ const stored = await this.databaseStorage.updateAsset(decided);
8872
+ if (decision === "purged") this.recordLifecycleEvent("asset.content-removed", stored);
8873
+ this.pushEvent({
8874
+ type: `asset.${decision}`,
8875
+ severity: decision === "allowed" || decision === "limited" ? "low" : "high",
8876
+ title: `asset.${decision}`,
8877
+ assetId: stored.id,
8878
+ asset: toEventAsset(stored),
8879
+ facts: [
8880
+ {
8881
+ key: "decided-by",
8882
+ value: "reviewer"
8883
+ },
8884
+ {
8885
+ key: "asset-type",
8886
+ value: stored.type
8887
+ },
8888
+ {
8889
+ key: "asset-name",
8890
+ value: stored.name,
8891
+ url: `https://calmlens.com/go/asset/${stored.id}`
8892
+ },
8893
+ ...review ? [{
8894
+ key: "reviewer-user-id",
8895
+ value: null
8896
+ }, {
8897
+ key: "reviewer-name",
8898
+ value: review.reviewerName
8899
+ }] : [],
8900
+ {
8901
+ key: "note",
8902
+ value: review?.note ?? null
8903
+ }
8904
+ ]
8905
+ });
8906
+ return stored;
8653
8907
  }
8654
- begin(operation, options) {
8908
+ /** The `CalmLensError` the real client throws for an API failure, for helpers that send none. */
8909
+ toError(failure) {
8910
+ if (!(failure instanceof ApiFailure)) return failure;
8911
+ const requestId = `mock-request-${++this.requestCount}`;
8912
+ return new require_ModerationContextLimits.CalmLensError(failure.code, {
8913
+ message: failure.message,
8914
+ status: failure.status,
8915
+ details: failure.details ?? {},
8916
+ requestId,
8917
+ headers: new Headers({
8918
+ "content-type": "application/json",
8919
+ "x-request-id": requestId
8920
+ })
8921
+ });
8922
+ }
8923
+ async handle(input, init, endpoints, session) {
8924
+ if (init?.signal?.aborted) throw init.signal.reason;
8925
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
8926
+ const cdn = url.startsWith(`${endpoints.cdnBaseUrl}/`);
8927
+ const base = cdn ? endpoints.cdnBaseUrl : endpoints.baseUrl;
8928
+ if (!url.startsWith(`${base}/`)) throw new TypeError(`MockCalmLensServerClient does not serve ${url}`);
8929
+ const relative = url.slice(base.length);
8930
+ const queryIndex = relative.indexOf("?");
8931
+ const path = queryIndex === -1 ? relative : relative.slice(0, queryIndex);
8932
+ const headers = new Headers(init?.headers);
8933
+ const bytes = init?.body === void 0 || init.body === null ? /* @__PURE__ */ new Uint8Array(0) : new Uint8Array(await new Response(init.body).arrayBuffer());
8934
+ const request = {
8935
+ method: (init?.method ?? "GET").toUpperCase(),
8936
+ url,
8937
+ segments: path.split("/").filter(Boolean).map(decodeSegment),
8938
+ query: new URLSearchParams(queryIndex === -1 ? "" : relative.slice(queryIndex + 1)),
8939
+ headers,
8940
+ bytes,
8941
+ cdn
8942
+ };
8943
+ const operations = operationsOf(request);
8944
+ const call = {
8945
+ operation: operations[0] ?? "unknown",
8946
+ method: request.method,
8947
+ url,
8948
+ path: `/${request.segments.join("/")}`,
8949
+ query: Object.fromEntries(request.query),
8950
+ headers: Object.fromEntries(headers),
8951
+ body: this.describeBody(request),
8952
+ status: void 0
8953
+ };
8655
8954
  this.calls.push({
8656
- operation,
8657
- options
8955
+ call,
8956
+ operations
8658
8957
  });
8659
- const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || pending.operation === operation);
8958
+ const index = this.pendingFailures.findIndex((pending) => pending.operation === void 0 || operations.includes(pending.operation));
8660
8959
  if (index !== -1) {
8661
- const [pending] = this.pendingFailures.splice(index, 1);
8662
- throw toFailureError(pending.failure);
8960
+ const [{ failure, details, retryAfter }] = this.pendingFailures.splice(index, 1);
8961
+ if (typeof failure === "string" && failure in require_ModerationContextLimits.API_ERROR_DEFINITIONS) return this.answered(call, this.failureResponse(fail(failure, { details }), url, retryAfter));
8962
+ throw this.toFailureError(failure);
8663
8963
  }
8964
+ const response = cdn ? await this.serveCdn(request) : await this.answer(request, operations[0], session);
8965
+ return this.answered(call, response);
8664
8966
  }
8665
- jsonResponse(status, body, url) {
8666
- const requestId = `mock-request-${++this.requestCount}`;
8667
- const response = new Response(JSON.stringify(body), {
8967
+ answered(call, response) {
8968
+ call.status = response.status;
8969
+ return response;
8970
+ }
8971
+ /** What `fetch` throws for a simulated network failure, as the real client reports it. */
8972
+ toFailureError(failure) {
8973
+ if (failure === "transport-error") return /* @__PURE__ */ new TypeError("Simulated network failure");
8974
+ if (failure === "timeout") {
8975
+ const cause = /* @__PURE__ */ new Error("The operation timed out.");
8976
+ cause.name = "TimeoutError";
8977
+ return new require_ModerationContextLimits.CalmLensError(require_ModerationContextLimits.SDK_ERROR_CODES.TIMEOUT, {
8978
+ message: "Request timed out",
8979
+ cause
8980
+ });
8981
+ }
8982
+ return failure;
8983
+ }
8984
+ describeBody({ bytes, headers }) {
8985
+ if (bytes.byteLength === 0) return void 0;
8986
+ const type = headers.get("content-type") ?? "";
8987
+ const text = () => new TextDecoder().decode(bytes);
8988
+ if (type === "application/json") try {
8989
+ return JSON.parse(text());
8990
+ } catch {
8991
+ return text();
8992
+ }
8993
+ return type.startsWith("text/") ? text() : bytes.slice();
8994
+ }
8995
+ response(status, body, url, headers, requestId = `mock-request-${++this.requestCount}`) {
8996
+ const response = new Response(body, {
8668
8997
  status,
8669
8998
  headers: {
8670
- "content-type": "application/json",
8671
- "x-request-id": requestId
8999
+ ...headers,
9000
+ "X-Request-Id": requestId
8672
9001
  }
8673
9002
  });
8674
9003
  Object.defineProperty(response, "url", { value: url });
8675
9004
  return response;
8676
9005
  }
8677
- get cdn() {
8678
- return {
8679
- getAssetUrl: ({ assetIdentifier, ...options }) => {
8680
- const resource = options.resource ?? "content";
8681
- const url = `${this.cdnBaseUrl}/assets/${encodeURIComponent(this.projectId)}/${require_CalmLensUtils.encodePathSegment(assetIdentifier, "Asset identifier")}/${resource}`;
8682
- return require_CalmLensUtils.appendCdnSelector(url, options);
8683
- },
8684
- /**
8685
- * Serves stored content like the CDN: `200` with `Content-Type`, `Content-Length`,
8686
- * `ETag` and `X-Request-Id`, or a JSON error body with `404`/`403`. Never throws for
8687
- * HTTP errors, like the real client.
8688
- */
8689
- serve: async (serveOptions) => {
8690
- this.begin("cdn.serve", serveOptions);
8691
- const { assetIdentifier, init, ...options } = serveOptions;
8692
- const url = this.cdn.getAssetUrl({
8693
- assetIdentifier,
8694
- ...options
8695
- });
8696
- const asset = (await this.databaseStorage.listAssets(this.projectId)).find((asset) => asset.id === assetIdentifier || asset.externalId === assetIdentifier);
8697
- const notFound = () => this.jsonResponse(404, {
8698
- code: "asset/not-found",
8699
- message: "Asset not found"
8700
- }, url);
8701
- if (!asset) return notFound();
8702
- if (asset.visibility !== "public" && !permits(this.apiKeyGrants, "assets:download", asset.folderId ?? null, await this.databaseStorage.listFolders?.(this.projectId) ?? [])) return this.jsonResponse(403, {
8703
- code: "asset/api-key-missing-permission",
8704
- message: "This API key needs assets:download permission for this asset’s folder."
8705
- }, url);
8706
- const storedContent = await this.imageStorage.getContent(asset.id);
8707
- if (!storedContent) return notFound();
8708
- const parts = [...storedContent.parts.entries()].sort(([left], [right]) => left - right).map(([, value]) => value);
8709
- const byteLength = parts.reduce((sum, value) => sum + value.byteLength, 0);
8710
- const bytes = new Uint8Array(byteLength);
8711
- let offset = 0;
8712
- for (const part of parts) {
8713
- bytes.set(part, offset);
8714
- offset += part.byteLength;
8715
- }
8716
- const requestId = `mock-request-${++this.requestCount}`;
8717
- const isHead = init?.method?.toUpperCase() === "HEAD";
8718
- const response = new Response(isHead ? null : bytes, {
8719
- status: 200,
8720
- headers: {
8721
- "Content-Type": storedContent.type ?? "application/octet-stream",
8722
- "Content-Length": String(byteLength),
8723
- "Cache-Control": asset.visibility === "public" ? "public, max-age=31536000" : "private, no-store",
8724
- ETag: `"${asset.id}-${asset.updatedAt}"`,
8725
- "X-Request-Id": requestId,
8726
- "X-Mock-CalmLens-CDN-URL": url
8727
- }
9006
+ jsonResponse(status, body, url, headers = {}, requestId) {
9007
+ return this.response(status, JSON.stringify(body), url, {
9008
+ "Content-Type": "application/json",
9009
+ ...headers
9010
+ }, requestId);
9011
+ }
9012
+ /** The API's error body: `{ code, message, requestId, details }`, `requestId` as in the header. */
9013
+ failureResponse(failure, url, retryAfter) {
9014
+ const requestId = `mock-request-${++this.requestCount}`;
9015
+ return this.jsonResponse(failure.status, {
9016
+ code: failure.code,
9017
+ message: failure.message,
9018
+ requestId,
9019
+ details: failure.details ?? {}
9020
+ }, url, retryAfter === void 0 ? {} : { "Retry-After": String(retryAfter) }, requestId);
9021
+ }
9022
+ /** Answers an API request: the API key, idempotency, then the route. */
9023
+ async answer(request, operation, session) {
9024
+ const { url, headers } = request;
9025
+ if (!(request.segments[0] === "upload-presets") && headers.get("x-api-key") !== this.apiKey) return this.failureResponse(fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_API_KEY), url);
9026
+ const key = headers.get("idempotency-key")?.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
9027
+ const upload = headers.get("x-calmlens-upload");
9028
+ const fingerprint = JSON.stringify([
9029
+ request.method,
9030
+ request.segments,
9031
+ [...request.query].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
9032
+ headers.get("content-type"),
9033
+ upload,
9034
+ upload ? request.bytes.byteLength : new TextDecoder().decode(request.bytes)
9035
+ ]);
9036
+ if (key !== void 0) {
9037
+ if (!isValidIdempotencyKey(key)) return this.failureResponse(fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_IDEMPOTENCY_KEY), url);
9038
+ const saved = this.idempotentResults.get(key);
9039
+ if (saved) {
9040
+ if (saved.fingerprint !== fingerprint) return this.failureResponse(fail(require_ModerationContextLimits.API_ERROR_CODES.IDEMPOTENCY_KEY_REUSED), url);
9041
+ return this.response(saved.status, saved.body, url, {
9042
+ "Content-Type": "application/json",
9043
+ "Idempotent-Replayed": "true"
8728
9044
  });
8729
- Object.defineProperty(response, "url", { value: url });
8730
- return response;
8731
9045
  }
8732
- };
9046
+ }
9047
+ let answer;
9048
+ try {
9049
+ answer = await this.route(request, session);
9050
+ } catch (failure) {
9051
+ if (failure instanceof ApiFailure) return this.failureResponse(failure, url);
9052
+ throw failure;
9053
+ }
9054
+ const [status, body] = answer;
9055
+ const json = JSON.stringify(body ?? {});
9056
+ const finalStatus = operation ? SUCCESS_STATUS[operation] ?? status : status;
9057
+ if (key !== void 0) this.idempotentResults.set(key, {
9058
+ fingerprint,
9059
+ status: finalStatus,
9060
+ body: json
9061
+ });
9062
+ return this.response(finalStatus, json, url, { "Content-Type": "application/json" });
9063
+ }
9064
+ async route(request, session) {
9065
+ const { method, segments } = request;
9066
+ const [root, id, action, ...rest] = segments;
9067
+ if (root === "projects") {
9068
+ if (id !== this.projectId) throw fail(require_ModerationContextLimits.API_ERROR_CODES.API_KEY_PROJECT_MISMATCH);
9069
+ switch (`${method} ${segments.slice(2).join("/")}`) {
9070
+ case "POST assets": return [200, await this.createAsset(request)];
9071
+ case "GET assets": return [200, await this.listAssets(request.query)];
9072
+ case "GET assets/search": return [200, await this.searchAssets(request.query)];
9073
+ case "POST assets/batch": return [202, await this.batch(this.json(request))];
9074
+ case "GET events": return [200, this.listEvents(request.query)];
9075
+ case "GET folders": return [200, await this.listFolders(request.query)];
9076
+ case "POST folders": return [201, await this.createFolder(this.json(request))];
9077
+ case "GET hash-lists": return [200, this.listHashLists(request.query)];
9078
+ case "POST hash-lists": return [201, this.createHashList(this.json(request))];
9079
+ case "PUT lenses": return [200, this.ensureLenses(this.json(request))];
9080
+ }
9081
+ }
9082
+ if (root === "upload-presets" && id !== void 0 && action === "assets") return [200, await this.presetRoute(request, id, rest, session)];
9083
+ if (root === "assets" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
9084
+ case "GET ": return [200, await this.findOwnAsset(id)];
9085
+ case "PATCH ": return [200, await this.updateAsset(id, this.json(request))];
9086
+ case "DELETE ": return [200, await this.deleteAsset(id)];
9087
+ case "GET children": return [200, await this.listChildren(id, request.query)];
9088
+ case "POST part": return [200, await this.uploadPart(id, request)];
9089
+ case "POST signed-url": return [200, await this.createSignedUrl(id, this.json(request))];
9090
+ case "POST move": return [200, await this.moveAsset(id, this.json(request))];
9091
+ case "POST retry": return [200, await this.retryAsset(id)];
9092
+ case "POST explain": return [200, await this.explainAsset(id, this.json(request))];
9093
+ case "POST decision": return [200, await this.decideAsset(id, this.json(request))];
9094
+ case "DELETE content": return [200, await this.removeContent(await this.findOwnAsset(id))];
9095
+ }
9096
+ if (root === "folders" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
9097
+ case "GET ": return [200, await this.findOwnFolder(id)];
9098
+ case "PATCH ": return [200, await this.updateFolder(id, this.json(request))];
9099
+ case "DELETE ": return [200, await this.deleteFolder(id)];
9100
+ case "POST move": return [200, await this.moveFolder(id, this.json(request))];
9101
+ }
9102
+ if (root === "hash-lists" && id !== void 0 && rest.length === 0) switch (`${method} ${action ?? ""}`) {
9103
+ case "GET ": return [200, this.findHashList(id)];
9104
+ case "PATCH ": return [200, this.updateHashList(id, this.json(request))];
9105
+ case "DELETE ": return [200, this.deleteHashList(id)];
9106
+ case "GET entries": return [200, this.listHashListEntries(id, request.query)];
9107
+ case "POST entries": return this.addHashListEntry(id, this.json(request));
9108
+ }
9109
+ if (root === "hash-list-entries" && id !== void 0 && method === "DELETE" && !action) {
9110
+ if (!this.hashListEntries.delete(id)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.HASH_LIST_ENTRY_NOT_FOUND);
9111
+ return [200, {}];
9112
+ }
9113
+ throw fail(require_ModerationContextLimits.API_ERROR_CODES.ROUTE_NOT_FOUND, { message: `No route for ${method} /${segments.join("/")}` });
8733
9114
  }
8734
- get events() {
8735
- return {
8736
- /**
8737
- * Reads the mock feed like the API: without `after`, no events and a cursor for "now";
8738
- * with `after`, up to `limit` events added since, oldest first. Add events with
8739
- * `enqueueEvent`.
8740
- */
8741
- list: async (options = {}) => {
8742
- this.begin("events.list", options);
8743
- return this.listEvents(options);
8744
- },
8745
- pages: (options = {}) => this.iterateEventPages(options)
9115
+ createHashList(body) {
9116
+ const { name, description, action } = body;
9117
+ if (typeof name !== "string" || !name.trim() || action !== "flag" && action !== "block") throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "A hash list needs a name and an action of flag or block" });
9118
+ const now = this.now();
9119
+ const list = {
9120
+ id: this.createId("hl"),
9121
+ projectId: this.projectId,
9122
+ name: name.trim(),
9123
+ description: typeof description === "string" ? description : null,
9124
+ action,
9125
+ createdAt: now,
9126
+ updatedAt: now
8746
9127
  };
9128
+ this.hashLists.set(list.id, list);
9129
+ return list;
8747
9130
  }
8748
- listEvents(options) {
8749
- const limit = options.limit ?? DEFAULT_EVENTS_LIMIT;
8750
- if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENTS_LIMIT) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: `limit must be an integer from 1 to ${MAX_EVENTS_LIMIT}` });
8751
- if (!options.after) return {
8752
- events: [],
8753
- cursor: `mock_${this.eventFeed.length}`,
8754
- hasMore: false
9131
+ listHashLists(query) {
9132
+ return createPage([...this.hashLists.values()], this.pageQuery(query), {
9133
+ scope: `hash-lists:${this.projectId}`,
9134
+ fail: this.pageFailure
9135
+ });
9136
+ }
9137
+ findHashList(hashListId) {
9138
+ const list = this.hashLists.get(hashListId);
9139
+ if (!list) throw fail(require_ModerationContextLimits.API_ERROR_CODES.HASH_LIST_NOT_FOUND);
9140
+ return list;
9141
+ }
9142
+ updateHashList(hashListId, body) {
9143
+ const updated = {
9144
+ ...this.findHashList(hashListId),
9145
+ ...typeof body.name === "string" ? { name: body.name.trim() } : {},
9146
+ ...body.description !== void 0 ? { description: body.description ?? null } : {},
9147
+ ...body.action === "flag" || body.action === "block" ? { action: body.action } : {},
9148
+ updatedAt: this.now()
8755
9149
  };
8756
- const match = /^mock_(\d+)$/.exec(options.after);
8757
- const position = match ? Number(match[1]) : NaN;
8758
- if (!Number.isSafeInteger(position) || position > this.eventFeed.length) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_EVENT_CURSOR);
8759
- const events = this.eventFeed.slice(position, position + limit);
8760
- const next = position + events.length;
9150
+ this.hashLists.set(hashListId, updated);
9151
+ return updated;
9152
+ }
9153
+ deleteHashList(hashListId) {
9154
+ this.findHashList(hashListId);
9155
+ this.hashLists.delete(hashListId);
9156
+ for (const [id, entry] of this.hashListEntries) if (entry.hashListId === hashListId) this.hashListEntries.delete(id);
9157
+ return {};
9158
+ }
9159
+ listHashListEntries(hashListId, query) {
9160
+ this.findHashList(hashListId);
9161
+ const pageSize = this.integer(query, "pageSize", 50);
9162
+ const entries = [...this.hashListEntries.values()].filter((entry) => entry.hashListId === hashListId).reverse();
9163
+ const cursor = query.get("cursor");
9164
+ const start = cursor ? Number(atob(cursor)) : 0;
9165
+ if (!Number.isInteger(start) || start < 0) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_CURSOR);
9166
+ const items = entries.slice(start, start + pageSize);
9167
+ const hasMore = start + pageSize < entries.length;
8761
9168
  return {
8762
- events,
8763
- cursor: `mock_${next}`,
8764
- hasMore: next < this.eventFeed.length
9169
+ items,
9170
+ hasMore,
9171
+ nextCursor: hasMore ? btoa(String(start + pageSize)) : null,
9172
+ pageSize,
9173
+ ordering: [{
9174
+ field: "createdAt",
9175
+ direction: "desc"
9176
+ }, {
9177
+ field: "id",
9178
+ direction: "desc"
9179
+ }]
8765
9180
  };
8766
9181
  }
8767
- async *iterateEventPages(options) {
8768
- let after = options.after;
8769
- for (;;) {
8770
- const page = await this.events.list({
8771
- ...options,
8772
- after
8773
- });
8774
- yield page;
8775
- if (!page.hasMore || page.cursor === after) return;
8776
- after = page.cursor;
9182
+ async addHashListEntry(hashListId, body) {
9183
+ const list = this.findHashList(hashListId);
9184
+ let hash = typeof body.hash === "string" ? body.hash.trim().toLowerCase() : void 0;
9185
+ let quality = typeof body.quality === "number" ? body.quality : null;
9186
+ const assetId = typeof body.assetId === "string" ? body.assetId : void 0;
9187
+ if (hash === void 0 === (assetId === void 0)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Send either hash or assetId" });
9188
+ if (assetId) {
9189
+ const image = (await this.findOwnAsset(assetId)).content.image;
9190
+ if (!image?.perceptualHash) throw fail(require_ModerationContextLimits.API_ERROR_CODES.HASH_LIST_ASSET_NOT_HASHED);
9191
+ hash = image.perceptualHash;
9192
+ quality = image.perceptualHashQuality ?? null;
8777
9193
  }
9194
+ if (!/^[0-9a-f]{64}$/.test(hash)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Use a PDQ hash of 64 hexadecimal characters" });
9195
+ const existing = [...this.hashListEntries.values()].find((entry) => entry.hashListId === list.id && entry.hash === hash);
9196
+ if (existing) return [200, existing];
9197
+ const entry = {
9198
+ id: this.createId("hle"),
9199
+ projectId: this.projectId,
9200
+ hashListId: list.id,
9201
+ hash,
9202
+ quality,
9203
+ label: typeof body.label === "string" ? body.label : null,
9204
+ sourceAssetId: assetId ?? null,
9205
+ createdAt: this.now()
9206
+ };
9207
+ this.hashListEntries.set(entry.id, entry);
9208
+ return [201, entry];
8778
9209
  }
8779
- async *iteratePages(listPage) {
8780
- let cursor;
8781
- for (;;) {
8782
- const page = await listPage(cursor);
8783
- yield* page.items;
8784
- if (!page.nextCursor || !page.hasMore || page.items.length === 0) return;
8785
- cursor = page.nextCursor;
9210
+ /** The request's JSON body, or `{}` without one. */
9211
+ json(request) {
9212
+ if (request.bytes.byteLength === 0) return {};
9213
+ try {
9214
+ const body = JSON.parse(new TextDecoder().decode(request.bytes));
9215
+ return body && typeof body === "object" ? body : {};
9216
+ } catch {
9217
+ throw new ApiFailure(400, "validation/invalid-json", "Invalid JSON body");
8786
9218
  }
8787
9219
  }
8788
- get text() {
8789
- return { moderate: async (options) => {
8790
- this.begin("text.moderate", options);
8791
- return this.moderateAsset(require_CalmLensUtils.toTextAssetOptions(options), "text.moderate");
8792
- } };
9220
+ /** A whole number from the query, or `fallback` when absent; other values fail like zod. */
9221
+ integer(query, name, fallback) {
9222
+ const raw = query.get(name);
9223
+ if (raw === null) return fallback;
9224
+ const value = Number(raw);
9225
+ if (raw.trim() === "" || !Number.isFinite(value)) throw invalidField("validation/invalid-type", name);
9226
+ if (!Number.isInteger(value)) throw invalidField("validation/invalid-type", name);
9227
+ return value;
9228
+ }
9229
+ /** `ordering[i][field]` and `ordering[i][direction]` pairs, as qs parses them. */
9230
+ ordering(query) {
9231
+ const ordering = [];
9232
+ for (const [key, value] of query) {
9233
+ const match = /^ordering\[(\d+)\]\[(field|direction)\]$/.exec(key);
9234
+ if (!match) continue;
9235
+ const index = Number(match[1]);
9236
+ ordering[index] = {
9237
+ ...ordering[index],
9238
+ [match[2]]: value
9239
+ };
9240
+ }
9241
+ return ordering.filter(Boolean);
8793
9242
  }
8794
- get assets() {
9243
+ pageQuery(query) {
9244
+ const ordering = this.ordering(query);
8795
9245
  return {
8796
- /**
8797
- * Matches `query` (case-insensitively) against each asset's name, ID, external ID,
8798
- * description and tags, best match first. Mock scores are 1 for an exact match and 0.5
8799
- * otherwise; `mode` is accepted but there is no semantic search.
8800
- */
8801
- search: async (options) => {
8802
- this.begin("assets.search", options);
8803
- const query = typeof options?.query === "string" ? options.query.trim() : "";
8804
- if (!query || query.length > 200) throw this.invalidPayload("query must be 1 to 200 characters");
8805
- const limit = options.limit ?? 10;
8806
- const needle = query.toLowerCase();
8807
- const fields = [
8808
- "name",
8809
- "id",
8810
- "externalId",
8811
- "description"
8812
- ];
8813
- return {
8814
- query,
8815
- items: (await this.databaseStorage.listAssets(this.projectId)).map((asset) => {
8816
- const matchedFields = [];
8817
- for (const field of fields) if (String(asset[field] ?? "").toLowerCase().includes(needle)) matchedFields.push(field);
8818
- if (asset.tags?.some((tag) => tag.toLowerCase().includes(needle))) matchedFields.push("tags");
8819
- return {
8820
- asset,
8821
- score: fields.some((field) => String(asset[field] ?? "").toLowerCase() === needle) ? 1 : .5,
8822
- matchedFields
8823
- };
8824
- }).filter((result) => result.matchedFields.length > 0).sort((a, b) => b.score - a.score).slice(0, limit),
8825
- semanticStatus: "disabled"
8826
- };
8827
- },
8828
- /** Lists assets with the real client's paging, `ordering`, `folderId` and `filter`. */
8829
- list: async (options = {}) => {
8830
- this.begin("assets.list", options);
8831
- let assets = await this.databaseStorage.listAssets(this.projectId);
8832
- if (options.folderId !== void 0) assets = assets.filter((asset) => (asset.folderId ?? null) === options.folderId);
8833
- assets = filterAssets(assets, options.filter);
8834
- return createPage(assets, options);
8835
- },
8836
- iterate: (options = {}) => this.iteratePages((cursor) => this.assets.list({
8837
- ...options,
8838
- cursor
8839
- })),
8840
- retrieve: async (options) => {
8841
- this.begin("assets.retrieve", options);
8842
- return this.findOwnAsset(options?.assetId);
8843
- },
8844
- listChildren: async (childrenOptions = {}) => {
8845
- this.begin("assets.listChildren", childrenOptions);
8846
- const { assetId, ...options } = childrenOptions;
8847
- require_CalmLensUtils.encodePathSegment(assetId, "Asset ID");
8848
- const assets = await this.databaseStorage.listAssets(this.projectId);
8849
- return createPage(assets.filter((asset) => asset.parentId === assetId), options);
8850
- },
8851
- iterateChildren: (options) => {
8852
- require_CalmLensUtils.encodePathSegment(options?.assetId, "Asset ID");
8853
- return this.iteratePages((cursor) => this.assets.listChildren({
8854
- ...options,
8855
- cursor
8856
- }));
8857
- },
8858
- update: async (options) => {
8859
- this.begin("assets.update", options);
8860
- const { assetId, ...body } = options;
8861
- const asset = await this.findOwnAsset(assetId);
8862
- const changes = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
8863
- return this.databaseStorage.updateAsset({
8864
- ...asset,
8865
- ...changes,
8866
- updatedAt: this.now()
8867
- });
8868
- },
8869
- create: async (options) => {
8870
- this.begin("assets.create", options);
8871
- return this.withIdempotency("assets.create", options, () => this.createAsset(options, true));
8872
- },
8873
- moderate: async (options) => {
8874
- this.begin("assets.moderate", options);
8875
- return this.moderateAsset(options, "assets.moderate");
8876
- },
8877
- /** Continues a multipart upload: parts must arrive in order while it is `uploading`. */
8878
- uploadPart: async (options) => {
8879
- this.begin("assets.uploadPart", options);
8880
- const { assetId, part } = options;
8881
- require_CalmLensUtils.encodePathSegment(assetId, "Asset ID");
8882
- if (!part) throw require_CalmLensVersion.invalidArgument("Part options are required");
8883
- return this.withIdempotency("assets.uploadPart", options, async () => {
8884
- const asset = await this.findOwnAsset(assetId);
8885
- const validation = require_CalmLensUtils.validateAssetPayload(part, {
8886
- multipart: true,
8887
- isFinal: part.isFinal
8888
- });
8889
- if (!validation.ok) throw this.invalidPayload(validation.error);
8890
- if (asset.processing.status !== "uploading") throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.UPLOAD_NOT_OPEN);
8891
- const expectedPartNumber = (asset.processing.uploadPartsReceived ?? 1) + 1;
8892
- if (part.partNumber !== expectedPartNumber) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_UPLOAD_PART_NUMBER, { details: { expectedPartNumber } });
8893
- await this.imageStorage.putPart(assetId, part);
8894
- const queue = options.queue ?? this.uploadQueues.get(assetId) ?? false;
8895
- if (part.isFinal) this.uploadQueues.delete(assetId);
8896
- const now = this.now();
8897
- const received = {
8898
- ...asset,
8899
- processing: {
8900
- ...asset.processing,
8901
- uploadPartsReceived: part.partNumber
8902
- },
8903
- updatedAt: now
8904
- };
8905
- return this.databaseStorage.updateAsset(part.isFinal ? await this.applyOutcome(received, queue ? this.queuedOutcome : this.analyzedOutcome) : received);
8906
- });
8907
- },
8908
- createSignedUrl: async (signedUrlOptions) => {
8909
- this.begin("assets.createSignedUrl", signedUrlOptions);
8910
- const { assetId, ...options } = signedUrlOptions;
8911
- const asset = await this.findOwnAsset(assetId);
8912
- const expiresInSeconds = options.expiresInSeconds ?? 900;
8913
- const resource = options.resource ?? "content";
8914
- const transform = require_CalmLensUtils.transformSelector(options.transform);
8915
- const selector = (options.lens ? `&lens=${encodeURIComponent(options.lens)}` : "") + (transform ? `&transform=${encodeURIComponent(transform)}` : "");
8916
- return {
8917
- url: `${this.signedUrlBaseUrl}/assets/${asset.id}/${resource}?expiresInSeconds=${expiresInSeconds}${selector}`,
8918
- expiresAt: this.now() + expiresInSeconds * 1e3
8919
- };
8920
- },
8921
- move: async (options) => {
8922
- this.begin("assets.move", options);
8923
- const { assetId, folderId } = options;
8924
- const asset = await this.findOwnAsset(assetId);
8925
- if (folderId !== null && folderId !== void 0) {
8926
- if (!await this.resolveFolder(folderId)) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.FOLDER_NOT_FOUND);
8927
- }
8928
- return this.databaseStorage.updateAsset({
8929
- ...asset,
8930
- folderId: folderId ?? null,
8931
- updatedAt: this.now()
8932
- });
8933
- },
8934
- retry: async (options) => {
8935
- this.begin("assets.retry", options);
8936
- const asset = await this.findOwnAsset(options?.assetId);
8937
- if (asset.processing.status !== "failed") throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.ASSET_NOT_RETRYABLE);
8938
- if (asset.content.status === "removed") throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
8939
- return this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
8940
- },
8941
- /** Waits like the real client, checking the mock's storage for a decision. */
8942
- waitForDecision: async (options) => {
8943
- this.begin("assets.waitForDecision", options);
8944
- const { assetId, ...waitOptions } = options;
8945
- return require_CalmLensUtils.pollForDecision(() => this.findOwnAsset(assetId), waitOptions, require_CalmLensUtils.sleep);
8946
- },
8947
- deleteContent: async (options) => {
8948
- this.begin("assets.deleteContent", options);
8949
- const asset = await this.findOwnAsset(options?.assetId);
8950
- await this.imageStorage.deleteContent(asset.id);
8951
- const now = this.now();
8952
- return this.databaseStorage.updateAsset({
8953
- ...asset,
8954
- content: asset.content.status === "removed" ? asset.content : {
8955
- ...asset.content,
8956
- status: "removed",
8957
- removalReason: "requested",
8958
- updatedAt: now,
8959
- extractedText: null
8960
- },
8961
- updatedAt: now
8962
- });
8963
- },
8964
- delete: async (options) => {
8965
- this.begin("assets.delete", options);
8966
- const asset = await this.findOwnAsset(options?.assetId);
8967
- await this.imageStorage.deleteContent(asset.id);
8968
- await this.databaseStorage.deleteAsset(asset.id);
8969
- }
9246
+ pageSize: this.integer(query, "pageSize", 50),
9247
+ cursor: query.get("cursor") ?? void 0,
9248
+ ordering: ordering.length ? ordering : void 0
8970
9249
  };
8971
9250
  }
8972
- /** Signs real upload tokens with the mock's API key, like the real client, without a request. */
8973
- get uploadTokens() {
8974
- return { sign: async (options) => {
8975
- this.begin("uploadTokens.sign", options);
8976
- return require_CalmLensWebhooks.signUploadTokenWithApiKey(this.apiKey, options, {
8977
- now: this.now(),
8978
- nonce: this.createId()
8979
- });
8980
- } };
9251
+ async findOwnAsset(assetId) {
9252
+ const asset = await this.databaseStorage.getAsset(assetId);
9253
+ if (!asset || asset.projectId !== this.projectId) throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_NOT_FOUND);
9254
+ return asset;
8981
9255
  }
8982
- get folders() {
9256
+ async listAssets(query) {
9257
+ let assets = await this.databaseStorage.listAssets(this.projectId);
9258
+ const folderId = query.get("folderId");
9259
+ if (folderId !== null) assets = assets.filter((asset) => (asset.folderId ?? null) === folderId);
9260
+ const filter = query.get("filter") ?? void 0;
9261
+ assets = filterAssets(assets, filter);
9262
+ return createPage(assets, this.pageQuery(query), {
9263
+ scope: JSON.stringify([
9264
+ this.projectId,
9265
+ filter ?? null,
9266
+ folderId
9267
+ ]),
9268
+ fail: this.pageFailure
9269
+ });
9270
+ }
9271
+ /**
9272
+ * Keyword search like the API's: every whitespace-separated token must appear,
9273
+ * case-insensitively, in the asset's name, ID, external ID, description, tags, type, content
9274
+ * format or extension, or moderation status. There is no semantic search.
9275
+ */
9276
+ async searchAssets(params) {
9277
+ const query = (params.get("query") ?? "").trim();
9278
+ if (!query) throw invalidField("validation/too-small", "query");
9279
+ if (query.length > MAX_SEARCH_QUERY_LENGTH) throw invalidField("validation/too-big", "query");
9280
+ const limit = this.integer(params, "limit", 10);
9281
+ if (limit < 1) throw invalidField("validation/too-small", "limit");
9282
+ if (limit > MAX_SEARCH_LIMIT) throw invalidField("validation/too-big", "limit");
8983
9283
  return {
8984
- list: async () => {
8985
- this.begin("folders.list", void 0);
8986
- return [...this.databaseStorage.listFolders ? await this.databaseStorage.listFolders(this.projectId) : []].sort((a, b) => a.name.localeCompare(b.name));
8987
- },
8988
- retrieve: async (options) => {
8989
- this.begin("folders.retrieve", options);
8990
- return this.findOwnFolder(options?.folderId);
8991
- },
8992
- create: async (options) => {
8993
- this.begin("folders.create", options);
8994
- if (!options?.name) throw this.invalidPayload("Folder name is required");
8995
- if (options.parentId) await this.findOwnFolder(options.parentId);
8996
- await this.assertUniqueFolderName(options.name, options.parentId ?? null);
8997
- const folder = {
8998
- id: this.createId(),
8999
- projectId: this.projectId,
9000
- parentId: options.parentId ?? null,
9001
- name: options.name,
9002
- createdAt: this.now(),
9003
- updatedAt: this.now()
9004
- };
9005
- return this.databaseStorage.createFolder ? await this.databaseStorage.createFolder(folder) : folder;
9006
- },
9007
- update: async (options) => {
9008
- this.begin("folders.update", options);
9009
- const { folderId, ...body } = options;
9010
- const folder = await this.findOwnFolder(folderId);
9011
- if (body.name !== void 0 && body.name !== folder.name) await this.assertUniqueFolderName(body.name, folder.parentId ?? null, folderId);
9012
- const updated = {
9013
- ...folder,
9014
- ...body.name !== void 0 ? { name: body.name } : {},
9015
- updatedAt: this.now()
9016
- };
9017
- return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
9018
- },
9019
- delete: async (options) => {
9020
- this.begin("folders.delete", options);
9021
- const { folderId } = options;
9022
- await this.findOwnFolder(folderId);
9023
- const hasAssets = (await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.folderId === folderId);
9024
- const hasChildren = (this.databaseStorage.listFolders ? await this.databaseStorage.listFolders(this.projectId) : []).some((f) => f.parentId === folderId);
9025
- if (hasAssets || hasChildren) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.FOLDER_NOT_EMPTY, { details: {
9026
- hasAssets,
9027
- hasChildren
9028
- } });
9029
- await this.databaseStorage.deleteFolder?.(folderId);
9030
- },
9031
- move: async (options) => {
9032
- this.begin("folders.move", options);
9033
- const { folderId, parentId } = options;
9034
- const folder = await this.findOwnFolder(folderId);
9035
- const targetParentId = parentId ?? null;
9036
- if (targetParentId) {
9037
- let candidate = await this.findOwnFolder(targetParentId);
9038
- const visited = /* @__PURE__ */ new Set();
9039
- while (candidate) {
9040
- if (candidate.id === folderId || visited.has(candidate.id)) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.FOLDER_PARENT_CYCLE);
9041
- visited.add(candidate.id);
9042
- candidate = candidate.parentId ? await this.resolveFolder(candidate.parentId) : void 0;
9043
- }
9044
- }
9045
- if (targetParentId !== folder.parentId) await this.assertUniqueFolderName(folder.name, targetParentId, folderId);
9046
- const updated = {
9047
- ...folder,
9048
- parentId: targetParentId,
9049
- updatedAt: this.now()
9050
- };
9051
- return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
9052
- }
9284
+ query,
9285
+ items: searchAssets(await this.databaseStorage.listAssets(this.projectId), query, limit),
9286
+ semanticStatus: "disabled"
9053
9287
  };
9054
9288
  }
9055
- async ensureLens(options) {
9056
- this.begin("ensureLens", options);
9057
- if (!options?.identifier) throw require_CalmLensVersion.invalidArgument("Lens identifier is required");
9058
- return this.ensureLensDefinition(options);
9289
+ async listChildren(assetId, query) {
9290
+ const parent = await this.findOwnAsset(assetId);
9291
+ const assets = await this.databaseStorage.listAssets(this.projectId);
9292
+ return createPage(assets.filter((asset) => asset.parentId === parent.id), this.pageQuery(query), {
9293
+ scope: parent.id,
9294
+ fail: this.pageFailure
9295
+ });
9059
9296
  }
9060
- ensureLensDefinition(options) {
9061
- const existing = this.lenses.get(options.identifier);
9062
- if (!existing) {
9063
- const lens = {
9064
- id: this.createId(),
9065
- projectId: this.projectId,
9066
- userId: null,
9067
- ...options,
9068
- createdAt: this.now(),
9069
- updatedAt: this.now()
9070
- };
9071
- this.lenses.set(lens.identifier, lens);
9072
- return lens;
9297
+ async updateAsset(assetId, body) {
9298
+ const asset = await this.findOwnAsset(assetId);
9299
+ if (body.externalId) {
9300
+ if ((await this.databaseStorage.listAssets(this.projectId)).some((other) => other.id !== asset.id && other.externalId === body.externalId)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
9073
9301
  }
9074
- if (existing.name === options.name && (existing.description ?? null) === (options.description ?? null) && existing.type === options.type && existing.active === options.active && require_CalmLensWebhooks.stringifyJsonCanonical(existing.transforms ?? null) === require_CalmLensWebhooks.stringifyJsonCanonical(options.transforms ?? null) && existing.version === options.version) return existing;
9075
- if (options.version > existing.version) {
9076
- const lens = {
9077
- ...existing,
9078
- ...options,
9079
- updatedAt: this.now()
9080
- };
9081
- this.lenses.set(lens.identifier, lens);
9082
- return lens;
9302
+ return this.databaseStorage.updateAsset({
9303
+ ...asset,
9304
+ ...body,
9305
+ updatedAt: this.now()
9306
+ });
9307
+ }
9308
+ async deleteAsset(assetId) {
9309
+ const asset = await this.findOwnAsset(assetId);
9310
+ await this.imageStorage.deleteContent(asset.id);
9311
+ await this.databaseStorage.deleteAsset(asset.id);
9312
+ this.recordLifecycleEvent("asset.deleted", asset);
9313
+ }
9314
+ async moveAsset(assetId, body) {
9315
+ const asset = await this.findOwnAsset(assetId);
9316
+ const folderId = body.folderId ?? null;
9317
+ if (folderId !== null && !await this.resolveFolder(folderId)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.FOLDER_NOT_FOUND);
9318
+ return this.databaseStorage.updateAsset({
9319
+ ...asset,
9320
+ folderId,
9321
+ updatedAt: this.now()
9322
+ });
9323
+ }
9324
+ async createSignedUrl(assetId, body) {
9325
+ const asset = await this.findOwnAsset(assetId);
9326
+ const expiresInSeconds = body.expiresInSeconds ?? 900;
9327
+ const resource = body.resource ?? "rendered";
9328
+ const selector = (body.lens ? `&lens=${encodeURIComponent(String(body.lens))}` : "") + (body.transform ? `&transform=${encodeURIComponent(String(body.transform))}` : "");
9329
+ return {
9330
+ url: `${this.options.signedUrlBaseUrl}/assets/${asset.id}/${resource}?expiresInSeconds=${expiresInSeconds}${selector}`,
9331
+ expiresAt: this.now() + expiresInSeconds * 1e3
9332
+ };
9333
+ }
9334
+ async retryAsset(assetId) {
9335
+ const asset = await this.findOwnAsset(assetId);
9336
+ if (asset.classification.status !== "failed" || asset.classification.moderation.status !== "pending") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_NOT_RETRYABLE);
9337
+ if (asset.content.status === "removed") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
9338
+ const retried = await this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
9339
+ this.recordLifecycleEvent("asset.retried", retried);
9340
+ return this.moderateStored(retried, "retry");
9341
+ }
9342
+ async explainAsset(assetId, body) {
9343
+ const asset = await this.findOwnAsset(assetId);
9344
+ if (asset.classification.status !== "completed") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_NOT_DECIDED);
9345
+ if (!body.rerun) {
9346
+ if (asset.classification.moderation?.explanationComplete !== false) return asset;
9347
+ return this.databaseStorage.updateAsset({
9348
+ ...asset,
9349
+ updatedAt: this.now(),
9350
+ classification: {
9351
+ ...asset.classification,
9352
+ moderation: {
9353
+ ...asset.classification.moderation,
9354
+ explanationComplete: true
9355
+ }
9356
+ }
9357
+ });
9083
9358
  }
9084
- throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.LENS_VERSION_CONFLICT, { details: {
9085
- currentVersion: existing.version,
9086
- minimumVersion: existing.version + 1,
9087
- requestedVersion: options.version
9088
- } });
9359
+ if (asset.classification.moderation.manualReviews.length) throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_REVIEWED);
9360
+ if (asset.content.status === "removed") throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_CONTENT_UNAVAILABLE);
9361
+ const rerun = await this.databaseStorage.updateAsset(await this.applyOutcome(asset, "pending"));
9362
+ return this.moderateStored(rerun, "rerun");
9089
9363
  }
9090
- async ensureLenses(options) {
9091
- this.begin("ensureLenses", options);
9092
- if (!Array.isArray(options?.lenses) || options.lenses.length === 0) throw require_CalmLensVersion.invalidArgument("At least one lens is required");
9093
- if (options.lenses.some((lens) => !lens?.identifier)) throw require_CalmLensVersion.invalidArgument("Lens identifier is required");
9094
- return options.lenses.map((option) => this.ensureLensDefinition(option));
9095
- }
9096
- /** Replays the first result for a reused idempotency key, like the API. */
9097
- async withIdempotency(operation, options, run) {
9098
- const key = options?.idempotencyKey;
9099
- if (key === void 0) return run();
9100
- if (!key || key.length > 255 || !/^[\x21-\x7e]+$/.test(key)) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.INVALID_IDEMPOTENCY_KEY);
9101
- const fingerprint = `${operation}:${require_CalmLensWebhooks.stringifyJsonCanonical(JSON.parse(JSON.stringify(options, (_key, value) => describeBinary(value))))}`;
9102
- const saved = this.idempotentResults.get(key);
9103
- if (saved) {
9104
- if (saved.fingerprint !== fingerprint) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.IDEMPOTENCY_KEY_REUSED);
9105
- return saved.asset;
9106
- }
9107
- const asset = await run();
9108
- this.idempotentResults.set(key, {
9109
- fingerprint,
9110
- asset
9364
+ /**
9365
+ * Checks every asset like the API (an ID that is not an asset ID is
9366
+ * `validation/invalid-format`, an unknown
9367
+ * one `asset/not-found`, and either refuses the whole batch), then applies the operation at
9368
+ * once instead of in the background: `delete` deletes each asset with its stored child
9369
+ * assets and records `asset.deleted` with the asset as it was; `remove-content` removes
9370
+ * the content like `deleteContent`. Mock assets carry no safety holds, so none is skipped.
9371
+ */
9372
+ async batch(body) {
9373
+ const assetIds = Array.isArray(body.assetIds) ? body.assetIds : [];
9374
+ const operation = body.operation;
9375
+ if (assetIds.length === 0) throw invalidField("validation/too-small", "assetIds");
9376
+ if (assetIds.length > 500) throw invalidField("validation/too-big", "assetIds");
9377
+ if (operation !== "delete" && operation !== "remove-content") throw invalidField("validation/invalid-value", "operation");
9378
+ assetIds.forEach((assetId, index) => {
9379
+ if (typeof assetId !== "string" || !isId(assetId, "asset")) throw invalidField("validation/invalid-format", `assetIds.${index}`);
9111
9380
  });
9112
- return asset;
9381
+ const assets = [];
9382
+ for (const assetId of new Set(assetIds)) assets.push(await this.findOwnAsset(assetId));
9383
+ for (const asset of assets) if (operation === "delete") {
9384
+ await this.deleteAssetTree(asset);
9385
+ this.recordLifecycleEvent("asset.deleted", asset);
9386
+ } else await this.removeContent(asset);
9387
+ return { queued: true };
9113
9388
  }
9114
- async moderateAsset(options, operation) {
9115
- 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");
9116
- return this.withIdempotency(operation, options, () => this.createAsset(options, false));
9117
- }
9118
- async createAsset(options, queue) {
9119
- if (!options || typeof options !== "object") throw require_CalmLensVersion.invalidArgument("Asset options are required");
9120
- if (!("content" in options) && !("url" in options) && !("part" in options)) throw this.invalidPayload("Either file content, URL, or multipart part is required");
9121
- if (!options.name) throw this.invalidPayload("Asset name is required");
9122
- if (!("part" in options) && "isFinal" in options) throw this.invalidPayload("isFinal is only valid for multipart uploads");
9123
- if ("part" in options && options.part.partNumber && options.part.partNumber > 1) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.USE_UPLOAD_PART);
9124
- if ("content" in options) {
9125
- this.assertValidUploadMimeType(options.content.type);
9126
- if (!("plainText" in options.content)) {
9127
- const validation = require_CalmLensUtils.validateAssetPayload(options.content);
9128
- if (!validation.ok) throw this.invalidPayload(validation.error);
9389
+ /**
9390
+ * Removes an asset's content like the API: the record and report stay; stored content,
9391
+ * extracted text and the placeholder go. Content already removed keeps its reason, and an
9392
+ * `asset.content-removed` event is recorded either way.
9393
+ */
9394
+ async removeContent(asset) {
9395
+ await this.imageStorage.deleteContent(asset.id);
9396
+ let removed = asset;
9397
+ if (asset.content.status === "removed") {
9398
+ if (asset.content.image?.thumbhash) removed = await this.databaseStorage.updateAsset({
9399
+ ...asset,
9400
+ content: {
9401
+ ...asset.content,
9402
+ image: withoutPlaceholder(asset.content.image)
9403
+ }
9404
+ });
9405
+ } else {
9406
+ const now = this.now();
9407
+ removed = await this.databaseStorage.updateAsset({
9408
+ ...asset,
9409
+ context: null,
9410
+ content: {
9411
+ ...asset.content,
9412
+ status: "removed",
9413
+ removalReason: "requested",
9414
+ updatedAt: now,
9415
+ extractedText: null,
9416
+ fileMetadata: null,
9417
+ image: withoutPlaceholder(asset.content.image)
9418
+ },
9419
+ updatedAt: now
9420
+ });
9421
+ }
9422
+ this.recordLifecycleEvent("asset.content-removed", removed);
9423
+ return removed;
9424
+ }
9425
+ /** Deletes an asset, its stored child assets and their content. */
9426
+ async deleteAssetTree(asset) {
9427
+ const assets = await this.databaseStorage.listAssets(this.projectId);
9428
+ const deleting = /* @__PURE__ */ new Set([asset.id]);
9429
+ for (let added = true; added;) {
9430
+ added = false;
9431
+ for (const other of assets) if (other.parentId && deleting.has(other.parentId) && !deleting.has(other.id)) {
9432
+ deleting.add(other.id);
9433
+ added = true;
9129
9434
  }
9130
9435
  }
9131
- if ("part" in options) {
9132
- this.assertValidUploadMimeType(options.part.type);
9133
- const validation = require_CalmLensUtils.validateAssetPayload(options.part, {
9436
+ for (const id of deleting) {
9437
+ await this.imageStorage.deleteContent(id);
9438
+ await this.databaseStorage.deleteAsset(id);
9439
+ }
9440
+ }
9441
+ /** The upload fields of `POST .../assets`: raw uploads carry them in the query. */
9442
+ uploadFields(request) {
9443
+ const upload = request.headers.get("x-calmlens-upload");
9444
+ if (upload !== "content" && upload !== "part") return this.json(request);
9445
+ const { query } = request;
9446
+ const text = (name) => query.get(name) ?? void 0;
9447
+ const parsed = (name) => {
9448
+ const raw = query.get(name);
9449
+ if (raw === null) return void 0;
9450
+ try {
9451
+ return JSON.parse(raw);
9452
+ } catch {
9453
+ throw invalidField("validation/invalid-json", name);
9454
+ }
9455
+ };
9456
+ const total = query.get("totalSizeInBytes");
9457
+ return {
9458
+ name: query.get("name") ?? "",
9459
+ description: text("description"),
9460
+ processingOptions: parsed("processingOptions"),
9461
+ visibility: text("visibility"),
9462
+ metadata: parsed("metadata"),
9463
+ authorId: text("authorId"),
9464
+ context: parsed("context"),
9465
+ externalId: text("externalId"),
9466
+ externalUrl: text("externalUrl"),
9467
+ folderId: text("folderId"),
9468
+ uploadPresetId: text("uploadPresetId"),
9469
+ contentType: query.get("contentType") ?? request.headers.get("content-type") ?? "",
9470
+ bytes: request.bytes,
9471
+ part: upload === "part" ? {
9472
+ partNumber: this.integer(query, "partNumber", 1),
9473
+ isFinal: query.get("isFinal") === "true",
9474
+ totalSizeInBytes: total === null ? void 0 : this.integer(query, "totalSizeInBytes", 0)
9475
+ } : void 0
9476
+ };
9477
+ }
9478
+ async createAsset(request) {
9479
+ return this.storeAsset(this.uploadFields(request), request.query.get("queue") === "true");
9480
+ }
9481
+ async storeAsset(fields, queue) {
9482
+ if (!fields.name) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Asset name is required" });
9483
+ const { contentType, part } = fields;
9484
+ const bytes = fields.bytes ?? /* @__PURE__ */ new Uint8Array(0);
9485
+ if (contentType !== void 0 && require_ModerationContextLimits.isRejectedUploadMimeType(contentType)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_MIME_TYPE, {
9486
+ message: require_ModerationContextLimits.getRejectedUploadMimeTypeError(contentType),
9487
+ details: { mimeType: contentType }
9488
+ });
9489
+ const type = inferAssetType(fields.url === void 0 ? contentType ?? "" : void 0);
9490
+ if (part) {
9491
+ if (part.partNumber > 1) throw fail(require_ModerationContextLimits.API_ERROR_CODES.USE_UPLOAD_PART);
9492
+ const partSize = bytes.byteLength;
9493
+ 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);
9494
+ if (sizeError) throw fail(sizeError);
9495
+ const validation = require_CalmLensConnection.validateAssetPayload({ uint8Array: bytes }, {
9134
9496
  multipart: true,
9135
- isFinal: options.part.isFinal
9497
+ isFinal: part.isFinal
9136
9498
  });
9137
- if (!validation.ok) throw this.invalidPayload(validation.error);
9499
+ if (!validation.ok) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
9500
+ if (!part.isFinal) {
9501
+ const totalError = this.multipartTotalSizeError(part.totalSizeInBytes, partSize, false);
9502
+ if (totalError) throw fail(totalError);
9503
+ }
9138
9504
  }
9139
- if (options.externalId) {
9140
- if ((await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.externalId === options.externalId)) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
9505
+ if (fields.folderId) await this.findOwnFolder(fields.folderId);
9506
+ if (fields.externalId) {
9507
+ if ((await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.externalId === fields.externalId)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.DUPLICATE_EXTERNAL_ID);
9141
9508
  }
9142
- const id = this.createId();
9509
+ const id = this.createId("asset");
9143
9510
  const now = this.now();
9144
- const isOpenUpload = "part" in options && !options.part.isFinal;
9145
- const keepContent = options.processingOptions?.keepContentAfterProcessing === true;
9511
+ const isOpenUpload = part !== void 0 && !part.isFinal;
9512
+ const processingOptions = fields.processingOptions ?? { keepContentAfterProcessing: false };
9513
+ const keepContent = processingOptions.keepContentAfterProcessing === true;
9514
+ const hasContent = fields.url === void 0;
9515
+ const contextItems = require_ModerationContextLimits.selectModerationContextItems(fields.context?.items ?? []);
9146
9516
  const asset = {
9147
9517
  id,
9148
9518
  projectId: this.projectId,
9149
- folderId: options.folderId ?? null,
9519
+ parentId: null,
9520
+ folderId: fields.folderId ?? null,
9521
+ uploadPresetId: fields.uploadPresetId ?? null,
9150
9522
  createdAt: now,
9151
9523
  updatedAt: now,
9152
- name: options.name,
9153
- description: options.description,
9154
- externalId: options.externalId,
9155
- externalUrl: "url" in options ? options.url : "content" in options ? options.externalUrl : void 0,
9156
- type: inferAssetType(options),
9157
- metadata: options.metadata,
9158
- processingOptions: options.processingOptions,
9159
- visibility: options.visibility ?? "private",
9160
- processing: {
9161
- status: isOpenUpload ? "uploading" : "processing",
9162
- updatedAt: now,
9163
- uploadPartsReceived: "part" in options ? options.part.partNumber ?? 1 : null
9164
- },
9165
- moderation: {
9166
- status: "pending",
9167
- action: null,
9168
- note: null,
9169
- reviewerAction: null,
9170
- reviewerNote: null,
9171
- reviewerUserId: null,
9172
- reviewedAt: null,
9173
- decidedAt: null,
9174
- report: null
9175
- },
9524
+ name: fields.name,
9525
+ description: fields.description ?? null,
9526
+ externalId: fields.externalId ?? null,
9527
+ externalUrl: fields.url ?? fields.externalUrl ?? null,
9528
+ type,
9529
+ metadata: fields.metadata ?? null,
9530
+ authorId: fields.authorId ?? null,
9531
+ context: contextItems.length ? { items: contextItems } : null,
9532
+ tags: null,
9533
+ processingOptions,
9534
+ visibility: fields.visibility ?? "private",
9176
9535
  content: {
9177
9536
  status: keepContent ? "stored" : "temporary",
9178
9537
  removalReason: null,
9179
9538
  updatedAt: now,
9180
9539
  safetyHold: null,
9181
- format: this.getContentFormat(options),
9540
+ format: hasContent ? contentType ?? null : null,
9182
9541
  extension: null,
9183
- sizeInBytes: this.getSizeInBytes(options) ?? null,
9542
+ sizeInBytes: hasContent ? bytes.byteLength : null,
9184
9543
  checksums: null,
9185
9544
  image: null,
9186
- previewImage: null,
9187
- extractedText: null
9545
+ extractedText: null,
9546
+ fileMetadata: null
9547
+ },
9548
+ upload: {
9549
+ status: isOpenUpload ? "uploading" : "completed",
9550
+ updatedAt: now,
9551
+ partsReceived: isOpenUpload ? part.partNumber : null
9552
+ },
9553
+ classification: {
9554
+ status: isOpenUpload ? "pending" : "processing",
9555
+ failureReason: null,
9556
+ updatedAt: now,
9557
+ completedAt: null,
9558
+ moderation: {
9559
+ status: "pending",
9560
+ manualReviews: [],
9561
+ flaggedCategories: [],
9562
+ regions: []
9563
+ }
9564
+ }
9565
+ };
9566
+ if (part) {
9567
+ await this.imageStorage.putPart(id, {
9568
+ uint8Array: bytes,
9569
+ type: contentType,
9570
+ partNumber: part.partNumber,
9571
+ isFinal: part.isFinal
9572
+ });
9573
+ if (isOpenUpload) this.openUploads.set(id, {
9574
+ queue,
9575
+ totalSizeInBytes: part.totalSizeInBytes
9576
+ });
9577
+ } else if (hasContent) await this.imageStorage.putContent(id, {
9578
+ uint8Array: bytes,
9579
+ type: contentType ?? ""
9580
+ });
9581
+ if (isOpenUpload) return this.databaseStorage.createAsset(asset);
9582
+ const decision = await this.decisionFor(asset, "upload", queue, hasContent ? bytes : null);
9583
+ const stored = await this.databaseStorage.createAsset(decision.status === "pending" ? asset : await this.decided(asset, decision));
9584
+ this.recordUploaded(asset);
9585
+ this.recordProcessingEvents(stored, decision);
9586
+ return stored;
9587
+ }
9588
+ /** Continues a multipart upload: parts must arrive in order while it is `uploading`. */
9589
+ async uploadPart(assetId, request) {
9590
+ const { query, bytes } = request;
9591
+ const partNumber = this.integer(query, "partNumber", 0);
9592
+ const isFinal = query.get("isFinal") === "true";
9593
+ const asset = await this.findOwnAsset(assetId);
9594
+ if (asset.upload.status !== "uploading") throw fail(require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_NOT_OPEN);
9595
+ const expectedPartNumber = (asset.upload.partsReceived ?? 0) + 1;
9596
+ if (partNumber !== expectedPartNumber) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PART_NUMBER, {
9597
+ message: `Expected part ${expectedPartNumber}`,
9598
+ details: {
9599
+ expectedPartNumber,
9600
+ receivedPartNumber: partNumber
9601
+ }
9602
+ });
9603
+ const received = (asset.content.sizeInBytes ?? 0) + bytes.byteLength;
9604
+ const upload = this.openUploads.get(assetId);
9605
+ const sizeError = this.multipartImageSizeError(asset.type, received, isFinal) ?? this.multipartTotalSizeError(upload?.totalSizeInBytes, received, isFinal);
9606
+ if (sizeError) throw fail(sizeError);
9607
+ const validation = require_CalmLensConnection.validateAssetPayload({ uint8Array: bytes }, {
9608
+ multipart: true,
9609
+ isFinal
9610
+ });
9611
+ if (!validation.ok) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: validation.error });
9612
+ await this.imageStorage.putPart(assetId, {
9613
+ uint8Array: bytes,
9614
+ partNumber,
9615
+ isFinal
9616
+ });
9617
+ const queueOverride = query.get("queue");
9618
+ const queue = queueOverride === null ? upload?.queue ?? false : queueOverride === "true";
9619
+ if (isFinal) this.openUploads.delete(assetId);
9620
+ const now = this.now();
9621
+ const next = {
9622
+ ...asset,
9623
+ updatedAt: now,
9624
+ content: {
9625
+ ...asset.content,
9626
+ sizeInBytes: received
9627
+ },
9628
+ upload: {
9629
+ ...asset.upload,
9630
+ updatedAt: now,
9631
+ partsReceived: isFinal ? null : partNumber
9188
9632
  }
9189
9633
  };
9190
- if ("content" in options) {
9191
- if (!("plainText" in options.content)) await this.imageStorage.putContent(id, options.content);
9192
- } else if ("part" in options) {
9193
- await this.imageStorage.putPart(id, options.part);
9194
- if (isOpenUpload) this.uploadQueues.set(id, queue);
9634
+ if (!isFinal) return this.databaseStorage.updateAsset(next);
9635
+ const processing = await this.applyOutcome(next, "pending");
9636
+ const decision = await this.decisionFor(processing, "upload", queue);
9637
+ const stored = await this.databaseStorage.updateAsset(decision.status === "pending" ? processing : await this.decided(processing, decision));
9638
+ this.recordUploaded(processing);
9639
+ this.recordProcessingEvents(stored, decision);
9640
+ return stored;
9641
+ }
9642
+ /** Like the API: image uploads in parts total 10 MiB (checked at the last part) to 20 MiB. */
9643
+ multipartImageSizeError(type, sizeInBytes, isFinal) {
9644
+ if (type !== "image") return void 0;
9645
+ if (sizeInBytes > require_ModerationContextLimits.MAX_IMAGE_UPLOAD_BYTES) return require_ModerationContextLimits.API_ERROR_CODES.MULTIPART_IMAGE_TOO_LARGE;
9646
+ if (isFinal && sizeInBytes < require_ModerationContextLimits.MIN_MULTIPART_IMAGE_UPLOAD_BYTES) return require_ModerationContextLimits.API_ERROR_CODES.MULTIPART_IMAGE_TOO_SMALL;
9647
+ }
9648
+ /** Like the API: the parts may not overshoot a declared total, and must reach it at the end. */
9649
+ multipartTotalSizeError(totalSizeInBytes, receivedSizeInBytes, isFinal) {
9650
+ if (totalSizeInBytes === void 0) return void 0;
9651
+ return receivedSizeInBytes > totalSizeInBytes || isFinal && receivedSizeInBytes !== totalSizeInBytes ? require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_SIZE_MISMATCH : void 0;
9652
+ }
9653
+ /**
9654
+ * Asks `moderate` how to decide `asset`, with its content: `bytes` when the caller has them,
9655
+ * otherwise the stored parts in order (none once the content is removed).
9656
+ */
9657
+ async decisionFor(asset, trigger, queued, bytes) {
9658
+ let data = bytes;
9659
+ if (data === void 0) {
9660
+ const stored = asset.content.status === "removed" ? void 0 : await this.imageStorage.getContent(asset.id);
9661
+ const parts = stored ? [...stored.parts].sort(([a], [b]) => a - b).map(([, part]) => part) : [];
9662
+ data = stored ? new Uint8Array(await new Blob(parts).arrayBuffer()) : null;
9195
9663
  }
9196
- return this.databaseStorage.createAsset(isOpenUpload ? asset : await this.applyOutcome(asset, queue ? this.queuedOutcome : this.analyzedOutcome));
9664
+ const content = {
9665
+ bytes: data,
9666
+ text: data && asset.type === "plaintext-document" ? new TextDecoder().decode(data) : null
9667
+ };
9668
+ return toDecision(await this.options.moderate({
9669
+ asset: structuredClone(asset),
9670
+ content,
9671
+ trigger,
9672
+ queued
9673
+ }));
9674
+ }
9675
+ /** `asset` decided as `decision` says, with its report. */
9676
+ decided(asset, decision) {
9677
+ return this.applyOutcome(asset, decision.status, "policy", decision.report());
9678
+ }
9679
+ /**
9680
+ * Stores a processing asset's `decision` and records its events; a `pending` decision leaves it
9681
+ * processing.
9682
+ */
9683
+ async settle(asset, decision) {
9684
+ if (decision.status === "pending") return asset;
9685
+ const stored = await this.databaseStorage.updateAsset(await this.decided(asset, decision));
9686
+ this.recordProcessingEvents(stored, decision);
9687
+ return stored;
9688
+ }
9689
+ /** Asks `moderate` (unless `decision` is given) and settles a processing asset. */
9690
+ async moderateStored(asset, trigger, decision) {
9691
+ return this.settle(asset, decision ?? await this.decisionFor(asset, trigger, true));
9692
+ }
9693
+ /** `asset.uploaded`, as the API records it when an upload completes (UploadAssetUtils.ts). */
9694
+ recordUploaded(asset) {
9695
+ this.pushEvent({
9696
+ type: "asset.uploaded",
9697
+ severity: "low",
9698
+ assetId: asset.id,
9699
+ asset: toEventAsset(asset),
9700
+ facts: [
9701
+ {
9702
+ key: "asset-type",
9703
+ value: asset.type
9704
+ },
9705
+ assetNameFact(asset),
9706
+ {
9707
+ key: "file-format",
9708
+ value: asset.content.format ?? null
9709
+ }
9710
+ ]
9711
+ });
9197
9712
  }
9198
9713
  /**
9199
- * Moves an asset to `outcome` like the pipeline would: a decision finishes processing, and
9200
- * finished processing removes content kept only temporarily (`keepContentAfterProcessing` off).
9714
+ * The events the pipeline records when it settles an asset (AssetClassification.ts and
9715
+ * AssetErrorAlert.ts): `asset.classified` and the automated `asset.<status>`, or
9716
+ * `asset.failed`.
9201
9717
  */
9202
- async applyOutcome(asset, outcome) {
9718
+ recordProcessingEvents(asset, decision) {
9719
+ if (decision.status === "pending") return;
9720
+ const event = {
9721
+ assetId: asset.id,
9722
+ asset: toEventAsset(asset)
9723
+ };
9724
+ if (decision.status === "failed") {
9725
+ this.pushEvent({
9726
+ ...event,
9727
+ type: "asset.failed",
9728
+ severity: "high",
9729
+ facts: [
9730
+ assetNameFact(asset),
9731
+ {
9732
+ key: "asset-type",
9733
+ value: asset.type
9734
+ },
9735
+ {
9736
+ key: "previous-status",
9737
+ value: "processing"
9738
+ },
9739
+ {
9740
+ key: "failure-reason",
9741
+ value: asset.classification.failureReason ?? "processing-error"
9742
+ }
9743
+ ]
9744
+ });
9745
+ return;
9746
+ }
9747
+ const flaggedCategories = decision.report()?.flaggedCategories ?? [];
9748
+ this.pushEvent({
9749
+ ...event,
9750
+ type: "asset.classified",
9751
+ severity: "low",
9752
+ facts: [
9753
+ {
9754
+ key: "flagged-categories",
9755
+ value: flaggedCategories
9756
+ },
9757
+ {
9758
+ key: "moderation-status",
9759
+ value: decision.status
9760
+ },
9761
+ {
9762
+ key: "asset-type",
9763
+ value: asset.type
9764
+ },
9765
+ assetNameFact(asset)
9766
+ ]
9767
+ });
9768
+ this.pushEvent({
9769
+ ...event,
9770
+ type: `asset.${decision.status}`,
9771
+ severity: decision.status === "allowed" || decision.status === "limited" ? "low" : "high",
9772
+ facts: [
9773
+ {
9774
+ key: "decided-by",
9775
+ value: "automated"
9776
+ },
9777
+ {
9778
+ key: "flagged-categories",
9779
+ value: flaggedCategories
9780
+ },
9781
+ {
9782
+ key: "asset-type",
9783
+ value: asset.type
9784
+ },
9785
+ assetNameFact(asset)
9786
+ ]
9787
+ });
9788
+ }
9789
+ /**
9790
+ * Moves an asset to `outcome` like the pipeline would: a decision finishes processing, `purged`
9791
+ * deletes the content (with `purgeReason`, `policy` for the pipeline), and finished processing
9792
+ * removes content kept only temporarily (`keepContentAfterProcessing` off). A classified image
9793
+ * gets a ThumbHash for its placeholder, which goes with purged content.
9794
+ */
9795
+ async applyOutcome(asset, outcome, purgeReason = "policy", report) {
9203
9796
  const now = this.now();
9204
- const decided = outcome === "approved" || outcome === "rejected";
9205
- const processingStatus = decided ? "done" : outcome === "failed" ? "failed" : "processing";
9206
- const removeContent = processingStatus === "done" && asset.content.status === "temporary";
9797
+ const decided = outcome !== "pending" && outcome !== "failed";
9798
+ const classificationStatus = decided ? "completed" : outcome === "failed" ? "failed" : "processing";
9799
+ const purge = outcome === "purged" && asset.content.status !== "removed";
9800
+ const removeContent = purge || classificationStatus === "completed" && asset.content.status === "temporary";
9207
9801
  if (removeContent) await this.imageStorage.deleteContent(asset.id);
9802
+ const image = decided && asset.type === "image" && asset.content.status !== "removed" && !purge ? {
9803
+ width: null,
9804
+ height: null,
9805
+ perceptualHash: null,
9806
+ perceptualHashQuality: null,
9807
+ ...asset.content.image,
9808
+ thumbhash: MOCK_THUMBHASH,
9809
+ averageColor: asset.content.image?.averageColor ?? "#7f7f7f"
9810
+ } : outcome === "purged" ? withoutPlaceholder(asset.content.image) : asset.content.image;
9208
9811
  return {
9209
9812
  ...asset,
9210
9813
  updatedAt: now,
9211
- processing: {
9212
- ...asset.processing,
9213
- status: processingStatus,
9214
- updatedAt: now
9215
- },
9216
- moderation: {
9217
- ...asset.moderation,
9218
- status: decided ? outcome : "pending",
9219
- decidedAt: decided ? now : null
9220
- },
9814
+ ...removeContent && { context: null },
9221
9815
  content: removeContent ? {
9222
9816
  ...asset.content,
9223
9817
  status: "removed",
9224
- removalReason: "retention",
9818
+ removalReason: purge ? purgeReason : "retention",
9225
9819
  updatedAt: now,
9226
- extractedText: null
9227
- } : asset.content
9820
+ extractedText: null,
9821
+ fileMetadata: null,
9822
+ image: withoutPlaceholder(image)
9823
+ } : {
9824
+ ...asset.content,
9825
+ image
9826
+ },
9827
+ upload: {
9828
+ ...asset.upload,
9829
+ status: "completed",
9830
+ updatedAt: now
9831
+ },
9832
+ classification: {
9833
+ ...asset.classification,
9834
+ status: classificationStatus,
9835
+ failureReason: classificationStatus === "failed" ? "processing-error" : null,
9836
+ updatedAt: now,
9837
+ completedAt: decided ? now : null,
9838
+ moderation: {
9839
+ ...asset.classification.moderation,
9840
+ status: decided ? outcome : "pending",
9841
+ ...report
9842
+ }
9843
+ }
9228
9844
  };
9229
9845
  }
9846
+ /** Adds an `asset.deleted`, `asset.content-removed` or `asset.retried` event, like the API. */
9847
+ recordLifecycleEvent(type, asset) {
9848
+ this.pushEvent({
9849
+ type,
9850
+ severity: "low",
9851
+ assetId: asset.id,
9852
+ asset: toEventAsset(asset),
9853
+ facts: [{
9854
+ key: "asset-name",
9855
+ value: asset.name,
9856
+ ...type === "asset.deleted" ? {} : { url: `https://calmlens.com/go/asset/${asset.id}` }
9857
+ }, {
9858
+ key: "asset-type",
9859
+ value: asset.type
9860
+ }]
9861
+ });
9862
+ }
9863
+ /**
9864
+ * `/upload-presets/:id/assets[/:assetId[/part]]`: uploads through the preset, with its rules
9865
+ * applied in the API's order (token, content type, size), and checks of its uploads.
9866
+ */
9867
+ async presetRoute(request, uploadPresetId, rest, session) {
9868
+ if (!session) throw fail(require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_PRESET_NOT_FOUND);
9869
+ const { preset } = session;
9870
+ const toUploaded = (asset) => ({
9871
+ id: asset.id,
9872
+ name: asset.name,
9873
+ uploadPresetId,
9874
+ createdAt: asset.createdAt,
9875
+ ...preset.showDecisions !== false && { status: asset.classification.moderation.status }
9876
+ });
9877
+ const [assetId, action] = rest;
9878
+ if (assetId !== void 0) {
9879
+ if (!session.uploads.has(assetId)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.ASSET_NOT_FOUND);
9880
+ const asset = await this.findOwnAsset(assetId);
9881
+ if (request.method === "POST" && action === "part") return toUploaded(await this.uploadPart(assetId, request));
9882
+ if (request.method === "GET" && action === void 0) return toUploaded(asset);
9883
+ throw new ApiFailure(404, "not-found", "No such route");
9884
+ }
9885
+ if (request.method !== "POST") throw new ApiFailure(404, "not-found", "No such route");
9886
+ const fields = this.uploadFields(request);
9887
+ const token = request.headers.get("authorization");
9888
+ if (preset.requireToken && !token) throw fail(require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_PRESET_TOKEN_REQUIRED);
9889
+ if (preset.allowedContentTypes && !require_UploadController.isFileAccepted({
9890
+ name: fields.name,
9891
+ type: fields.contentType ?? ""
9892
+ }, preset.allowedContentTypes.join(","))) throw fail(require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED);
9893
+ const size = fields.part?.totalSizeInBytes ?? fields.bytes?.byteLength;
9894
+ if (preset.maxFileSizeInBytes !== void 0 && size !== void 0 && size > preset.maxFileSizeInBytes) throw fail(require_ModerationContextLimits.API_ERROR_CODES.UPLOAD_PRESET_TOO_LARGE);
9895
+ const asset = await this.storeAsset({
9896
+ ...fields,
9897
+ uploadPresetId,
9898
+ visibility: preset.visibility ?? "public",
9899
+ folderId: preset.folderId ?? void 0,
9900
+ processingOptions: { keepContentAfterProcessing: preset.keepContentAfterProcessing ?? true }
9901
+ }, request.query.get("queue") === "true");
9902
+ session.uploads.add(asset.id);
9903
+ return toUploaded(asset);
9904
+ }
9905
+ /**
9906
+ * Reads the feed like the API: without `cursor`, no events and a `nextCursor` for "now"; with
9907
+ * `cursor`, up to `pageSize` events added since, oldest first. `nextCursor` is never null: it
9908
+ * is where to keep polling from.
9909
+ */
9910
+ listEvents(query) {
9911
+ const pageSize = this.integer(query, "pageSize", 50);
9912
+ if (pageSize < 1) throw invalidField("validation/too-small", "pageSize");
9913
+ if (pageSize > MAX_PAGE_SIZE) throw invalidField("validation/too-big", "pageSize");
9914
+ const cursor = query.get("cursor");
9915
+ if (!cursor) return {
9916
+ items: [],
9917
+ nextCursor: `mock_${this.eventFeed.length}`,
9918
+ hasMore: false
9919
+ };
9920
+ const match = /^mock_(\d+)$/.exec(cursor);
9921
+ const position = match ? Number(match[1]) : NaN;
9922
+ if (!Number.isSafeInteger(position) || position > this.eventFeed.length) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_CURSOR);
9923
+ const items = this.eventFeed.slice(position, position + pageSize);
9924
+ const next = position + items.length;
9925
+ return {
9926
+ items,
9927
+ nextCursor: `mock_${next}`,
9928
+ hasMore: next < this.eventFeed.length
9929
+ };
9930
+ }
9931
+ /**
9932
+ * Lists folders like the API: one keyset page of a parent's children, optionally matching
9933
+ * `search`, by name unless `ordering` says otherwise, with the folders above them.
9934
+ */
9935
+ async listFolders(query) {
9936
+ const all = await this.databaseStorage.listFolders?.(this.projectId) ?? [];
9937
+ const parentId = query.get("parentId");
9938
+ const search = query.get("search")?.trim() || null;
9939
+ const ordering = this.folderOrdering(query);
9940
+ const matching = all.filter((folder) => (folder.parentId ?? null) === parentId && (!search || folder.name.toLowerCase().includes(search.toLowerCase())));
9941
+ const page = createPage(matching, {
9942
+ cursor: query.get("cursor") ?? void 0,
9943
+ pageSize: this.integer(query, "pageSize", 50),
9944
+ ordering
9945
+ }, {
9946
+ scope: JSON.stringify([
9947
+ this.projectId,
9948
+ parentId,
9949
+ search
9950
+ ]),
9951
+ fail: this.pageFailure,
9952
+ defaultOrdering: [{
9953
+ field: "name",
9954
+ direction: "asc"
9955
+ }]
9956
+ });
9957
+ const folders = page.items;
9958
+ const ids = /* @__PURE__ */ new Set([...parentId ? [parentId] : [], ...folders.map((folder) => folder.id)]);
9959
+ const byId = new Map(all.map((folder) => [folder.id, folder]));
9960
+ for (const id of ids) {
9961
+ const parent = byId.get(id)?.parentId;
9962
+ if (parent) ids.add(parent);
9963
+ }
9964
+ return {
9965
+ items: folders,
9966
+ ancestors: all.filter((folder) => ids.has(folder.id) && !folders.some((item) => item.id === folder.id)),
9967
+ nextCursor: page.nextCursor,
9968
+ pageSize: page.pageSize,
9969
+ hasMore: page.hasMore,
9970
+ ordering: page.ordering
9971
+ };
9972
+ }
9973
+ /** The folder list's `ordering`: at most one rule, by `name` or `createdAt`. */
9974
+ folderOrdering(query) {
9975
+ const ordering = this.ordering(query);
9976
+ if (ordering.length > 1) throw invalidField("validation/too-big", "ordering");
9977
+ for (const [index, rule] of ordering.entries()) {
9978
+ if (rule.field !== "name" && rule.field !== "createdAt") throw invalidField("validation/invalid-value", `ordering.${index}.field`);
9979
+ if (rule.direction !== "asc" && rule.direction !== "desc") throw invalidField("validation/invalid-value", `ordering.${index}.direction`);
9980
+ }
9981
+ return ordering;
9982
+ }
9983
+ async createFolder(body) {
9984
+ const name = body.name;
9985
+ if (typeof name !== "string" || !name) throw fail(require_ModerationContextLimits.API_ERROR_CODES.INVALID_UPLOAD_PAYLOAD, { message: "Folder name is required" });
9986
+ const parentId = body.parentId ?? null;
9987
+ if (parentId) await this.findOwnFolder(parentId);
9988
+ await this.assertUniqueFolderName(name, parentId);
9989
+ const now = this.now();
9990
+ const folder = {
9991
+ id: this.createId("fldr"),
9992
+ projectId: this.projectId,
9993
+ parentId,
9994
+ name,
9995
+ createdAt: now,
9996
+ updatedAt: now
9997
+ };
9998
+ return this.databaseStorage.createFolder ? await this.databaseStorage.createFolder(folder) : folder;
9999
+ }
10000
+ async updateFolder(folderId, body) {
10001
+ const folder = await this.findOwnFolder(folderId);
10002
+ const name = body.name;
10003
+ if (name !== void 0 && name !== folder.name) await this.assertUniqueFolderName(name, folder.parentId ?? null, folderId);
10004
+ const updated = {
10005
+ ...folder,
10006
+ ...name !== void 0 ? { name } : {},
10007
+ updatedAt: this.now()
10008
+ };
10009
+ return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
10010
+ }
10011
+ async deleteFolder(folderId) {
10012
+ await this.findOwnFolder(folderId);
10013
+ const hasAssets = (await this.databaseStorage.listAssets(this.projectId)).some((asset) => asset.folderId === folderId);
10014
+ const hasChildren = (await this.databaseStorage.listFolders?.(this.projectId) ?? []).some((folder) => folder.parentId === folderId);
10015
+ if (hasAssets || hasChildren) throw fail(require_ModerationContextLimits.API_ERROR_CODES.FOLDER_NOT_EMPTY, { details: {
10016
+ hasAssets,
10017
+ hasChildren
10018
+ } });
10019
+ await this.databaseStorage.deleteFolder?.(folderId);
10020
+ }
10021
+ async moveFolder(folderId, body) {
10022
+ const folder = await this.findOwnFolder(folderId);
10023
+ const targetParentId = body.parentId ?? null;
10024
+ if (targetParentId) {
10025
+ let candidate = await this.findOwnFolder(targetParentId);
10026
+ const visited = /* @__PURE__ */ new Set();
10027
+ while (candidate) {
10028
+ if (candidate.id === folderId || visited.has(candidate.id)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.FOLDER_PARENT_CYCLE);
10029
+ visited.add(candidate.id);
10030
+ candidate = candidate.parentId ? await this.resolveFolder(candidate.parentId) : void 0;
10031
+ }
10032
+ }
10033
+ if (targetParentId !== folder.parentId) await this.assertUniqueFolderName(folder.name, targetParentId, folderId);
10034
+ const updated = {
10035
+ ...folder,
10036
+ parentId: targetParentId,
10037
+ updatedAt: this.now()
10038
+ };
10039
+ return this.databaseStorage.updateFolder ? await this.databaseStorage.updateFolder(updated) : updated;
10040
+ }
9230
10041
  async resolveFolder(folderId) {
9231
- if (!this.databaseStorage.getFolder) return void 0;
9232
- const folder = await this.databaseStorage.getFolder(folderId);
9233
- if (!folder || folder.projectId !== this.projectId) return;
9234
- return folder;
10042
+ const folder = await this.databaseStorage.getFolder?.(folderId);
10043
+ return folder && folder.projectId === this.projectId ? folder : void 0;
9235
10044
  }
9236
10045
  async findOwnFolder(folderId) {
9237
- require_CalmLensUtils.encodePathSegment(folderId, "Folder ID");
9238
10046
  const folder = await this.resolveFolder(folderId);
9239
- if (!folder) throw this.apiError(require_CalmLensVersion.API_ERROR_CODES.FOLDER_NOT_FOUND);
10047
+ if (!folder) throw fail(require_ModerationContextLimits.API_ERROR_CODES.FOLDER_NOT_FOUND);
9240
10048
  return folder;
9241
10049
  }
9242
10050
  async assertUniqueFolderName(name, parentId, excludeFolderId) {
9243
- 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(require_CalmLensVersion.API_ERROR_CODES.DUPLICATE_FOLDER_NAME, { message: `A folder named "${name}" already exists in this location.` });
10051
+ if ((await this.databaseStorage.listFolders?.(this.projectId) ?? []).find((folder) => folder.id !== excludeFolderId && (folder.parentId ?? null) === parentId && folder.name === name)) throw fail(require_ModerationContextLimits.API_ERROR_CODES.DUPLICATE_FOLDER_NAME, { message: `A folder named "${name}" already exists in this location.` });
9244
10052
  }
9245
- getContentFormat(options) {
9246
- if ("content" in options) return options.content.type;
9247
- if ("part" in options) return options.part.type;
9248
- return null;
10053
+ /** Saves each lens in order, like the API: new ones, or changes with a greater version. */
10054
+ ensureLenses(body) {
10055
+ const lenses = Array.isArray(body.lenses) ? body.lenses : [];
10056
+ if (lenses.length === 0) throw invalidField("validation/too-small", "lenses");
10057
+ return { lenses: lenses.map((lens) => this.ensureLens(lens)) };
9249
10058
  }
9250
- getSizeInBytes(options) {
9251
- if ("content" in options) {
9252
- if ("plainText" in options.content) return new TextEncoder().encode(options.content.plainText).byteLength;
9253
- return require_CalmLensUtils.getAssetPayloadByteLength(options.content);
10059
+ ensureLens(options) {
10060
+ const existing = this.lenses.get(options.identifier);
10061
+ if (!existing) {
10062
+ const now = this.now();
10063
+ const lens = {
10064
+ id: this.createId("lens"),
10065
+ projectId: this.projectId,
10066
+ ...options,
10067
+ createdAt: now,
10068
+ updatedAt: now
10069
+ };
10070
+ this.lenses.set(lens.identifier, lens);
10071
+ return lens;
10072
+ }
10073
+ if (existing.name === options.name && (existing.description ?? null) === (options.description ?? null) && existing.type === options.type && existing.active === options.active && require_CalmLensServerClient.stringifyJsonCanonical(existing.transforms ?? null) === require_CalmLensServerClient.stringifyJsonCanonical(options.transforms ?? null) && existing.version === options.version) return existing;
10074
+ if (options.version > existing.version) {
10075
+ const lens = {
10076
+ ...existing,
10077
+ ...options,
10078
+ updatedAt: this.now()
10079
+ };
10080
+ this.lenses.set(lens.identifier, lens);
10081
+ return lens;
10082
+ }
10083
+ throw fail(require_ModerationContextLimits.API_ERROR_CODES.LENS_VERSION_CONFLICT, { details: {
10084
+ currentVersion: existing.version,
10085
+ minimumVersion: existing.version + 1,
10086
+ requestedVersion: options.version
10087
+ } });
10088
+ }
10089
+ /**
10090
+ * Serves stored content like the CDN (`/assets/:projectId/:asset/:resource`): `200` with
10091
+ * `Content-Type`, `Content-Length`, `Cache-Control`, `Cache-Tag`, the CDN's protection headers
10092
+ * and, for images, `ETag` and `Expires`; or a JSON error body with `404`/`403`. A
10093
+ * `placeholder` is the asset's ThumbHash as a PNG.
10094
+ */
10095
+ async serveCdn(request) {
10096
+ const { url, segments, query } = request;
10097
+ const [root, projectId, assetIdentifier, resource] = segments;
10098
+ const notFound = () => this.jsonResponse(404, {
10099
+ code: "asset/not-found",
10100
+ message: "Asset not found"
10101
+ }, url);
10102
+ if (root !== "assets" || projectId !== this.projectId || !assetIdentifier) return notFound();
10103
+ const asset = (await this.databaseStorage.listAssets(this.projectId)).find((asset) => asset.id === assetIdentifier || asset.externalId === assetIdentifier);
10104
+ if (!asset) return notFound();
10105
+ const notAvailable = () => this.jsonResponse(403, {
10106
+ code: "asset/not-available",
10107
+ message: "Asset is not available"
10108
+ }, url);
10109
+ const grants = request.headers.get("x-api-key") === this.apiKey ? this.grants : [];
10110
+ const folders = await this.databaseStorage.listFolders?.(this.projectId) ?? [];
10111
+ const canDownload = () => permits(grants, "assets:download", asset.folderId ?? null, folders);
10112
+ const status = asset.classification.moderation.status;
10113
+ const isHead = request.method === "HEAD";
10114
+ if (resource === "placeholder") {
10115
+ const thumbhash = asset.content.image?.thumbhash;
10116
+ if (!thumbhash || status === "purged") return notAvailable();
10117
+ const png = Uint8Array.from(atob(require_UploadController.placeholderHashToDataURL(thumbhash).split(",", 2)[1]), (char) => char.charCodeAt(0));
10118
+ return this.cdnResponse(isHead, png, url, {
10119
+ "Content-Type": "image/png",
10120
+ "Cache-Control": "public, max-age=3600",
10121
+ "Cache-Tag": `asset-${asset.id}`,
10122
+ ETag: `"${thumbhash}"`
10123
+ });
10124
+ }
10125
+ const storedContent = await this.imageStorage.getContent(asset.id);
10126
+ if (asset.upload.status === "uploading" || status === "purged" || status === "blocked" || asset.classification.status === "failed" || asset.content.status === "removed" || !storedContent) return notAvailable();
10127
+ let claims = {};
10128
+ try {
10129
+ claims = JSON.parse(query.get("viewerClaims") ?? request.headers.get("X-CalmLens-Viewer-Claims") ?? "{}");
10130
+ } catch {
10131
+ return this.jsonResponse(400, { code: "viewer/invalid-claims" }, url);
10132
+ }
10133
+ if (status === "age-restricted" && claims.ageConfirmed !== true) return this.jsonResponse(403, { code: "viewer/age-confirmation-required" }, url);
10134
+ const isPrivate = asset.visibility !== "public";
10135
+ if (isPrivate && !canDownload()) return this.jsonResponse(403, {
10136
+ code: "asset/api-key-missing-permission",
10137
+ message: "This API key needs assets:download permission for this asset’s folder."
10138
+ }, url);
10139
+ const parts = [...storedContent.parts.entries()].sort(([left], [right]) => left - right).map(([, value]) => value);
10140
+ const bytes = new Uint8Array(parts.reduce((sum, value) => sum + value.byteLength, 0));
10141
+ let offset = 0;
10142
+ for (const part of parts) {
10143
+ bytes.set(part, offset);
10144
+ offset += part.byteLength;
9254
10145
  }
9255
- if ("part" in options) return options.part.sizeInBytes ?? options.sizeInBytes ?? require_CalmLensUtils.getAssetPayloadByteLength(options.part);
10146
+ const contentType = storedContent.type ?? "application/octet-stream";
10147
+ const isImage = asset.type === "image";
10148
+ return this.cdnResponse(isHead, bytes, url, {
10149
+ "Content-Type": contentType,
10150
+ "Cache-Control": Object.keys(claims).length ? "private, no-store" : `${isPrivate ? "private" : "public"}, ${isImage ? "max-age=31536000, immutable" : "max-age=3600"}`,
10151
+ "Cache-Tag": `asset-${asset.id}`,
10152
+ ...isImage ? {
10153
+ ETag: `"${asset.id}"`,
10154
+ Expires: new Date(this.now() + 31536e6).toUTCString()
10155
+ } : {},
10156
+ "X-Content-Type-Options": "nosniff",
10157
+ "Content-Security-Policy": "sandbox; default-src 'none'",
10158
+ ...isActiveContentType(contentType) ? { "Content-Disposition": "attachment" } : {}
10159
+ });
10160
+ }
10161
+ cdnResponse(isHead, bytes, url, headers) {
10162
+ return this.response(200, isHead ? null : bytes, url, {
10163
+ ...headers,
10164
+ "Content-Length": String(bytes.byteLength),
10165
+ "X-Mock-CalmLens-CDN-URL": url
10166
+ });
9256
10167
  }
9257
10168
  };
9258
- //#endregion
9259
- //#region src/testing/MockWebhooks.ts
9260
- /** The JSON body of a webhook delivery for a stored event (or an event from `events.list`). */
10169
+ // src/testing/MockStorage.ts
10170
+ var InMemoryMockImageStorage = class {
10171
+ constructor() {
10172
+ this.content = /* @__PURE__ */ new Map();
10173
+ }
10174
+ async putContent(assetId, content) {
10175
+ const validation = require_CalmLensConnection.validateAssetPayload(content);
10176
+ if (!validation.ok) throw new Error(validation.error);
10177
+ const bytes = await require_CalmLensConnection.assetDataToUint8Array(require_CalmLensConnection.assetToData(content));
10178
+ this.content.set(assetId, {
10179
+ assetId,
10180
+ parts: /* @__PURE__ */ new Map([[1, bytes]]),
10181
+ type: content.type
10182
+ });
10183
+ }
10184
+ async putPart(assetId, part) {
10185
+ const validation = require_CalmLensConnection.validateAssetPayload(part, {
10186
+ multipart: true,
10187
+ isFinal: part.isFinal
10188
+ });
10189
+ if (!validation.ok) throw new Error(validation.error);
10190
+ const bytes = await require_CalmLensConnection.assetDataToUint8Array(require_CalmLensConnection.assetToData(part));
10191
+ const existing = this.content.get(assetId) ?? {
10192
+ assetId,
10193
+ parts: /* @__PURE__ */ new Map(),
10194
+ type: part.type
10195
+ };
10196
+ existing.parts.set(part.partNumber ?? 1, bytes);
10197
+ existing.type = part.type ?? existing.type;
10198
+ this.content.set(assetId, existing);
10199
+ }
10200
+ async getContent(assetId) {
10201
+ return this.content.get(assetId);
10202
+ }
10203
+ async deleteContent(assetId) {
10204
+ this.content.delete(assetId);
10205
+ }
10206
+ };
10207
+ var InMemoryMockDatabaseStorage = class {
10208
+ constructor() {
10209
+ this.assets = /* @__PURE__ */ new Map();
10210
+ this.folders = /* @__PURE__ */ new Map();
10211
+ }
10212
+ async createAsset(asset) {
10213
+ this.assets.set(asset.id, asset);
10214
+ return asset;
10215
+ }
10216
+ async updateAsset(asset) {
10217
+ this.assets.set(asset.id, asset);
10218
+ return asset;
10219
+ }
10220
+ async getAsset(assetId) {
10221
+ return this.assets.get(assetId);
10222
+ }
10223
+ async listAssets(projectId) {
10224
+ return [...this.assets.values()].filter((asset) => asset.projectId === projectId);
10225
+ }
10226
+ async deleteAsset(assetId) {
10227
+ this.assets.delete(assetId);
10228
+ }
10229
+ async createFolder(folder) {
10230
+ this.folders.set(folder.id, folder);
10231
+ return folder;
10232
+ }
10233
+ async updateFolder(folder) {
10234
+ this.folders.set(folder.id, folder);
10235
+ return folder;
10236
+ }
10237
+ async getFolder(folderId) {
10238
+ return this.folders.get(folderId);
10239
+ }
10240
+ async listFolders(projectId) {
10241
+ return [...this.folders.values()].filter((folder) => folder.projectId === projectId).sort((a, b) => a.name.localeCompare(b.name));
10242
+ }
10243
+ async deleteFolder(folderId) {
10244
+ this.folders.delete(folderId);
10245
+ }
10246
+ };
10247
+ // src/testing/MockCalmLensServerClient.ts
10248
+ const apis = /* @__PURE__ */ new WeakMap();
10249
+ /** The in-memory API a mock server client runs against, for `MockCalmLensAppClient`. */
10250
+ const getMockApi = (client) => {
10251
+ const api = apis.get(client);
10252
+ if (!api) throw require_ModerationContextLimits.invalidConfig("mockOptions.server must be a MockCalmLensServerClient");
10253
+ return api;
10254
+ };
10255
+ /**
10256
+ * Options that make a real client send its requests to `api`: waits only move the mock's clock,
10257
+ * multipart size checks are the in-memory API's, and nothing is retried unless the options say so.
10258
+ */
10259
+ const mockClientOptions = (api, endpoints, session) => {
10260
+ const fetch = api.createFetch(endpoints, session);
10261
+ const hooks = {
10262
+ sleep: async (ms, signal) => {
10263
+ await require_CalmLensConnection.sleep(0, signal);
10264
+ api.waitedMs += ms;
10265
+ },
10266
+ now: () => api.waitedMs,
10267
+ uploadTokenTime: () => ({
10268
+ now: api.now(),
10269
+ nonce: require_ModerationContextLimits.randomUuid()
10270
+ }),
10271
+ deferUploadChecks: true,
10272
+ defaultCdnBaseUrl: "mock://calmlens/v1"
10273
+ };
10274
+ return {
10275
+ fetch,
10276
+ uploadFetch: fetch,
10277
+ cdnFetch: fetch,
10278
+ [require_CalmLensConnection.MOCK_HOOKS]: hooks
10279
+ };
10280
+ };
10281
+ /**
10282
+ * The real `CalmLensServerClient`, answered by an in-memory CalmLens API and CDN instead of the
10283
+ * network: every method runs the real client's code, and the in-memory API answers its requests
10284
+ * with the API's statuses, bodies, error bodies and headers. Mock helpers decide assets, add
10285
+ * events, change the API key's grants, fail requests and list the requests sent.
10286
+ */
10287
+ var MockCalmLensServerClient = class extends require_CalmLensServerClient.CalmLensServerClient {
10288
+ #api;
10289
+ #projectId;
10290
+ /**
10291
+ * Accepts the same options as `CalmLensServerClient` (validated the same way), plus
10292
+ * `mockOptions`. `maxRetries` defaults to 0, so a failed request fails once; retry backoff and
10293
+ * `assets.waitForDecision` take no real time.
10294
+ */
10295
+ constructor(options) {
10296
+ require_ModerationContextLimits.validateServerClientOptions(options);
10297
+ const { mockOptions = {}, ...clientOptions } = options;
10298
+ const api = new MockCalmLensApi({
10299
+ projectId: options.projectId,
10300
+ apiKey: options.apiKey,
10301
+ grants: getMockApiKeyGrants(options.apiKey),
10302
+ databaseStorage: mockOptions.databaseStorage ?? new InMemoryMockDatabaseStorage(),
10303
+ imageStorage: mockOptions.imageStorage ?? new InMemoryMockImageStorage(),
10304
+ createId: mockOptions.createId ?? newId,
10305
+ now: mockOptions.now ?? createMonotonicClock(),
10306
+ moderate: mockOptions.moderate ?? defaultModerate,
10307
+ signedUrlBaseUrl: mockOptions.signedUrlBaseUrl ?? "mock://calmlens"
10308
+ });
10309
+ let endpoints;
10310
+ super({
10311
+ maxRetries: 0,
10312
+ ...clientOptions,
10313
+ dangerouslyAllowBrowser: true,
10314
+ ...mockClientOptions(api, () => endpoints)
10315
+ });
10316
+ const { baseUrl, cdnBaseUrl } = this.toJSON();
10317
+ endpoints = {
10318
+ baseUrl,
10319
+ cdnBaseUrl
10320
+ };
10321
+ this.#api = api;
10322
+ this.#projectId = options.projectId;
10323
+ this.databaseStorage = api.databaseStorage;
10324
+ this.imageStorage = api.imageStorage;
10325
+ apis.set(this, api);
10326
+ }
10327
+ /** Keeps the API key out of `console.log` and `util.inspect`. */
10328
+ [Symbol.for("nodejs.util.inspect.custom")]() {
10329
+ return `MockCalmLensServerClient { projectId: ${JSON.stringify(this.#projectId)} }`;
10330
+ }
10331
+ /**
10332
+ * Every request sent to the mock's API and CDN, oldest first, by this client and the
10333
+ * `MockCalmLensAppClient`s that use it, each with the client method that sent it.
10334
+ */
10335
+ get calls() {
10336
+ return this.#api.calls.map(({ call }) => call);
10337
+ }
10338
+ /**
10339
+ * Requests `operation` sends, oldest first. A request several methods send matches each of
10340
+ * them: `callsTo("text.classify")` also lists `assets.classify` text uploads, and
10341
+ * `callsTo("assets.waitForDecision")` the `assets.retrieve` requests it polls with.
10342
+ */
10343
+ callsTo(operation) {
10344
+ return this.#api.calls.filter(({ operations }) => operations.includes(operation)).map(({ call }) => call);
10345
+ }
10346
+ /** Forgets the requests recorded so far, so `calls` lists only later ones. */
10347
+ clearCalls() {
10348
+ this.#api.calls.length = 0;
10349
+ }
10350
+ /**
10351
+ * Makes the next request (or the next one `operation` sends) fail instead of reaching the
10352
+ * in-memory API. Each queued failure fails one request, so a retry takes the next queued
10353
+ * failure or reaches the API. An API error code answers with that error and its HTTP status,
10354
+ * with `details` and a `Retry-After` of `retryAfter` seconds when given.
10355
+ */
10356
+ failNextRequest(failure = "transport-error", options = {}) {
10357
+ this.#api.failNextRequest(failure, options);
10358
+ }
10359
+ /**
10360
+ * Changes the grants of the mock's API key, as editing the key in the portal would: later
10361
+ * requests, such as `cdn.serve`, use them. The key itself stays the same, so idempotency keys
10362
+ * it used still replay.
10363
+ */
10364
+ setApiKeyGrants(grants) {
10365
+ this.#api.grants = getMockApiKeyGrants(createMockApiKey(grants));
10366
+ }
10367
+ /**
10368
+ * Adds an event to the feed `events.list` reads, as if CalmLens had recorded it. Returns the
10369
+ * stored event; pass it to `toWebhookEventPayload` to build a matching webhook body.
10370
+ */
10371
+ enqueueEvent(input) {
10372
+ return this.#api.enqueueEvent(input);
10373
+ }
10374
+ /**
10375
+ * Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
10376
+ * status changes (`purged` also removes the content, with reason `requested` and an
10377
+ * `asset.content-removed` event), the review joins `manualReviews`, and an `asset.<decision>`
10378
+ * event with a `decided-by: reviewer` fact joins the events feed. Rejects with
10379
+ * `asset/not-found` for an unknown asset. It sends no request.
10380
+ */
10381
+ decide(assetId, decision) {
10382
+ return this.#api.decide(assetId, decision);
10383
+ }
10384
+ /**
10385
+ * Finishes processing a pending asset as the moderation pipeline would: with `decision`, or
10386
+ * else what `mockOptions.moderate` returns for the `complete` trigger (`allowed` by default).
10387
+ * The asset gets the decision's report, and `asset.classified` and the automated
10388
+ * `asset.<status>` (or `asset.failed`) join the events feed. No review is recorded, so the
10389
+ * asset can still be moderated again. Rejects with `asset/not-found` for an unknown asset and
10390
+ * `sdk/invalid-argument` for one that isn't processing. It sends no request.
10391
+ */
10392
+ completeProcessing(assetId, decision) {
10393
+ return this.#api.completeProcessing(assetId, decision);
10394
+ }
10395
+ };
10396
+ // src/testing/MockCalmLensAppClient.ts
10397
+ const MOCK_PROJECT_ID = "proj_0000000000000000c1a5";
10398
+ /**
10399
+ * The real `CalmLensAppClient`, answered by the in-memory API of a `MockCalmLensServerClient`.
10400
+ * The API applies `mockOptions.preset` to this client's uploads like CalmLens applies an upload
10401
+ * preset: its token, content type and size rules with the API's error codes, and its visibility,
10402
+ * folder, content retention and decision display. The client can check and continue only the
10403
+ * uploads it made.
10404
+ */
10405
+ var MockCalmLensAppClient = class extends require_CalmLensAppClient.CalmLensAppClient {
10406
+ #uploadPresetId;
10407
+ constructor(options) {
10408
+ require_ModerationContextLimits.validateAppClientOptions(options);
10409
+ const { mockOptions = {}, ...clientOptions } = options;
10410
+ const server = mockOptions.server ?? new MockCalmLensServerClient({
10411
+ apiKey: createMockApiKey([{
10412
+ actions: [
10413
+ "assets:create",
10414
+ "assets:read",
10415
+ "assets:update"
10416
+ ],
10417
+ folderId: null,
10418
+ includeSubfolders: true
10419
+ }]),
10420
+ projectId: MOCK_PROJECT_ID,
10421
+ mockOptions: { moderate: mockOptions.moderate }
10422
+ });
10423
+ let endpoints;
10424
+ const { cdnFetch: _cdnFetch, ...requestOptions } = mockClientOptions(getMockApi(server), () => endpoints, {
10425
+ preset: mockOptions.preset ?? {},
10426
+ uploads: /* @__PURE__ */ new Set()
10427
+ });
10428
+ super({
10429
+ maxRetries: 0,
10430
+ ...clientOptions,
10431
+ ...requestOptions
10432
+ });
10433
+ endpoints = {
10434
+ baseUrl: this.toJSON().baseUrl,
10435
+ cdnBaseUrl: server.toJSON().cdnBaseUrl
10436
+ };
10437
+ this.server = server;
10438
+ this.#uploadPresetId = options.uploadPresetId;
10439
+ }
10440
+ /** Keeps tokens out of `console.log` and `util.inspect`. */
10441
+ [Symbol.for("nodejs.util.inspect.custom")]() {
10442
+ return `MockCalmLensAppClient { uploadPresetId: ${JSON.stringify(this.#uploadPresetId)} }`;
10443
+ }
10444
+ };
10445
+ // src/testing/MockWebhooks.ts
10446
+ /**
10447
+ * The JSON body of a webhook delivery for an event, such as one from `events.list`: the event in
10448
+ * the same shape `events.list` returns it. Fields that are not part of that shape are left out.
10449
+ */
9261
10450
  const toWebhookEventPayload = (event) => ({
9262
10451
  id: event.id,
9263
- type: event.type,
9264
- createdAt: event.createdAt,
9265
10452
  projectId: event.projectId,
9266
- data: {
9267
- severity: event.severity,
9268
- title: event.title,
9269
- description: event.description ?? null,
9270
- assetId: event.assetId ?? null,
9271
- asset: event.asset ?? null,
9272
- facts: event.facts
9273
- }
10453
+ type: event.type,
10454
+ severity: event.severity,
10455
+ title: event.title,
10456
+ description: event.description ?? null,
10457
+ assetId: event.assetId ?? null,
10458
+ asset: event.asset ?? null,
10459
+ facts: event.facts,
10460
+ createdAt: event.createdAt
9274
10461
  });
9275
10462
  /** `v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`, the `x-calmlens-signature` header value. */
9276
- const createWebhookSignatureHeader = async (secret, timestamp, body) => `v1=${await require_CalmLensWebhooks.hmacSha256Hex(secret, `${timestamp}.${body}`)}`;
10463
+ const createWebhookSignatureHeader = async (secret, timestamp, body) => `v1=${await require_CryptoUtils_B_x8lMIx.hmacSha256Hex(secret, `${timestamp}.${body}`)}`;
9277
10464
  /** Headers for one signed delivery of `body`, as CalmLens sends them. */
9278
10465
  const createWebhookHeaders = async (options) => {
9279
10466
  const timestamp = options.timestampSeconds ?? Math.floor(Date.now() / 1e3);
9280
10467
  return {
9281
10468
  "content-type": "application/json",
9282
- [require_CalmLensWebhooks.WEBHOOK_DELIVERY_HEADER]: options.deliveryId,
9283
- [require_CalmLensWebhooks.WEBHOOK_TIMESTAMP_HEADER]: String(timestamp),
9284
- [require_CalmLensWebhooks.WEBHOOK_SIGNATURE_HEADER]: await createWebhookSignatureHeader(options.secret, timestamp, options.body)
10469
+ [require_CalmLensServerClient.WEBHOOK_DELIVERY_HEADER]: options.deliveryId,
10470
+ [require_CalmLensServerClient.WEBHOOK_TIMESTAMP_HEADER]: String(timestamp),
10471
+ [require_CalmLensServerClient.WEBHOOK_SIGNATURE_HEADER]: await createWebhookSignatureHeader(options.secret, timestamp, options.body)
9285
10472
  };
9286
10473
  };
9287
- //#endregion
9288
10474
  exports.InMemoryMockDatabaseStorage = InMemoryMockDatabaseStorage;
9289
10475
  exports.InMemoryMockImageStorage = InMemoryMockImageStorage;
9290
10476
  exports.MEDIA_TEST_SIGNALS = MEDIA_TEST_SIGNALS;
9291
10477
  exports.MOCK_API_KEY_PREFIX = MOCK_API_KEY_PREFIX;
10478
+ exports.MockCalmLensAppClient = MockCalmLensAppClient;
9292
10479
  exports.MockCalmLensServerClient = MockCalmLensServerClient;
10480
+ exports.MockDecision = MockDecision;
9293
10481
  exports.createMockApiKey = createMockApiKey;
9294
10482
  exports.createWebhookHeaders = createWebhookHeaders;
9295
10483
  exports.createWebhookSignatureHeader = createWebhookSignatureHeader;
9296
10484
  exports.generateMediaTestCode = generateMediaTestCode;
10485
+ exports.mockDecision = mockDecision;
10486
+ exports.signUploadToken = require_CalmLensServerClient.signUploadTokenWithApiKey;
9297
10487
  exports.toWebhookEventPayload = toWebhookEventPayload;