@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,297 @@
1
+ import { describeAuthRejection } from '../../lib/auth-diagnostics.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Auth helpers
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * Pre-1.2.8: every translation-hub endpoint was admin-only (Decision
7
+ * #31). v1.2.8 opens trigger + read endpoints to editor-role users so
8
+ * content editors can run bulk translation without needing an admin
9
+ * to fire it for them. Admin-only access is preserved for
10
+ * configuration / cost-limit / force-reset surfaces; per-batch
11
+ * mutations (cancel / retry / revert) are gated by `ownsBatch` so
12
+ * one editor cannot disrupt another's run.
13
+ *
14
+ * Defaults to checking for the literal `'admin'` role. Consumers using
15
+ * a different role naming convention should wrap this in their plugin
16
+ * config's `bulk.requireTotp`/audit-role surfaces; the surface here
17
+ * intentionally stays narrow.
18
+ */ export function isAdminUser(user) {
19
+ if (!user) return false;
20
+ const roles = user?.roles;
21
+ if (!Array.isArray(roles)) return false;
22
+ return roles.includes('admin');
23
+ }
24
+ /**
25
+ * Returns `true` for admin OR editor roles. Used by the editor-open
26
+ * endpoints (enqueue / active / status / preflight / list / failures)
27
+ * — the per-batch mutation endpoints layer `ownsBatch` on top of this.
28
+ *
29
+ * `editor` is the conventional role name for content editors across
30
+ * the wild-payload-cms-style consumers; consumers using a different
31
+ * role naming convention should add the synonym to the membership
32
+ * list rather than re-implementing the check.
33
+ */ export function isEditorOrAdmin(user) {
34
+ if (!user) return false;
35
+ const roles = user?.roles;
36
+ if (!Array.isArray(roles)) return false;
37
+ return roles.includes('admin') || roles.includes('editor');
38
+ }
39
+ /**
40
+ * Ownership check for per-batch mutations (cancel / retry-failed /
41
+ * revert). Admins always pass — they own the world. Editors must be
42
+ * the user who triggered the batch (matched on `triggeredByUserId`).
43
+ *
44
+ * The batch record may carry `triggeredByUserId` as either `string`
45
+ * (the canonical persisted form — see `enqueue.ts:266`) or `number`
46
+ * depending on the auth collection's id type. Compare stringified to
47
+ * sidestep that.
48
+ */ export function ownsBatch(user, batch) {
49
+ if (!user || !batch) return false;
50
+ if (isAdminUser(user)) return true;
51
+ const userId = user?.id;
52
+ if (userId == null || batch.triggeredByUserId == null) return false;
53
+ return String(userId) === String(batch.triggeredByUserId);
54
+ }
55
+ /**
56
+ * Structured 403 for "you can do this kind of action, but not on
57
+ * THIS batch (you didn't trigger it)". Distinct from the 401
58
+ * `unauthorizedResponse` (cookie / CSRF failure) and from the 403
59
+ * we'd return for an editor hitting an admin-only endpoint — the
60
+ * `code` lets the UI render the right copy.
61
+ */ export function forbiddenOwnershipResponse() {
62
+ return Response.json({
63
+ error: {
64
+ code: 'forbidden_ownership',
65
+ message: 'You can only modify bulk runs you started. Ask the editor who triggered this run, or contact an admin.'
66
+ }
67
+ }, {
68
+ status: 403
69
+ });
70
+ }
71
+ /**
72
+ * Structured error JSON used across the translation-hub endpoints.
73
+ * Mirrors the shape an `api-designer` would draft for a public API —
74
+ * `code` is the stable machine-readable identifier (UI surfaces
75
+ * key off it), `message` is human-readable, `fields` carries per-
76
+ * field validation hints when relevant.
77
+ */ export function errorResponse(code, message, status, fields) {
78
+ const body = {
79
+ error: fields ? {
80
+ code,
81
+ message,
82
+ fields
83
+ } : {
84
+ code,
85
+ message
86
+ }
87
+ };
88
+ return Response.json(body, {
89
+ status
90
+ });
91
+ }
92
+ /**
93
+ * Structured 401 with a `diagnostic` field naming why auth was rejected —
94
+ * almost always a CSRF allowlist mismatch when the page was opened via the
95
+ * `Network:` URL Next prints in dev. Without this, the response is a
96
+ * silent "Unauthorized" and the next investigator wastes 30 min on JWTs.
97
+ */ export function unauthorizedResponse(req) {
98
+ const diagnostic = describeAuthRejection(req);
99
+ return Response.json({
100
+ error: {
101
+ code: 'unauthorized',
102
+ message: 'Authentication required.',
103
+ diagnostic
104
+ }
105
+ }, {
106
+ status: 401
107
+ });
108
+ }
109
+ export async function verifyTotpCode(payload, userId, totpCode, options) {
110
+ if (!options.required) {
111
+ return {
112
+ ok: true
113
+ };
114
+ }
115
+ if (!totpCode || typeof totpCode !== 'string' || totpCode.length === 0) {
116
+ return {
117
+ ok: false,
118
+ code: 'missing',
119
+ message: 'An authentication code is required. Enter the 6-digit code from your authenticator app.'
120
+ };
121
+ }
122
+ // Lazy import — totp plugin may not be installed in the consumer's
123
+ // app. If the dynamic import fails (or the module shape changes),
124
+ // we treat it as plugin_unavailable.
125
+ let verifyFn = ()=>({
126
+ valid: false
127
+ });
128
+ let decryptFn = (s)=>s;
129
+ try {
130
+ // Use a string variable to make the import path opaque to the
131
+ // bundler — the plugin must remain optional at install time.
132
+ const totpCrypto = '../../../../totp/crypto/totp';
133
+ const cryptoMod = await import(totpCrypto);
134
+ const helpers = '../../../../totp/utilities/helpers';
135
+ const helpersMod = await import(helpers);
136
+ if (typeof cryptoMod.verifyTOTPToken !== 'function' || typeof helpersMod.decryptSecret !== 'function' || typeof helpersMod.isTOTPEncryptionConfigured !== 'function' || !helpersMod.isTOTPEncryptionConfigured()) {
137
+ throw new Error('totp_unavailable');
138
+ }
139
+ verifyFn = cryptoMod.verifyTOTPToken;
140
+ decryptFn = helpersMod.decryptSecret;
141
+ } catch {
142
+ payload.logger?.warn?.('[ai-translate] bulk-translate: TOTP plugin not detected; skipping TOTP check (requireTotp was true).');
143
+ return options.allowSkipWhenUnavailable ? {
144
+ ok: true
145
+ } : {
146
+ ok: false,
147
+ code: 'plugin_unavailable',
148
+ message: "Two-factor authentication is required but isn't available in this environment. Contact engineering."
149
+ };
150
+ }
151
+ // Look up the user's TOTP secret. The auth collection slug varies
152
+ // per consumer — read it from payload.config (Payload exposes
153
+ // `config.admin.user` as the auth collection slug).
154
+ const authSlug = payload.config.admin?.user ?? 'users';
155
+ let user;
156
+ try {
157
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
158
+ user = await payload.findByID({
159
+ collection: authSlug,
160
+ id: userId,
161
+ overrideAccess: true
162
+ });
163
+ } catch {
164
+ user = undefined;
165
+ }
166
+ const totpField = user?.totp ?? undefined;
167
+ if (!totpField?.enabled || typeof totpField.secret !== 'string') {
168
+ return {
169
+ ok: false,
170
+ code: 'plugin_unavailable',
171
+ message: "Two-factor authentication isn't set up for your account. Go to Account → Security to enrol your authenticator app."
172
+ };
173
+ }
174
+ let plainSecret;
175
+ try {
176
+ plainSecret = decryptFn(totpField.secret);
177
+ } catch {
178
+ return {
179
+ ok: false,
180
+ code: 'plugin_unavailable',
181
+ message: 'Your two-factor authentication setup is unreadable on the server. Contact engineering.'
182
+ };
183
+ }
184
+ const result = verifyFn(plainSecret, totpCode, totpField.lastCounter);
185
+ if (!result.valid) {
186
+ return {
187
+ ok: false,
188
+ code: 'invalid',
189
+ message: 'The authentication code was incorrect. Check your authenticator app and try again.'
190
+ };
191
+ }
192
+ return {
193
+ ok: true
194
+ };
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Config helpers
198
+ // ---------------------------------------------------------------------------
199
+ export function getAiTranslateConfig(payload) {
200
+ return payload.config?.custom?.aiTranslate;
201
+ }
202
+ export function getBulkConfig(payload) {
203
+ const cfg = getAiTranslateConfig(payload);
204
+ return cfg?.bulk;
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Body parsing
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * Reads a JSON body using the same `req.json()` pattern existing
211
+ * handlers in this directory rely on. Returns either the parsed body
212
+ * or an `errorResponse` Response object so the handler can `return`
213
+ * it directly.
214
+ *
215
+ * Returning a Response (instead of throwing) keeps the handler
216
+ * flat — no try/catch around an awaited body read at every call
217
+ * site.
218
+ */ export async function readJsonBody(req) {
219
+ try {
220
+ if (typeof req.json !== 'function') {
221
+ return {
222
+ ok: true,
223
+ body: {}
224
+ };
225
+ }
226
+ const body = await req.json();
227
+ return {
228
+ ok: true,
229
+ body: body ?? {}
230
+ };
231
+ } catch {
232
+ return {
233
+ ok: false,
234
+ res: errorResponse('invalid_json', 'Invalid JSON body', 400)
235
+ };
236
+ }
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // Pagination cursor — opaque base64 of `{ offset: number }`
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * The status endpoint paginates unit drill-down via opaque cursors.
243
+ * The internal representation is `{ offset }` — base64-encoded so
244
+ * clients can't poke at it and depend on internal shape. Cheap and
245
+ * stable; switching to a row-id cursor later is a non-breaking change
246
+ * because the surface is opaque.
247
+ */ export function encodeCursor(offset) {
248
+ return Buffer.from(JSON.stringify({
249
+ offset
250
+ }), 'utf-8').toString('base64');
251
+ }
252
+ export function decodeCursor(cursor) {
253
+ if (!cursor) return 0;
254
+ try {
255
+ const raw = Buffer.from(cursor, 'base64').toString('utf-8');
256
+ const parsed = JSON.parse(raw);
257
+ if (typeof parsed.offset === 'number' && Number.isFinite(parsed.offset) && parsed.offset >= 0) {
258
+ return Math.floor(parsed.offset);
259
+ }
260
+ } catch {
261
+ // fall through
262
+ }
263
+ return 0;
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Audit-log entry (best-effort)
267
+ // ---------------------------------------------------------------------------
268
+ /**
269
+ * Decision #33 mandates an audit-log entry on revert. We don't take a
270
+ * hard dependency on `auditLogPlugin` — instead we write directly to
271
+ * the audit-logs collection if it exists. Best-effort: failures are
272
+ * logged but don't abort the action.
273
+ */ export async function writeBulkAuditEntry(payload, params) {
274
+ const collections = payload.config?.collections ?? [];
275
+ const auditSlug = collections.find((c)=>c.slug === 'audit-logs')?.slug;
276
+ if (!auditSlug) return;
277
+ try {
278
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
279
+ await payload.create({
280
+ collection: auditSlug,
281
+ data: {
282
+ authorEmail: params.userEmail ?? String(params.userId),
283
+ action: params.action,
284
+ type: 'audit',
285
+ source: 'bulk-translate',
286
+ collectionSlug: 'bulk-translate-batches',
287
+ documentId: params.batchId,
288
+ documentTitle: `bulk-translate-${params.batchId}`,
289
+ user: params.userId,
290
+ changes: params.metadata ?? null
291
+ },
292
+ overrideAccess: true
293
+ });
294
+ } catch (err) {
295
+ payload.logger?.warn?.(`[ai-translate] bulk-translate: audit-log write failed for ${params.action} on batch ${params.batchId}: ${err instanceof Error ? err.message : String(err)}`);
296
+ }
297
+ }
@@ -0,0 +1,21 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export interface BulkActiveHandlerOptions {
3
+ batchesCollectionSlug?: string;
4
+ unitsCollectionSlug?: string;
5
+ /**
6
+ * Window after a batch's `completedAt` during which the terminal card
7
+ * still shows on the Hub. Defaults to 24 hours, mirroring Decision #28.
8
+ */
9
+ terminalVisibilityMs?: number;
10
+ }
11
+ /**
12
+ * `GET /api/translation-hub/bulk-translate/active`
13
+ *
14
+ * Returns the most relevant batch for the Hub: an in-flight batch
15
+ * (queued / running / cancelling) takes precedence; otherwise we
16
+ * return the most recently-terminal batch within the 24h visibility
17
+ * window so the operator can review or revert it.
18
+ *
19
+ * Response shape: `{ batch: BulkTranslateBatchSummary | null }`.
20
+ */
21
+ export declare const getBulkTranslateActiveHandler: (options?: BulkActiveHandlerOptions) => PayloadHandler;
@@ -0,0 +1,220 @@
1
+ import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../../bulk-translate-batches-collection.js';
2
+ import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../../bulk-translate-units-collection.js';
3
+ import { computeLiveBatchCount } from '../../lib/batch-counts.js';
4
+ import { getDrizzle, slugToTable } from '../../lib/bulk-translate-migrations.js';
5
+ import { createScopedLogger } from '../../lib/logger.js';
6
+ import { errorResponse, isEditorOrAdmin, unauthorizedResponse } from './_helpers.js';
7
+ const DEFAULT_TERMINAL_VISIBILITY_MS = 24 * 60 * 60 * 1000;
8
+ /**
9
+ * `GET /api/translation-hub/bulk-translate/active`
10
+ *
11
+ * Returns the most relevant batch for the Hub: an in-flight batch
12
+ * (queued / running / cancelling) takes precedence; otherwise we
13
+ * return the most recently-terminal batch within the 24h visibility
14
+ * window so the operator can review or revert it.
15
+ *
16
+ * Response shape: `{ batch: BulkTranslateBatchSummary | null }`.
17
+ */ export const getBulkTranslateActiveHandler = (options = {})=>async (req)=>{
18
+ const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
19
+ const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
20
+ const visibilityMs = options.terminalVisibilityMs ?? DEFAULT_TERMINAL_VISIBILITY_MS;
21
+ if (!req.user) {
22
+ return unauthorizedResponse(req);
23
+ }
24
+ if (!isEditorOrAdmin(req.user)) {
25
+ return errorResponse('forbidden', "You don't have permission to view bulk-translation status. Contact an admin.", 403);
26
+ }
27
+ // Prefer the most recent active batch. Active here = anything that
28
+ // isn't terminal — a single tenant typically only has one in flight
29
+ // (the coordinator's lease lock enforces this), so the limit=1 sort
30
+ // is enough.
31
+ let active;
32
+ try {
33
+ const result = await req.payload.find({
34
+ collection: batchesSlug,
35
+ where: {
36
+ status: {
37
+ in: [
38
+ 'queued',
39
+ 'running',
40
+ 'cancelling'
41
+ ]
42
+ }
43
+ },
44
+ sort: '-enqueuedAt',
45
+ limit: 1,
46
+ depth: 0,
47
+ overrideAccess: true
48
+ });
49
+ active = result.docs[0];
50
+ } catch (err) {
51
+ const log = createScopedLogger(req.payload, {
52
+ component: 'hub.active'
53
+ });
54
+ log.event('warn', 'hub.active.batch-read.failed', {
55
+ err,
56
+ endpoint: 'hub.active',
57
+ phase: 'in-flight'
58
+ });
59
+ active = undefined;
60
+ }
61
+ // No active batch — look for the most recent terminal batch within
62
+ // the visibility window so the operator can still see / revert it.
63
+ let chosen = active;
64
+ if (!chosen) {
65
+ const cutoff = new Date(Date.now() - visibilityMs).toISOString();
66
+ try {
67
+ const result = await req.payload.find({
68
+ collection: batchesSlug,
69
+ where: {
70
+ and: [
71
+ {
72
+ status: {
73
+ in: [
74
+ 'success',
75
+ 'failed',
76
+ 'partial',
77
+ 'reverted',
78
+ 'cancelled'
79
+ ]
80
+ }
81
+ },
82
+ {
83
+ completedAt: {
84
+ greater_than: cutoff
85
+ }
86
+ }
87
+ ]
88
+ },
89
+ sort: '-completedAt',
90
+ limit: 1,
91
+ depth: 0,
92
+ overrideAccess: true
93
+ });
94
+ chosen = result.docs[0];
95
+ } catch (err) {
96
+ const log = createScopedLogger(req.payload, {
97
+ component: 'hub.active'
98
+ });
99
+ log.event('warn', 'hub.active.batch-read.failed', {
100
+ err,
101
+ endpoint: 'hub.active',
102
+ phase: 'terminal-window'
103
+ });
104
+ chosen = undefined;
105
+ }
106
+ }
107
+ if (!chosen) {
108
+ return Response.json({
109
+ batch: null
110
+ });
111
+ }
112
+ const collections = await aggregateCollectionsBreakdown(req.payload, unitsSlug, String(chosen.id));
113
+ // Live counts — bypass cached `completedUnits` / `failedUnits` on
114
+ // the batch row which drift on retries. See `lib/batch-counts.ts`.
115
+ const live = await computeLiveBatchCount(req.payload, unitsSlug, String(chosen.id));
116
+ const completedAtMs = chosen.completedAt ? Date.parse(chosen.completedAt) : null;
117
+ const revertExpiresAt = completedAtMs !== null ? new Date(completedAtMs + visibilityMs).toISOString() : null;
118
+ return Response.json({
119
+ batch: {
120
+ id: String(chosen.id),
121
+ status: chosen.status,
122
+ mode: chosen.mode,
123
+ totalUnits: live.total > 0 ? live.total : Number(chosen.totalUnits ?? 0),
124
+ completedUnits: live.completed,
125
+ failedUnits: live.failed,
126
+ estimatedCostUsd: chosen.estimatedCostUsd !== undefined ? Number(chosen.estimatedCostUsd) : undefined,
127
+ actualCostUsd: chosen.actualCostUsd !== undefined ? Number(chosen.actualCostUsd) : undefined,
128
+ createdAt: chosen.createdAt ?? chosen.enqueuedAt ?? new Date(0).toISOString(),
129
+ startedAt: chosen.startedAt ?? null,
130
+ completedAt: chosen.completedAt ?? null,
131
+ etaSeconds: null,
132
+ collections,
133
+ revertExpiresAt,
134
+ revertedAt: chosen.revertedAt ?? null,
135
+ triggeredByEmail: chosen.triggeredByEmail ?? null,
136
+ // 1.2.8: editor-facing chip needs the raw user id to compare
137
+ // against the viewer's id and decide whether to say "Your bulk
138
+ // run" or "Bulk run by alice@…".
139
+ triggeredByUserId: chosen.triggeredByUserId != null ? String(chosen.triggeredByUserId) : null
140
+ }
141
+ });
142
+ };
143
+ /**
144
+ * Per-collection progress for the in-flight monitor. We aggregate by
145
+ * counting unit rows per `collection` value; the labels are echoed
146
+ * back as the slug since the endpoint has no view of collection
147
+ * `labels` (the UI maps slug → human label client-side).
148
+ */ async function aggregateCollectionsBreakdown(payload, unitsSlug, batchId) {
149
+ const buckets = new Map();
150
+ const tally = (slug, status, n)=>{
151
+ if (!slug) return;
152
+ const bucket = buckets.get(slug) ?? {
153
+ total: 0,
154
+ completed: 0,
155
+ failed: 0
156
+ };
157
+ bucket.total += n;
158
+ if (status === 'success') bucket.completed += n;
159
+ if (status === 'failed') bucket.failed += n;
160
+ buckets.set(slug, bucket);
161
+ };
162
+ // Fast path: GROUP BY on Postgres. This endpoint is polled every 5s
163
+ // by every open Hub tab — and (terminal-visibility) for a full 24h
164
+ // after a run completes. The previous fetch-all pulled every unit
165
+ // row INCLUDING the `pre_run_snapshot` jsonb on each poll; on prod-
166
+ // sized batches that alone was seconds of DB + JSON.parse work per
167
+ // poll (2026-06-10 outage amplifier).
168
+ let aggregated = false;
169
+ if (/^\d+$/.test(batchId)) {
170
+ const db = getDrizzle(payload);
171
+ if (db) {
172
+ try {
173
+ const res = await db.execute(`SELECT collection, status, count(*) AS n
174
+ FROM ${slugToTable(unitsSlug)}
175
+ WHERE batch_id_id = ${batchId}
176
+ GROUP BY 1, 2`);
177
+ for (const row of res?.rows ?? []){
178
+ tally(String(row.collection ?? ''), String(row.status ?? ''), Number(row.n ?? 0));
179
+ }
180
+ aggregated = true;
181
+ } catch (err) {
182
+ payload.logger?.warn?.(`[ai-translate] active: grouped breakdown SQL failed, falling back to row scan: ${err instanceof Error ? err.message : String(err)}`);
183
+ }
184
+ }
185
+ }
186
+ if (!aggregated) {
187
+ // Fallback: projected row scan — (collection, status) only, so the
188
+ // snapshot jsonb never leaves the database.
189
+ let page = 1;
190
+ // eslint-disable-next-line no-constant-condition
191
+ while(true){
192
+ const result = await payload.find({
193
+ collection: unitsSlug,
194
+ where: {
195
+ batchId: {
196
+ equals: batchId
197
+ }
198
+ },
199
+ page,
200
+ limit: 5000,
201
+ depth: 0,
202
+ select: {
203
+ collection: true,
204
+ status: true
205
+ },
206
+ overrideAccess: true
207
+ });
208
+ for (const row of result.docs){
209
+ tally(String(row.collection ?? ''), String(row.status ?? ''), 1);
210
+ }
211
+ if (!result.hasNextPage) break;
212
+ page += 1;
213
+ }
214
+ }
215
+ return Array.from(buckets.entries()).map(([slug, b])=>({
216
+ slug,
217
+ label: slug,
218
+ ...b
219
+ }));
220
+ }
@@ -0,0 +1,22 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export interface BulkCancelHandlerOptions {
3
+ batchesCollectionSlug?: string;
4
+ unitsCollectionSlug?: string;
5
+ /** Payload jobs collection slug. Default `'payload-jobs'`. */
6
+ jobsCollectionSlug?: string;
7
+ }
8
+ /**
9
+ * `POST /api/translation-hub/bulk-translate/:id/cancel`
10
+ *
11
+ * Transitions a queued/running batch to `cancelling`. The coordinator
12
+ * and worker tasks honor the new status at their next checkpoint
13
+ * (coordinator reads `batch.status` at the top of every tick; worker
14
+ * reads at terminal write). In-flight LLM calls finish naturally —
15
+ * we don't try to interrupt them mid-stream.
16
+ *
17
+ * Queued payload-jobs rows for this batch are best-effort cancelled
18
+ * here (we update them to a terminal state so the runner skips them).
19
+ * Some Payload versions expose a jobs API for this; we fall back to a
20
+ * direct update on the queued rows when the API isn't available.
21
+ */
22
+ export declare const getBulkTranslateCancelHandler: (options?: BulkCancelHandlerOptions) => PayloadHandler;