@purposeinplay/payload-ai-translate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,376 @@
1
+ import { translateDocument, translateGlobal } from '../api.js';
2
+ import { describeAuthRejection } from '../lib/auth-diagnostics.js';
3
+ import { collectBlocksConfig, extractTranslationUnits } from '../lib/content-extractor.js';
4
+ import { getEffectiveExcludePatternsForSurface, getExcludedFieldPaths, getGlobalKillSwitches, isPathExcluded, isSurfaceDisabledByAdmin } from '../lib/effective-locales.js';
5
+ import { resolveTranslatableFields } from '../lib/field-resolver.js';
6
+ import { findByIdNoFallback } from '../lib/payload-read.js';
7
+ import { createJob, getActiveJobForDoc } from '../lib/progress-store.js';
8
+ function resolveContext(arg, url) {
9
+ if (typeof arg === 'object' && arg !== null) return arg;
10
+ // String arg or undefined → derive from URL.
11
+ // Collection: /api/{slug}/ai-translate
12
+ // Global: /api/globals/{slug}/ai-translate
13
+ try {
14
+ const segments = new URL(url).pathname.split('/').filter(Boolean);
15
+ const apiIndex = segments.indexOf('api');
16
+ if (apiIndex >= 0) {
17
+ if (segments[apiIndex + 1] === 'globals' && segments[apiIndex + 2]) {
18
+ return {
19
+ kind: 'global',
20
+ slug: segments[apiIndex + 2]
21
+ };
22
+ }
23
+ if (segments[apiIndex + 1]) {
24
+ return {
25
+ kind: 'collection',
26
+ slug: arg ?? segments[apiIndex + 1]
27
+ };
28
+ }
29
+ }
30
+ } catch {
31
+ // fall through
32
+ }
33
+ return {
34
+ kind: 'collection',
35
+ slug: typeof arg === 'string' ? arg : ''
36
+ };
37
+ }
38
+ export const getTranslateHandler = (arg)=>async (req)=>{
39
+ try {
40
+ if (!req.user) {
41
+ return Response.json({
42
+ error: 'Unauthorized',
43
+ diagnostic: describeAuthRejection(req)
44
+ }, {
45
+ status: 401
46
+ });
47
+ }
48
+ const config = req.payload.config?.custom?.aiTranslate;
49
+ if (config.access?.translate) {
50
+ const allowed = await config.access.translate({
51
+ req
52
+ });
53
+ if (!allowed) {
54
+ return Response.json({
55
+ error: 'Forbidden'
56
+ }, {
57
+ status: 403
58
+ });
59
+ }
60
+ }
61
+ // Plugin-wide manual-translate kill switch (v1.2.8). When admin
62
+ // flipped `globalManualTranslateEnabled` off, every manual entry
63
+ // point — Translate dialog AND per-field button — is refused with
64
+ // a friendly 403. Auto and Bulk are unaffected. The dialog +
65
+ // button hide themselves in the client based on the same flag in
66
+ // the client-config response, but the server gate is the source
67
+ // of truth.
68
+ const globalKillSwitches = await getGlobalKillSwitches(req.payload, config);
69
+ if (!globalKillSwitches.manualEnabled) {
70
+ return Response.json({
71
+ error: 'Manual Translate is paused site-wide. An admin can re-enable it in Translation Settings.'
72
+ }, {
73
+ status: 403
74
+ });
75
+ }
76
+ // BUG-08 fix: honor the per-surface `enabled` kill-switch on manual
77
+ // translate too. Pre-fix this gate was automation-only; editors
78
+ // could manual-translate a surface the admin had explicitly turned
79
+ // off. Admins who want "manual yes, auto no" should use the sibling
80
+ // `autoOnPublish` flag instead, which remains automation-only.
81
+ const ctxForGate = resolveContext(arg, req.url ?? '');
82
+ const isDisabled = await isSurfaceDisabledByAdmin(req.payload, config, ctxForGate.slug);
83
+ if (isDisabled) {
84
+ return Response.json({
85
+ error: 'Translation is turned off for this content type. Ask an admin to enable it in the translation settings.'
86
+ }, {
87
+ status: 403
88
+ });
89
+ }
90
+ let body;
91
+ try {
92
+ body = typeof req.json === 'function' ? await req.json() : {};
93
+ } catch {
94
+ return Response.json({
95
+ error: 'Invalid JSON body'
96
+ }, {
97
+ status: 400
98
+ });
99
+ }
100
+ const { id, ids, targetLocales, targetPolicy, fields, async: asyncMode, draft: bodyDraft, writeMode, confirmCost } = body;
101
+ const draft = bodyDraft ?? true;
102
+ const ctx = resolveContext(arg, req.url ?? '');
103
+ // Validate caller-supplied target locales against the plugin's
104
+ // configured `targetLocales`. Without this, an invalid locale code
105
+ // (e.g. `"xx"` from a typo or a client that ships ahead of config)
106
+ // still reaches the LLM — the model guesses a language, charges
107
+ // tokens, and Payload silently drops the write because the locale
108
+ // isn't registered. Validate up front and 400 instead.
109
+ if (targetLocales && Array.isArray(targetLocales)) {
110
+ const allowed = new Set(config.targetLocales);
111
+ const invalid = targetLocales.filter((l)=>!allowed.has(l));
112
+ if (invalid.length > 0) {
113
+ return Response.json({
114
+ error: `Invalid target locale(s): ${invalid.map((l)=>`"${l}"`).join(', ')}. Configured targetLocales: ${config.targetLocales.map((l)=>`"${l}"`).join(', ')}`
115
+ }, {
116
+ status: 400
117
+ });
118
+ }
119
+ }
120
+ const effectiveLocales = targetLocales ?? config.targetLocales;
121
+ // Cost guard: pre-flight an estimate when the projected work is large
122
+ // (multi-doc batch, or many locales × one doc). Compares to the
123
+ // configured `bulkConfirmUsdThreshold` and returns 402 unless the
124
+ // caller acknowledges with `confirmCost: true`. Per-doc translation
125
+ // is bounded by `perDocCharCeiling` inside the API; this is the
126
+ // multi-doc / multi-locale fanout guard that was previously
127
+ // declared in config but never enforced.
128
+ if (!confirmCost && ctx.kind === 'collection' && config.provider.estimate) {
129
+ const isBulk = (ids?.length ?? 0) > 1;
130
+ const wideFanout = effectiveLocales.length > 3;
131
+ if (isBulk || wideFanout) {
132
+ try {
133
+ const estimate = await estimateRequestCost({
134
+ req,
135
+ config,
136
+ ctx,
137
+ ids: ids ?? (id !== undefined ? [
138
+ id
139
+ ] : []),
140
+ targetLocales: effectiveLocales,
141
+ draft
142
+ });
143
+ if (estimate.estimatedCostUsd !== undefined && estimate.estimatedCostUsd > config.costLimits.bulkConfirmUsdThreshold) {
144
+ return Response.json({
145
+ error: 'Estimated cost exceeds bulkConfirmUsdThreshold',
146
+ estimate: {
147
+ estimatedCostUsd: estimate.estimatedCostUsd,
148
+ documentCount: estimate.documentCount,
149
+ localeCount: effectiveLocales.length,
150
+ threshold: config.costLimits.bulkConfirmUsdThreshold
151
+ },
152
+ hint: 'Re-send with `confirmCost: true` to acknowledge and proceed.'
153
+ }, {
154
+ status: 402
155
+ });
156
+ }
157
+ } catch {
158
+ // Estimate failure shouldn't block translation — fall through
159
+ // to the normal path. The provider's per-call limits still
160
+ // apply as a backstop.
161
+ }
162
+ }
163
+ }
164
+ // Globals don't take id — they're singletons identified by slug.
165
+ if (ctx.kind === 'global') {
166
+ if (asyncMode) {
167
+ const jobId = createJob(ctx.slug, ctx.slug, effectiveLocales);
168
+ // Fire-and-forget. The progress endpoint's SSE stream pushes
169
+ // updates as `updateJob` ticks land.
170
+ void translateGlobal(req.payload, {
171
+ global: ctx.slug,
172
+ targetLocales,
173
+ targetPolicy,
174
+ fields,
175
+ req,
176
+ jobId,
177
+ writeMode
178
+ }).catch((err)=>{
179
+ req.payload.logger?.error?.(`[ai-translate] Background translateGlobal failed for ${ctx.slug}: ${err instanceof Error ? err.message : 'Unknown error'}`);
180
+ });
181
+ return Response.json({
182
+ results: [
183
+ {
184
+ jobId,
185
+ succeeded: [],
186
+ failed: []
187
+ }
188
+ ]
189
+ }, {
190
+ status: 202
191
+ });
192
+ }
193
+ const result = await translateGlobal(req.payload, {
194
+ global: ctx.slug,
195
+ targetLocales,
196
+ targetPolicy,
197
+ fields,
198
+ req,
199
+ writeMode
200
+ });
201
+ return Response.json({
202
+ results: [
203
+ result
204
+ ]
205
+ }, {
206
+ status: 200
207
+ });
208
+ }
209
+ if (!id && (!ids || ids.length === 0)) {
210
+ return Response.json({
211
+ error: 'Either "id" or "ids" must be provided'
212
+ }, {
213
+ status: 400
214
+ });
215
+ }
216
+ if (asyncMode && id) {
217
+ // Coalesce concurrent same-doc async requests: if another
218
+ // translation is already running for this (collection, id) pair,
219
+ // return its jobId instead of spawning a second LLM call. The
220
+ // client can subscribe to the existing job's SSE stream. This
221
+ // prevents the 2× cost from rapid duplicate clicks of a
222
+ // per-field translate button or two admins racing on the same
223
+ // doc.
224
+ const existing = getActiveJobForDoc(ctx.slug, id);
225
+ if (existing) {
226
+ return Response.json({
227
+ results: [
228
+ {
229
+ jobId: existing.jobId,
230
+ succeeded: [],
231
+ failed: [],
232
+ coalesced: true
233
+ }
234
+ ]
235
+ }, {
236
+ status: 202
237
+ });
238
+ }
239
+ const jobId = createJob(ctx.slug, id, effectiveLocales);
240
+ void translateDocument(req.payload, {
241
+ collection: ctx.slug,
242
+ id,
243
+ targetLocales,
244
+ targetPolicy,
245
+ fields,
246
+ req,
247
+ jobId,
248
+ draft,
249
+ writeMode
250
+ }).catch((err)=>{
251
+ req.payload.logger?.error?.(`[ai-translate] Background translateDocument failed for ${ctx.slug}/${String(id)}: ${err instanceof Error ? err.message : 'Unknown error'}`);
252
+ });
253
+ return Response.json({
254
+ results: [
255
+ {
256
+ jobId,
257
+ succeeded: [],
258
+ failed: []
259
+ }
260
+ ]
261
+ }, {
262
+ status: 202
263
+ });
264
+ }
265
+ let results;
266
+ if (id) {
267
+ const result = await translateDocument(req.payload, {
268
+ collection: ctx.slug,
269
+ id,
270
+ targetLocales,
271
+ targetPolicy,
272
+ fields,
273
+ req,
274
+ draft,
275
+ writeMode
276
+ });
277
+ results = [
278
+ result
279
+ ];
280
+ } else {
281
+ results = await Promise.all(ids.map((docId)=>translateDocument(req.payload, {
282
+ collection: ctx.slug,
283
+ id: docId,
284
+ targetLocales,
285
+ targetPolicy,
286
+ fields,
287
+ req,
288
+ draft,
289
+ writeMode
290
+ })));
291
+ }
292
+ return Response.json({
293
+ results
294
+ }, {
295
+ status: 200
296
+ });
297
+ } catch (error) {
298
+ const message = error instanceof Error ? error.message : 'Internal server error';
299
+ return Response.json({
300
+ error: message
301
+ }, {
302
+ status: 500
303
+ });
304
+ }
305
+ };
306
+ /**
307
+ * Pre-flight cost estimate used by the `bulkConfirmUsdThreshold` guard.
308
+ * Mirrors the `/ai-translate/estimate` endpoint's calculation but
309
+ * inlined to avoid the round-trip — the caller already has a valid
310
+ * authenticated request, the document IDs, and the configured provider.
311
+ *
312
+ * Returns `estimatedCostUsd: undefined` if the provider doesn't
313
+ * implement `estimate()`. Caller treats `undefined` as "no guard
314
+ * possible" and proceeds (the per-call / per-doc limits still apply).
315
+ */ async function estimateRequestCost(params) {
316
+ const { req, config, ctx, ids, targetLocales, draft } = params;
317
+ if (ctx.kind !== 'collection') return {
318
+ estimatedCostUsd: undefined,
319
+ documentCount: 0
320
+ };
321
+ const collections = req.payload.config?.collections ?? [];
322
+ const collectionConfig = collections.find((c)=>c.slug === ctx.slug);
323
+ if (!collectionConfig) return {
324
+ estimatedCostUsd: undefined,
325
+ documentCount: 0
326
+ };
327
+ const collectionFields = collectionConfig.fields;
328
+ let translatableFields = resolveTranslatableFields(collectionFields);
329
+ // Admin per-surface exclusions also apply to the internal cost
330
+ // estimate (bulkConfirmUsdThreshold guard) so the user doesn't get
331
+ // a confirmation prompt sized against fields that won't actually
332
+ // be translated.
333
+ const adminExcludedPaths = await getExcludedFieldPaths(req.payload, config, ctx.slug);
334
+ if (adminExcludedPaths.size > 0) {
335
+ translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
336
+ }
337
+ const blocksConfig = collectBlocksConfig(collectionFields);
338
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(req.payload, config, ctx.slug);
339
+ const allItems = [];
340
+ for (const docId of ids){
341
+ const doc = await findByIdNoFallback(req.payload, ctx.slug, docId, config.sourceLocale, {
342
+ draft
343
+ });
344
+ if (!doc) continue;
345
+ const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
346
+ for (const unit of units){
347
+ allItems.push({
348
+ id: unit.id,
349
+ text: unit.text,
350
+ kind: unit.kind
351
+ });
352
+ }
353
+ }
354
+ if (allItems.length === 0 || !config.provider.estimate) {
355
+ return {
356
+ estimatedCostUsd: undefined,
357
+ documentCount: ids.length
358
+ };
359
+ }
360
+ const mockRequest = {
361
+ items: allItems,
362
+ sourceLocale: config.sourceLocale,
363
+ targetLocale: targetLocales[0] ?? '',
364
+ context: {
365
+ collectionSlug: ctx.slug,
366
+ fieldPath: '*'
367
+ }
368
+ };
369
+ const estimate = await config.provider.estimate(mockRequest);
370
+ // Multiply by locale count — provider estimates a single locale pass.
371
+ const totalCost = estimate.estimatedCostUsd !== undefined ? estimate.estimatedCostUsd * targetLocales.length : undefined;
372
+ return {
373
+ estimatedCostUsd: totalCost,
374
+ documentCount: ids.length
375
+ };
376
+ }
@@ -0,0 +1,140 @@
1
+ import type { Payload, PayloadRequest } from 'payload';
2
+ import type { AITranslatePluginConfig, BulkTranslateConfig } from '../../types.js';
3
+ /**
4
+ * Pre-1.2.8: every translation-hub endpoint was admin-only (Decision
5
+ * #31). v1.2.8 opens trigger + read endpoints to editor-role users so
6
+ * content editors can run bulk translation without needing an admin
7
+ * to fire it for them. Admin-only access is preserved for
8
+ * configuration / cost-limit / force-reset surfaces; per-batch
9
+ * mutations (cancel / retry / revert) are gated by `ownsBatch` so
10
+ * one editor cannot disrupt another's run.
11
+ *
12
+ * Defaults to checking for the literal `'admin'` role. Consumers using
13
+ * a different role naming convention should wrap this in their plugin
14
+ * config's `bulk.requireTotp`/audit-role surfaces; the surface here
15
+ * intentionally stays narrow.
16
+ */
17
+ export declare function isAdminUser(user: PayloadRequest['user'] | undefined): boolean;
18
+ /**
19
+ * Returns `true` for admin OR editor roles. Used by the editor-open
20
+ * endpoints (enqueue / active / status / preflight / list / failures)
21
+ * — the per-batch mutation endpoints layer `ownsBatch` on top of this.
22
+ *
23
+ * `editor` is the conventional role name for content editors across
24
+ * the wild-payload-cms-style consumers; consumers using a different
25
+ * role naming convention should add the synonym to the membership
26
+ * list rather than re-implementing the check.
27
+ */
28
+ export declare function isEditorOrAdmin(user: PayloadRequest['user'] | undefined): boolean;
29
+ /**
30
+ * Ownership check for per-batch mutations (cancel / retry-failed /
31
+ * revert). Admins always pass — they own the world. Editors must be
32
+ * the user who triggered the batch (matched on `triggeredByUserId`).
33
+ *
34
+ * The batch record may carry `triggeredByUserId` as either `string`
35
+ * (the canonical persisted form — see `enqueue.ts:266`) or `number`
36
+ * depending on the auth collection's id type. Compare stringified to
37
+ * sidestep that.
38
+ */
39
+ export declare function ownsBatch(user: PayloadRequest['user'] | undefined, batch: {
40
+ triggeredByUserId?: string | number | null;
41
+ } | null | undefined): boolean;
42
+ /**
43
+ * Structured 403 for "you can do this kind of action, but not on
44
+ * THIS batch (you didn't trigger it)". Distinct from the 401
45
+ * `unauthorizedResponse` (cookie / CSRF failure) and from the 403
46
+ * we'd return for an editor hitting an admin-only endpoint — the
47
+ * `code` lets the UI render the right copy.
48
+ */
49
+ export declare function forbiddenOwnershipResponse(): Response;
50
+ export type ErrorBody = {
51
+ error: {
52
+ code: string;
53
+ message: string;
54
+ fields?: Record<string, string>;
55
+ };
56
+ };
57
+ /**
58
+ * Structured error JSON used across the translation-hub endpoints.
59
+ * Mirrors the shape an `api-designer` would draft for a public API —
60
+ * `code` is the stable machine-readable identifier (UI surfaces
61
+ * key off it), `message` is human-readable, `fields` carries per-
62
+ * field validation hints when relevant.
63
+ */
64
+ export declare function errorResponse(code: string, message: string, status: number, fields?: Record<string, string>): Response;
65
+ /**
66
+ * Structured 401 with a `diagnostic` field naming why auth was rejected —
67
+ * almost always a CSRF allowlist mismatch when the page was opened via the
68
+ * `Network:` URL Next prints in dev. Without this, the response is a
69
+ * silent "Unauthorized" and the next investigator wastes 30 min on JWTs.
70
+ */
71
+ export declare function unauthorizedResponse(req: PayloadRequest): Response;
72
+ /**
73
+ * Decision #13 v2 + F-SEC-TOTP-BYPASS: TOTP is friction, not the
74
+ * primary security boundary. The daily USD cap is the real
75
+ * enforcement. We validate the supplied code against the TOTP plugin
76
+ * if one is wired up; if the plugin isn't installed, we log a warn
77
+ * and skip the check rather than failing closed (the consumer may be
78
+ * a single-admin setup with no TOTP enrollment).
79
+ *
80
+ * Returns `{ ok: true }` when the code is valid OR the plugin isn't
81
+ * installed AND `requireTotp` was set to `true`. Returns
82
+ * `{ ok: false, ... }` with a structured reason otherwise.
83
+ *
84
+ * The actual TOTP code verification reads the auth-collection's
85
+ * `totp.secret` (encrypted) + delegates to `verifyTOTPToken`. We
86
+ * resolve those lazily so the ai-translate plugin doesn't take a
87
+ * hard dependency on the totp plugin's exports.
88
+ */
89
+ export type TotpResult = {
90
+ ok: true;
91
+ } | {
92
+ ok: false;
93
+ code: 'missing' | 'invalid' | 'plugin_unavailable';
94
+ message: string;
95
+ };
96
+ export declare function verifyTotpCode(payload: Payload, userId: string | number, totpCode: string | undefined, options: {
97
+ required: boolean;
98
+ allowSkipWhenUnavailable: boolean;
99
+ }): Promise<TotpResult>;
100
+ export declare function getAiTranslateConfig(payload: Payload): AITranslatePluginConfig | undefined;
101
+ export declare function getBulkConfig(payload: Payload): BulkTranslateConfig | undefined;
102
+ /**
103
+ * Reads a JSON body using the same `req.json()` pattern existing
104
+ * handlers in this directory rely on. Returns either the parsed body
105
+ * or an `errorResponse` Response object so the handler can `return`
106
+ * it directly.
107
+ *
108
+ * Returning a Response (instead of throwing) keeps the handler
109
+ * flat — no try/catch around an awaited body read at every call
110
+ * site.
111
+ */
112
+ export declare function readJsonBody<T>(req: PayloadRequest): Promise<{
113
+ ok: true;
114
+ body: T;
115
+ } | {
116
+ ok: false;
117
+ res: Response;
118
+ }>;
119
+ /**
120
+ * The status endpoint paginates unit drill-down via opaque cursors.
121
+ * The internal representation is `{ offset }` — base64-encoded so
122
+ * clients can't poke at it and depend on internal shape. Cheap and
123
+ * stable; switching to a row-id cursor later is a non-breaking change
124
+ * because the surface is opaque.
125
+ */
126
+ export declare function encodeCursor(offset: number): string;
127
+ export declare function decodeCursor(cursor: string | undefined): number;
128
+ /**
129
+ * Decision #33 mandates an audit-log entry on revert. We don't take a
130
+ * hard dependency on `auditLogPlugin` — instead we write directly to
131
+ * the audit-logs collection if it exists. Best-effort: failures are
132
+ * logged but don't abort the action.
133
+ */
134
+ export declare function writeBulkAuditEntry(payload: Payload, params: {
135
+ userId: string | number;
136
+ userEmail?: string;
137
+ action: string;
138
+ batchId: string;
139
+ metadata?: Record<string, unknown>;
140
+ }): Promise<void>;