@elytracms/studio 0.0.92 → 0.0.94

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 (599) hide show
  1. package/convex/analytics-cost.test.ts +817 -0
  2. package/convex/asset-alt-migration.test.ts +127 -0
  3. package/convex/asset-replace.test.ts +168 -0
  4. package/convex/asset-revalidate.test.ts +6 -6
  5. package/convex/assets.ts +41 -5
  6. package/convex/auth.ts +5 -2
  7. package/convex/authority.ts +35 -11
  8. package/convex/cms.ts +25 -11
  9. package/convex/contract-parity.test.ts +25 -3
  10. package/convex/delivery.ts +12 -9
  11. package/convex/document-revalidate.test.ts +10 -12
  12. package/convex/guard.ts +9 -7
  13. package/convex/http-routes.test.ts +18 -8
  14. package/convex/http.ts +23 -17
  15. package/convex/ingest.test.ts +326 -0
  16. package/convex/ingest.ts +281 -0
  17. package/convex/invites.ts +13 -5
  18. package/convex/members.ts +10 -4
  19. package/convex/orphan-hydration.test.ts +116 -0
  20. package/convex/rail-write-parity.test.ts +11 -8
  21. package/convex/record-functions.ts +325 -0
  22. package/convex/records.test.ts +156 -0
  23. package/convex/redirect-migration.test.ts +205 -0
  24. package/convex/redirect-revalidate.test.ts +5 -1
  25. package/convex/redirects.ts +120 -79
  26. package/convex/role-policy.test.ts +315 -0
  27. package/convex/roles.ts +83 -0
  28. package/convex/schema.ts +36 -0
  29. package/convex/triggers.ts +21 -1
  30. package/dist/addon.d.ts +71 -0
  31. package/dist/addon.js +67 -0
  32. package/dist/addon.js.map +1 -0
  33. package/dist/components/account/account-settings.js +2 -2
  34. package/dist/components/account/account-settings.js.map +1 -1
  35. package/dist/components/account/credential-panel.js +2 -2
  36. package/dist/components/account/credential-panel.js.map +1 -1
  37. package/dist/components/account/self-account-schema.d.ts +6 -15
  38. package/dist/components/account/self-account-schema.js +52 -34
  39. package/dist/components/account/self-account-schema.js.map +1 -1
  40. package/dist/components/account/self-account-view-source.d.ts +1 -1
  41. package/dist/components/account/self-account-view-source.js +6 -4
  42. package/dist/components/account/self-account-view-source.js.map +1 -1
  43. package/dist/components/ai/ai-chat-panel-lazy.js +1 -1
  44. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  45. package/dist/components/ai/ai-chat-panel.js +6 -6
  46. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  47. package/dist/components/ai/ai-cms-panel.js +2 -2
  48. package/dist/components/ai/ai-cms-panel.js.map +1 -1
  49. package/dist/components/ai/ai-panel-toggle.js +4 -4
  50. package/dist/components/ai/ai-panel-toggle.js.map +1 -1
  51. package/dist/components/ai/chat-ui.js +2 -2
  52. package/dist/components/ai/chat-ui.js.map +1 -1
  53. package/dist/components/ai/proposal-card.d.ts +2 -2
  54. package/dist/components/ai/proposal-card.js +4 -3
  55. package/dist/components/ai/proposal-card.js.map +1 -1
  56. package/dist/components/ai/proposal-issues.js +2 -2
  57. package/dist/components/ai/proposal-issues.js.map +1 -1
  58. package/dist/components/ai/translate-document-action.js +2 -2
  59. package/dist/components/ai/translate-document-action.js.map +1 -1
  60. package/dist/components/ai/translate-document-dialog.js +10 -8
  61. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  62. package/dist/components/ai/translate-skipped.d.ts +19 -0
  63. package/dist/components/ai/translate-skipped.js +46 -0
  64. package/dist/components/ai/translate-skipped.js.map +1 -0
  65. package/dist/components/app-views/default-modules.d.ts +50 -2
  66. package/dist/components/app-views/default-modules.js +65 -20
  67. package/dist/components/app-views/default-modules.js.map +1 -1
  68. package/dist/components/app-views/module.d.ts +53 -3
  69. package/dist/components/app-views/module.js +14 -7
  70. package/dist/components/app-views/module.js.map +1 -1
  71. package/dist/components/app-views/sidebar.js +1 -1
  72. package/dist/components/app-views/sidebar.js.map +1 -1
  73. package/dist/components/app-views/studio-config.d.ts +11 -2
  74. package/dist/components/app-views/studio-config.js.map +1 -1
  75. package/dist/components/assets/asset-detail.js +20 -10
  76. package/dist/components/assets/asset-detail.js.map +1 -1
  77. package/dist/components/assets/asset-grid.js +2 -2
  78. package/dist/components/assets/asset-grid.js.map +1 -1
  79. package/dist/components/assets/asset-metadata.d.ts +15 -4
  80. package/dist/components/assets/asset-metadata.js +15 -2
  81. package/dist/components/assets/asset-metadata.js.map +1 -1
  82. package/dist/components/assets/asset-search.js +4 -1
  83. package/dist/components/assets/asset-search.js.map +1 -1
  84. package/dist/components/assets/asset-selection.d.ts +0 -8
  85. package/dist/components/assets/asset-selection.js +4 -23
  86. package/dist/components/assets/asset-selection.js.map +1 -1
  87. package/dist/components/assets/asset-thumb.js +6 -5
  88. package/dist/components/assets/asset-thumb.js.map +1 -1
  89. package/dist/components/assets/assets-collection-view-source.d.ts +5 -5
  90. package/dist/components/assets/assets-collection-view-source.js +9 -5
  91. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  92. package/dist/components/assets/assets-module.js +1 -1
  93. package/dist/components/assets/assets-module.js.map +1 -1
  94. package/dist/components/assets/assets-store.d.ts +16 -2
  95. package/dist/components/assets/assets-store.js +28 -6
  96. package/dist/components/assets/assets-store.js.map +1 -1
  97. package/dist/components/assets/assets-view-preference.js +1 -1
  98. package/dist/components/assets/assets-view-preference.js.map +1 -1
  99. package/dist/components/assets/replace-file.d.ts +22 -0
  100. package/dist/components/assets/replace-file.js +59 -0
  101. package/dist/components/assets/replace-file.js.map +1 -0
  102. package/dist/components/auth-context.d.ts +1 -1
  103. package/dist/components/auth-context.js +6 -6
  104. package/dist/components/auth-context.js.map +1 -1
  105. package/dist/components/builder/blocks-model.d.ts +15 -27
  106. package/dist/components/builder/blocks-model.js +38 -44
  107. package/dist/components/builder/blocks-model.js.map +1 -1
  108. package/dist/components/builder/blocks-panel.d.ts +8 -4
  109. package/dist/components/builder/blocks-panel.js +10 -9
  110. package/dist/components/builder/blocks-panel.js.map +1 -1
  111. package/dist/components/builder/builder-store.d.ts +104 -15
  112. package/dist/components/builder/builder-store.js +157 -32
  113. package/dist/components/builder/builder-store.js.map +1 -1
  114. package/dist/components/builder/canvas-body.d.ts +30 -0
  115. package/dist/components/builder/canvas-body.js +38 -0
  116. package/dist/components/builder/canvas-body.js.map +1 -0
  117. package/dist/components/builder/canvas-breakpoints.js +1 -1
  118. package/dist/components/builder/canvas-breakpoints.js.map +1 -1
  119. package/dist/components/builder/canvas-chrome-css.js +7 -0
  120. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  121. package/dist/components/builder/canvas-dnd.d.ts +9 -9
  122. package/dist/components/builder/canvas-dnd.js +6 -6
  123. package/dist/components/builder/canvas-dnd.js.map +1 -1
  124. package/dist/components/builder/canvas-inline-editing.d.ts +1 -1
  125. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  126. package/dist/components/builder/canvas-overlay-geometry.d.ts +1 -1
  127. package/dist/components/builder/canvas-overlay-geometry.js.map +1 -1
  128. package/dist/components/builder/canvas-render-context.d.ts +28 -20
  129. package/dist/components/builder/canvas-render-context.js +46 -47
  130. package/dist/components/builder/canvas-render-context.js.map +1 -1
  131. package/dist/components/builder/canvas-runs.d.ts +45 -0
  132. package/dist/components/builder/canvas-runs.js +51 -0
  133. package/dist/components/builder/canvas-runs.js.map +1 -0
  134. package/dist/components/builder/canvas-theme-notice.js +2 -2
  135. package/dist/components/builder/canvas-theme-notice.js.map +1 -1
  136. package/dist/components/builder/canvas.js +35 -8
  137. package/dist/components/builder/canvas.js.map +1 -1
  138. package/dist/components/builder/clipboard-actions.d.ts +16 -1
  139. package/dist/components/builder/clipboard-actions.js +48 -11
  140. package/dist/components/builder/clipboard-actions.js.map +1 -1
  141. package/dist/components/builder/composition-field-editor-lazy.js +1 -1
  142. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  143. package/dist/components/builder/composition-field-editor.d.ts +4 -16
  144. package/dist/components/builder/composition-field-editor.js +32 -43
  145. package/dist/components/builder/composition-field-editor.js.map +1 -1
  146. package/dist/components/builder/delete-key.d.ts +5 -0
  147. package/dist/components/builder/delete-key.js +4 -0
  148. package/dist/components/builder/delete-key.js.map +1 -1
  149. package/dist/components/builder/drag-session.d.ts +1 -1
  150. package/dist/components/builder/drag-session.js.map +1 -1
  151. package/dist/components/builder/fixture-thumbnail.js +1 -1
  152. package/dist/components/builder/fixture-thumbnail.js.map +1 -1
  153. package/dist/components/builder/insert-context.d.ts +18 -26
  154. package/dist/components/builder/insert-context.js +47 -94
  155. package/dist/components/builder/insert-context.js.map +1 -1
  156. package/dist/components/builder/layout-inspector.js +1 -1
  157. package/dist/components/builder/layout-inspector.js.map +1 -1
  158. package/dist/components/builder/node-title.js +1 -1
  159. package/dist/components/builder/node-title.js.map +1 -1
  160. package/dist/components/builder/page-tree.d.ts +4 -4
  161. package/dist/components/builder/page-tree.js +8 -8
  162. package/dist/components/builder/page-tree.js.map +1 -1
  163. package/dist/components/builder/placement-refusal.d.ts +18 -0
  164. package/dist/components/builder/placement-refusal.js +46 -0
  165. package/dist/components/builder/placement-refusal.js.map +1 -0
  166. package/dist/components/builder/property-inspector.js +8 -4
  167. package/dist/components/builder/property-inspector.js.map +1 -1
  168. package/dist/components/builder/reuse-context.js +68 -30
  169. package/dist/components/builder/reuse-context.js.map +1 -1
  170. package/dist/components/builder/reuse.d.ts +79 -1
  171. package/dist/components/builder/reuse.js +70 -3
  172. package/dist/components/builder/reuse.js.map +1 -1
  173. package/dist/components/builder/rich-text-toolbar.js +1 -1
  174. package/dist/components/builder/rich-text-toolbar.js.map +1 -1
  175. package/dist/components/builder/run-inspector.d.ts +11 -0
  176. package/dist/components/builder/run-inspector.js +33 -0
  177. package/dist/components/builder/run-inspector.js.map +1 -0
  178. package/dist/components/builder/selection-overlay.js +3 -3
  179. package/dist/components/builder/selection-overlay.js.map +1 -1
  180. package/dist/components/builder/spacing-handles.d.ts +2 -2
  181. package/dist/components/builder/spacing-handles.js +3 -3
  182. package/dist/components/builder/spacing-handles.js.map +1 -1
  183. package/dist/components/cms/asset-field.d.ts +38 -1
  184. package/dist/components/cms/asset-field.js +184 -60
  185. package/dist/components/cms/asset-field.js.map +1 -1
  186. package/dist/components/cms/asset-picker-core.d.ts +16 -0
  187. package/dist/components/cms/asset-picker-core.js +20 -0
  188. package/dist/components/cms/asset-picker-core.js.map +1 -1
  189. package/dist/components/cms/asset-picker.js +14 -20
  190. package/dist/components/cms/asset-picker.js.map +1 -1
  191. package/dist/components/cms/bulk-actions.d.ts +4 -20
  192. package/dist/components/cms/bulk-actions.js +5 -26
  193. package/dist/components/cms/bulk-actions.js.map +1 -1
  194. package/dist/components/cms/bulk-bar.d.ts +2 -2
  195. package/dist/components/cms/bulk-bar.js +2 -2
  196. package/dist/components/cms/bulk-bar.js.map +1 -1
  197. package/dist/components/cms/cell-edit.d.ts +1 -1
  198. package/dist/components/cms/cell-edit.js +1 -1
  199. package/dist/components/cms/cell-edit.js.map +1 -1
  200. package/dist/components/cms/cell.d.ts +1 -1
  201. package/dist/components/cms/cell.js +3 -3
  202. package/dist/components/cms/cell.js.map +1 -1
  203. package/dist/components/cms/cms-index.js +2 -2
  204. package/dist/components/cms/cms-index.js.map +1 -1
  205. package/dist/components/cms/cms-labels.js +2 -2
  206. package/dist/components/cms/cms-labels.js.map +1 -1
  207. package/dist/components/cms/cms-store.d.ts +7 -6
  208. package/dist/components/cms/cms-store.js +23 -43
  209. package/dist/components/cms/cms-store.js.map +1 -1
  210. package/dist/components/cms/collection-table.js +7 -7
  211. package/dist/components/cms/collection-table.js.map +1 -1
  212. package/dist/components/cms/detail-editor.d.ts +5 -2
  213. package/dist/components/cms/detail-editor.js +20 -15
  214. package/dist/components/cms/detail-editor.js.map +1 -1
  215. package/dist/components/cms/details-pane.js +1 -1
  216. package/dist/components/cms/details-pane.js.map +1 -1
  217. package/dist/components/cms/doc-state.js +1 -1
  218. package/dist/components/cms/doc-state.js.map +1 -1
  219. package/dist/components/cms/document-diff-view.d.ts +3 -1
  220. package/dist/components/cms/document-diff-view.js +17 -12
  221. package/dist/components/cms/document-diff-view.js.map +1 -1
  222. package/dist/components/cms/document-editor-context.js +1 -1
  223. package/dist/components/cms/document-editor-context.js.map +1 -1
  224. package/dist/components/cms/document-staging.js +1 -1
  225. package/dist/components/cms/document-staging.js.map +1 -1
  226. package/dist/components/cms/document-url-path.js +2 -2
  227. package/dist/components/cms/document-url-path.js.map +1 -1
  228. package/dist/components/cms/duplicate-document.d.ts +1 -1
  229. package/dist/components/cms/duplicate-document.js.map +1 -1
  230. package/dist/components/cms/editable-cell.js +3 -3
  231. package/dist/components/cms/editable-cell.js.map +1 -1
  232. package/dist/components/cms/field-anchors.d.ts +7 -4
  233. package/dist/components/cms/field-anchors.js +9 -7
  234. package/dist/components/cms/field-anchors.js.map +1 -1
  235. package/dist/components/cms/field-collaboration.d.ts +1 -1
  236. package/dist/components/cms/field-collaboration.js +1 -1
  237. package/dist/components/cms/field-collaboration.js.map +1 -1
  238. package/dist/components/cms/field-connection.d.ts +1 -1
  239. package/dist/components/cms/field-connection.js +2 -2
  240. package/dist/components/cms/field-connection.js.map +1 -1
  241. package/dist/components/cms/field-control-lib.d.ts +15 -1
  242. package/dist/components/cms/field-control-lib.js +23 -4
  243. package/dist/components/cms/field-control-lib.js.map +1 -1
  244. package/dist/components/cms/field-control.js +4 -4
  245. package/dist/components/cms/field-control.js.map +1 -1
  246. package/dist/components/cms/field-renderer.d.ts +1 -1
  247. package/dist/components/cms/field-renderer.js +2 -2
  248. package/dist/components/cms/field-renderer.js.map +1 -1
  249. package/dist/components/cms/filter-builder.d.ts +1 -1
  250. package/dist/components/cms/filter-builder.js +3 -3
  251. package/dist/components/cms/filter-builder.js.map +1 -1
  252. package/dist/components/cms/gate-validation.d.ts +21 -30
  253. package/dist/components/cms/gate-validation.js +11 -32
  254. package/dist/components/cms/gate-validation.js.map +1 -1
  255. package/dist/components/cms/import-export.js +2 -2
  256. package/dist/components/cms/import-export.js.map +1 -1
  257. package/dist/components/cms/issue-copy.js +1 -1
  258. package/dist/components/cms/issue-copy.js.map +1 -1
  259. package/dist/components/cms/issues-pane.js +3 -3
  260. package/dist/components/cms/issues-pane.js.map +1 -1
  261. package/dist/components/cms/object-array-field.js +6 -6
  262. package/dist/components/cms/object-array-field.js.map +1 -1
  263. package/dist/components/cms/prop-fields.d.ts +7 -2
  264. package/dist/components/cms/prop-fields.js +14 -7
  265. package/dist/components/cms/prop-fields.js.map +1 -1
  266. package/dist/components/cms/reference-confirm.d.ts +1 -1
  267. package/dist/components/cms/reference-confirm.js +2 -2
  268. package/dist/components/cms/reference-confirm.js.map +1 -1
  269. package/dist/components/cms/relation-picker.js +2 -2
  270. package/dist/components/cms/relation-picker.js.map +1 -1
  271. package/dist/components/cms/rich-text/block-style.js +1 -1
  272. package/dist/components/cms/rich-text/block-style.js.map +1 -1
  273. package/dist/components/cms/rich-text/component-embed.js +4 -4
  274. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  275. package/dist/components/cms/rich-text/editor.js +3 -3
  276. package/dist/components/cms/rich-text/editor.js.map +1 -1
  277. package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
  278. package/dist/components/cms/rich-text/embed-picker.js +3 -3
  279. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  280. package/dist/components/cms/rich-text/embed-props.js +3 -3
  281. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  282. package/dist/components/cms/rich-text/extensions.js +1 -1
  283. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  284. package/dist/components/cms/rich-text/font-size-control.d.ts +1 -1
  285. package/dist/components/cms/rich-text/font-size-control.js +2 -2
  286. package/dist/components/cms/rich-text/font-size-control.js.map +1 -1
  287. package/dist/components/cms/rich-text/insert.d.ts +20 -9
  288. package/dist/components/cms/rich-text/insert.js +25 -17
  289. package/dist/components/cms/rich-text/insert.js.map +1 -1
  290. package/dist/components/cms/rich-text/lazy.js +1 -1
  291. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  292. package/dist/components/cms/rich-text/link-control.js +2 -2
  293. package/dist/components/cms/rich-text/link-control.js.map +1 -1
  294. package/dist/components/cms/rich-text/slash-command.js +3 -3
  295. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  296. package/dist/components/cms/rich-text/toolbar.js +1 -1
  297. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  298. package/dist/components/cms/rich-text/unknown-node.js +2 -2
  299. package/dist/components/cms/rich-text/unknown-node.js.map +1 -1
  300. package/dist/components/cms/rich-text-preview.js +2 -2
  301. package/dist/components/cms/rich-text-preview.js.map +1 -1
  302. package/dist/components/cms/row-actions.d.ts +1 -1
  303. package/dist/components/cms/row-actions.js.map +1 -1
  304. package/dist/components/cms/scalar-value-editor.d.ts +1 -1
  305. package/dist/components/cms/scalar-value-editor.js +2 -2
  306. package/dist/components/cms/scalar-value-editor.js.map +1 -1
  307. package/dist/components/cms/use-discard-controller.d.ts +1 -1
  308. package/dist/components/cms/use-discard-controller.js +1 -1
  309. package/dist/components/cms/use-discard-controller.js.map +1 -1
  310. package/dist/components/cms/use-document-draft.d.ts +7 -1
  311. package/dist/components/cms/use-document-draft.js +16 -2
  312. package/dist/components/cms/use-document-draft.js.map +1 -1
  313. package/dist/components/cms/use-document-versions.js +1 -1
  314. package/dist/components/cms/use-document-versions.js.map +1 -1
  315. package/dist/components/cms/use-field-focus.d.ts +1 -1
  316. package/dist/components/cms/use-field-focus.js +2 -2
  317. package/dist/components/cms/use-field-focus.js.map +1 -1
  318. package/dist/components/cms/use-live-url.d.ts +1 -1
  319. package/dist/components/cms/use-live-url.js +3 -3
  320. package/dist/components/cms/use-live-url.js.map +1 -1
  321. package/dist/components/cms/use-publish-controller.d.ts +2 -3
  322. package/dist/components/cms/use-publish-controller.js +8 -20
  323. package/dist/components/cms/use-publish-controller.js.map +1 -1
  324. package/dist/components/cms/use-published-perspective.d.ts +1 -1
  325. package/dist/components/cms/use-published-perspective.js.map +1 -1
  326. package/dist/components/cms/use-restore-version.d.ts +1 -1
  327. package/dist/components/cms/use-restore-version.js +1 -1
  328. package/dist/components/cms/use-restore-version.js.map +1 -1
  329. package/dist/components/cms/version-compare-lazy.js +1 -1
  330. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  331. package/dist/components/cms/version-compare.js +1 -1
  332. package/dist/components/cms/version-compare.js.map +1 -1
  333. package/dist/components/cms/versions-pane.js +11 -4
  334. package/dist/components/cms/versions-pane.js.map +1 -1
  335. package/dist/components/cms/view-bar.js +3 -3
  336. package/dist/components/cms/view-bar.js.map +1 -1
  337. package/dist/components/collection-view/cms-collection-view-source.d.ts +1 -1
  338. package/dist/components/collection-view/cms-collection-view-source.js +5 -3
  339. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  340. package/dist/components/collection-view/collection-view-context.d.ts +1 -1
  341. package/dist/components/collection-view/collection-view-context.js +1 -1
  342. package/dist/components/collection-view/collection-view-context.js.map +1 -1
  343. package/dist/components/collection-view/collection-view-surface.d.ts +13 -0
  344. package/dist/components/collection-view/collection-view-surface.js +18 -0
  345. package/dist/components/collection-view/collection-view-surface.js.map +1 -0
  346. package/dist/components/components/component-detail.js +10 -10
  347. package/dist/components/components/component-detail.js.map +1 -1
  348. package/dist/components/components/component-preview.d.ts +2 -2
  349. package/dist/components/components/component-preview.js +5 -5
  350. package/dist/components/components/component-preview.js.map +1 -1
  351. package/dist/components/components/component-status.d.ts +1 -1
  352. package/dist/components/components/component-status.js +1 -1
  353. package/dist/components/components/component-status.js.map +1 -1
  354. package/dist/components/components/components-index.js +2 -2
  355. package/dist/components/components/components-index.js.map +1 -1
  356. package/dist/components/components/field-inspector.js +1 -1
  357. package/dist/components/components/field-inspector.js.map +1 -1
  358. package/dist/components/components/registry-browser.js +3 -3
  359. package/dist/components/components/registry-browser.js.map +1 -1
  360. package/dist/components/components/taxonomy.d.ts +1 -1
  361. package/dist/components/components/taxonomy.js.map +1 -1
  362. package/dist/components/components/transfer-dialog.js +4 -3
  363. package/dist/components/components/transfer-dialog.js.map +1 -1
  364. package/dist/components/components/transfer.d.ts +10 -10
  365. package/dist/components/components/transfer.js +13 -12
  366. package/dist/components/components/transfer.js.map +1 -1
  367. package/dist/components/current-user-menu.js +1 -1
  368. package/dist/components/current-user-menu.js.map +1 -1
  369. package/dist/components/delivery-health-banner.js +2 -2
  370. package/dist/components/delivery-health-banner.js.map +1 -1
  371. package/dist/components/deployment-setup-gate.js +3 -3
  372. package/dist/components/deployment-setup-gate.js.map +1 -1
  373. package/dist/components/dev-login-badge.js +2 -2
  374. package/dist/components/dev-login-badge.js.map +1 -1
  375. package/dist/components/guards.js +1 -1
  376. package/dist/components/guards.js.map +1 -1
  377. package/dist/components/inspector/document-panes.d.ts +54 -0
  378. package/dist/components/inspector/document-panes.js +41 -0
  379. package/dist/components/inspector/document-panes.js.map +1 -0
  380. package/dist/components/inspector/inspector-sidebar.js +1 -1
  381. package/dist/components/inspector/inspector-sidebar.js.map +1 -1
  382. package/dist/components/nav-issues-registry.d.ts +53 -0
  383. package/dist/components/nav-issues-registry.js +69 -0
  384. package/dist/components/nav-issues-registry.js.map +1 -0
  385. package/dist/components/nav-issues.js +4 -4
  386. package/dist/components/nav-issues.js.map +1 -1
  387. package/dist/components/operation-error.d.ts +1 -1
  388. package/dist/components/operation-error.js +4 -4
  389. package/dist/components/operation-error.js.map +1 -1
  390. package/dist/components/playground-badge.js +2 -2
  391. package/dist/components/playground-badge.js.map +1 -1
  392. package/dist/components/project-list.js +3 -3
  393. package/dist/components/project-list.js.map +1 -1
  394. package/dist/components/project-session.d.ts +2 -2
  395. package/dist/components/project-session.js +5 -5
  396. package/dist/components/project-session.js.map +1 -1
  397. package/dist/components/project-switcher.js +2 -2
  398. package/dist/components/project-switcher.js.map +1 -1
  399. package/dist/components/rail-store-status.js +1 -1
  400. package/dist/components/rail-store-status.js.map +1 -1
  401. package/dist/components/rail-view-source.d.ts +6 -5
  402. package/dist/components/rail-view-source.js +4 -3
  403. package/dist/components/rail-view-source.js.map +1 -1
  404. package/dist/components/records/record-lens.d.ts +51 -0
  405. package/dist/components/records/record-lens.js +156 -0
  406. package/dist/components/records/record-lens.js.map +1 -0
  407. package/dist/components/records/record-rail.d.ts +86 -0
  408. package/dist/components/records/record-rail.js +241 -0
  409. package/dist/components/records/record-rail.js.map +1 -0
  410. package/dist/components/redirects/redirects-schema.js +1 -1
  411. package/dist/components/redirects/redirects-schema.js.map +1 -1
  412. package/dist/components/references/references-panel.js +2 -2
  413. package/dist/components/references/references-panel.js.map +1 -1
  414. package/dist/components/section-nav.js +27 -11
  415. package/dist/components/section-nav.js.map +1 -1
  416. package/dist/components/section-state.js +2 -2
  417. package/dist/components/section-state.js.map +1 -1
  418. package/dist/components/section-surface.js +2 -2
  419. package/dist/components/section-surface.js.map +1 -1
  420. package/dist/components/session-expired-overlay.js +3 -3
  421. package/dist/components/session-expired-overlay.js.map +1 -1
  422. package/dist/components/settings-form.js +1 -1
  423. package/dist/components/settings-form.js.map +1 -1
  424. package/dist/components/sign-in-needed-badge.js +1 -1
  425. package/dist/components/sign-in-needed-badge.js.map +1 -1
  426. package/dist/components/sign-in-view.d.ts +2 -2
  427. package/dist/components/sign-in-view.js +5 -5
  428. package/dist/components/sign-in-view.js.map +1 -1
  429. package/dist/components/studio-context.js +3 -3
  430. package/dist/components/studio-context.js.map +1 -1
  431. package/dist/components/studio-project-screen.js +30 -3
  432. package/dist/components/studio-project-screen.js.map +1 -1
  433. package/dist/components/studio-shell.js +6 -5
  434. package/dist/components/studio-shell.js.map +1 -1
  435. package/dist/components/theme-context.d.ts +1 -1
  436. package/dist/components/theme-context.js +2 -2
  437. package/dist/components/theme-context.js.map +1 -1
  438. package/dist/components/use-hydration-phase.d.ts +1 -1
  439. package/dist/components/use-hydration-phase.js +1 -1
  440. package/dist/components/use-hydration-phase.js.map +1 -1
  441. package/dist/components/users/invite-user-bar.d.ts +27 -0
  442. package/dist/components/users/invite-user-bar.js +25 -9
  443. package/dist/components/users/invite-user-bar.js.map +1 -1
  444. package/dist/components/users/role-policy-drift-issues.d.ts +38 -0
  445. package/dist/components/users/role-policy-drift-issues.js +71 -0
  446. package/dist/components/users/role-policy-drift-issues.js.map +1 -0
  447. package/dist/components/users/users-collection-view-source.d.ts +1 -1
  448. package/dist/components/users/users-collection-view-source.js +4 -2
  449. package/dist/components/users/users-collection-view-source.js.map +1 -1
  450. package/dist/components/users/users-module.js +1 -1
  451. package/dist/components/users/users-module.js.map +1 -1
  452. package/dist/components/users/users-schema.d.ts +10 -1
  453. package/dist/components/users/users-schema.js +45 -25
  454. package/dist/components/users/users-schema.js.map +1 -1
  455. package/dist/components/users/users-store.js +3 -3
  456. package/dist/components/users/users-store.js.map +1 -1
  457. package/dist/components/workspace-projects.d.ts +1 -1
  458. package/dist/components/workspace-projects.js +1 -1
  459. package/dist/components/workspace-projects.js.map +1 -1
  460. package/dist/lib/ai/apply-content.js +56 -116
  461. package/dist/lib/ai/apply-content.js.map +1 -1
  462. package/dist/lib/ai/chat-context.d.ts +8 -0
  463. package/dist/lib/ai/chat-context.js +37 -6
  464. package/dist/lib/ai/chat-context.js.map +1 -1
  465. package/dist/lib/ai/chat-state.d.ts +6 -0
  466. package/dist/lib/ai/chat-state.js.map +1 -1
  467. package/dist/lib/ai/constraints.d.ts +20 -5
  468. package/dist/lib/ai/constraints.js +125 -33
  469. package/dist/lib/ai/constraints.js.map +1 -1
  470. package/dist/lib/ai/fake-provider.d.ts +16 -3
  471. package/dist/lib/ai/fake-provider.js +22 -6
  472. package/dist/lib/ai/fake-provider.js.map +1 -1
  473. package/dist/lib/ai/index.d.ts +3 -1
  474. package/dist/lib/ai/index.js +3 -1
  475. package/dist/lib/ai/index.js.map +1 -1
  476. package/dist/lib/ai/leash.d.ts +34 -0
  477. package/dist/lib/ai/leash.js +28 -0
  478. package/dist/lib/ai/leash.js.map +1 -0
  479. package/dist/lib/ai/node-handler.d.ts +1 -1
  480. package/dist/lib/ai/node-handler.js.map +1 -1
  481. package/dist/lib/ai/rich-text-input.d.ts +8 -8
  482. package/dist/lib/ai/rich-text-input.js +2 -1
  483. package/dist/lib/ai/rich-text-input.js.map +1 -1
  484. package/dist/lib/ai/rich-text-translate.d.ts +27 -4
  485. package/dist/lib/ai/rich-text-translate.js +28 -10
  486. package/dist/lib/ai/rich-text-translate.js.map +1 -1
  487. package/dist/lib/ai/shape.d.ts +13 -13
  488. package/dist/lib/ai/shape.js +69 -93
  489. package/dist/lib/ai/shape.js.map +1 -1
  490. package/dist/lib/ai/tools.d.ts +36 -41
  491. package/dist/lib/ai/tools.js +83 -87
  492. package/dist/lib/ai/tools.js.map +1 -1
  493. package/dist/lib/ai/translate-document.d.ts +32 -4
  494. package/dist/lib/ai/translate-document.js +145 -47
  495. package/dist/lib/ai/translate-document.js.map +1 -1
  496. package/dist/lib/ai/types.d.ts +34 -23
  497. package/dist/lib/ai/types.js.map +1 -1
  498. package/dist/lib/ai/update-fields.d.ts +15 -12
  499. package/dist/lib/ai/update-fields.js +112 -188
  500. package/dist/lib/ai/update-fields.js.map +1 -1
  501. package/dist/lib/ai/writer-form.d.ts +116 -0
  502. package/dist/lib/ai/writer-form.js +429 -0
  503. package/dist/lib/ai/writer-form.js.map +1 -0
  504. package/dist/lib/assets/upload.d.ts +31 -2
  505. package/dist/lib/assets/upload.js +61 -6
  506. package/dist/lib/assets/upload.js.map +1 -1
  507. package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
  508. package/dist/lib/clipboard/os-clipboard.js +8 -4
  509. package/dist/lib/clipboard/os-clipboard.js.map +1 -1
  510. package/dist/lib/clipboard/paste-issues.js +1 -1
  511. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  512. package/dist/lib/clipboard/paste-target.d.ts +18 -9
  513. package/dist/lib/clipboard/paste-target.js +10 -9
  514. package/dist/lib/clipboard/paste-target.js.map +1 -1
  515. package/dist/lib/cms/field-patch.d.ts +0 -10
  516. package/dist/lib/cms/field-patch.js +4 -30
  517. package/dist/lib/cms/field-patch.js.map +1 -1
  518. package/dist/lib/cms/published-documents.js +1 -1
  519. package/dist/lib/cms/published-documents.js.map +1 -1
  520. package/dist/lib/cms-views/view-model.d.ts +9 -9
  521. package/dist/lib/cms-views/view-model.js +2 -2
  522. package/dist/lib/cms-views/view-model.js.map +1 -1
  523. package/dist/lib/collection-view/collection-view-source.d.ts +14 -5
  524. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  525. package/dist/lib/data/collection-content.js +3 -2
  526. package/dist/lib/data/collection-content.js.map +1 -1
  527. package/dist/lib/data/content-session-refresh.d.ts +1 -1
  528. package/dist/lib/data/content-session-refresh.js.map +1 -1
  529. package/dist/lib/data/playground-unload-guard.d.ts +1 -1
  530. package/dist/lib/data/playground-unload-guard.js +1 -1
  531. package/dist/lib/data/playground-unload-guard.js.map +1 -1
  532. package/dist/lib/dnd/drop-rules.d.ts +25 -31
  533. package/dist/lib/dnd/drop-rules.js +53 -59
  534. package/dist/lib/dnd/drop-rules.js.map +1 -1
  535. package/dist/lib/engine-version.d.ts +1 -1
  536. package/dist/lib/engine-version.js +1 -1
  537. package/dist/lib/engine-version.js.map +1 -1
  538. package/dist/lib/i18n/i18n.js +1 -1
  539. package/dist/lib/i18n/i18n.js.map +1 -1
  540. package/dist/lib/i18n/messages.de.js +44 -3
  541. package/dist/lib/i18n/messages.de.js.map +1 -1
  542. package/dist/lib/i18n/messages.en.d.ts +38 -2
  543. package/dist/lib/i18n/messages.en.js +51 -3
  544. package/dist/lib/i18n/messages.en.js.map +1 -1
  545. package/dist/lib/operations/project-runtime-config.d.ts +34 -6
  546. package/dist/lib/operations/project-runtime-config.js +50 -10
  547. package/dist/lib/operations/project-runtime-config.js.map +1 -1
  548. package/dist/lib/operations/publish-with-revision.d.ts +33 -1
  549. package/dist/lib/operations/publish-with-revision.js +32 -0
  550. package/dist/lib/operations/publish-with-revision.js.map +1 -1
  551. package/dist/lib/operations/runtime-validator.d.ts +19 -68
  552. package/dist/lib/operations/runtime-validator.js +12 -138
  553. package/dist/lib/operations/runtime-validator.js.map +1 -1
  554. package/dist/lib/operations/studio-operations.d.ts +17 -2
  555. package/dist/lib/operations/studio-operations.js +18 -11
  556. package/dist/lib/operations/studio-operations.js.map +1 -1
  557. package/dist/lib/persistence/convex-adapter.d.ts +16 -3
  558. package/dist/lib/persistence/convex-adapter.js +83 -47
  559. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  560. package/dist/lib/persistence/index.js +14 -1
  561. package/dist/lib/persistence/index.js.map +1 -1
  562. package/dist/lib/persona/labels.js +4 -2
  563. package/dist/lib/persona/labels.js.map +1 -1
  564. package/dist/lib/query/operations-query.d.ts +1 -1
  565. package/dist/lib/query/operations-query.js +1 -1
  566. package/dist/lib/query/operations-query.js.map +1 -1
  567. package/dist/lib/references/reference-model.js +1 -1
  568. package/dist/lib/references/reference-model.js.map +1 -1
  569. package/dist/lib/references/references-store.js +1 -1
  570. package/dist/lib/references/references-store.js.map +1 -1
  571. package/dist/lib/workspace/index.d.ts +14 -11
  572. package/dist/lib/workspace/index.js +20 -24
  573. package/dist/lib/workspace/index.js.map +1 -1
  574. package/dist/mount.d.ts +28 -0
  575. package/dist/mount.js +27 -0
  576. package/dist/mount.js.map +1 -0
  577. package/dist/router/root.js +10 -10
  578. package/dist/router/root.js.map +1 -1
  579. package/dist/ui/badge.d.ts +1 -1
  580. package/dist/ui/button.d.ts +2 -2
  581. package/package.json +15 -11
  582. package/dist/components/cms/cms-composition-validators.d.ts +0 -16
  583. package/dist/components/cms/cms-composition-validators.js +0 -133
  584. package/dist/components/cms/cms-composition-validators.js.map +0 -1
  585. package/dist/components/cms/publish-clean.d.ts +0 -28
  586. package/dist/components/cms/publish-clean.js +0 -38
  587. package/dist/components/cms/publish-clean.js.map +0 -1
  588. package/dist/components/redirects/redirects-collection-view-source.d.ts +0 -53
  589. package/dist/components/redirects/redirects-collection-view-source.js +0 -146
  590. package/dist/components/redirects/redirects-collection-view-source.js.map +0 -1
  591. package/dist/components/redirects/redirects-module.d.ts +0 -21
  592. package/dist/components/redirects/redirects-module.js +0 -40
  593. package/dist/components/redirects/redirects-module.js.map +0 -1
  594. package/dist/components/redirects/redirects-store.d.ts +0 -68
  595. package/dist/components/redirects/redirects-store.js +0 -132
  596. package/dist/components/redirects/redirects-store.js.map +0 -1
  597. package/dist/lib/assets/asset-placements.d.ts +0 -28
  598. package/dist/lib/assets/asset-placements.js +0 -83
  599. package/dist/lib/assets/asset-placements.js.map +0 -1
@@ -3,11 +3,17 @@ import { convexTest } from 'convex-test'
3
3
  import type { ConvexHttpClient } from 'convex/browser'
4
4
  import type { FunctionReference } from 'convex/server'
5
5
  import { fieldPath, measureMaxDepth, nodePropPath } from '@elytracms/core/cms-core'
6
- import { PERSISTENCE_CONTRACT_SCENARIOS, replaceAtCurrentRevision } from '@elytracms/core/persistence'
6
+ import {
7
+ CONTRACT_RECORD_KINDS,
8
+ PERSISTENCE_CONTRACT_SCENARIOS,
9
+ publishCurrentDraft,
10
+ replaceAtCurrentRevision,
11
+ } from '@elytracms/core/persistence'
7
12
  import type { ContractContext } from '@elytracms/core/persistence'
8
13
  import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
9
14
  import type { ReactiveDocumentClient } from '../src/lib/persistence/convex-client'
10
15
  import schema from './schema'
16
+ import { defineRecordFunctions } from './record-functions'
11
17
 
12
18
  /**
13
19
  * EC-454 — Convex-side contract parity, restored.
@@ -36,6 +42,11 @@ import schema from './schema'
36
42
  const modules = {
37
43
  ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
38
44
  './_generated/api.ts': async () => ({}),
45
+ // EC-616: the contract's FIXTURE record kinds, registered as test-only modules exactly as
46
+ // an addon's convex module registers its kind (one module per kind, named by the kind).
47
+ ...Object.fromEntries(
48
+ CONTRACT_RECORD_KINDS.map((kind) => [`./${kind.module}.ts`, async () => defineRecordFunctions(kind)]),
49
+ ),
39
50
  }
40
51
 
41
52
  /** The config-owned slug this deployment's records are stamped with (EC-179). */
@@ -190,8 +201,7 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
190
201
  )
191
202
 
192
203
  // Pinning a version re-runs maintenance: published rows mirror the draft rows.
193
- const v = await adapter.cms.saveDocumentVersion(projectId, doc, { label: 'published' })
194
- await adapter.cms.publishDocumentVersion(projectId, ref, v.version)
204
+ await publishCurrentDraft(adapter.cms, projectId, ref)
195
205
  expect(
196
206
  (await adapter.references.outbound(projectId, source, { variant: 'published' })).length,
197
207
  ).toBe(2)
@@ -266,6 +276,18 @@ describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth c
266
276
  })
267
277
  expect(atRest).toBeDefined()
268
278
  expect(measureMaxDepth(atRest).depth).toBeLessThanOrEqual(16) // folded at rest
279
+
280
+ // EC-585: the publish step's version row now carries `kind` — a top-level scalar
281
+ // beside the folded snapshot, so the WHOLE stored row stays within the cap.
282
+ await publishCurrentDraft(adapter.cms, PROJECT_ID, { collection: doc.collection, id: doc.id })
283
+ const versionRow = await t.run(async (ctx) =>
284
+ ctx.db
285
+ .query('documentVersions')
286
+ .withIndex('by_doc_version', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('version', 1))
287
+ .unique(),
288
+ )
289
+ expect(versionRow?.kind).toBe('publish')
290
+ expect(measureMaxDepth(versionRow).depth).toBeLessThanOrEqual(16)
269
291
  })
270
292
  })
271
293
 
@@ -1,12 +1,14 @@
1
1
  import { v } from 'convex/values'
2
- import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, unfoldValue, variantOf } from '@elytracms/core/cms-core'
2
+ import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, liveRedirectRecords, unfoldValue, variantOf } from '@elytracms/core/cms-core'
3
3
  import type { Perspective } from '@elytracms/core/cms-core'
4
4
  import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
5
+ import { constantTimeEquals } from '@elytracms/core/revalidation'
5
6
  import type { AssetRecord } from '@elytracms/core/persistence'
6
7
  import studioPackage from '../package.json'
7
8
  import { query } from './functions'
8
9
  import type { QueryCtx } from './functions'
9
10
  import { requireCapability } from './guard'
11
+ import { loadRedirectEntries } from './redirects'
10
12
 
11
13
  // EC-207: routes/redirects are config-sourced (authored in elytra.config.ts and
12
14
  // resolved by studio-core), NOT backend data. The delivery snapshot no longer
@@ -156,13 +158,12 @@ export async function loadSnapshot(
156
158
 
157
159
  const assets = await loadDeliveryAssets(ctx, projectId)
158
160
 
159
- // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect
160
- // is stored but never live, in either perspective. Projected to the routing
161
- // `RedirectRecord` shape the router/`resolvePage` consume.
162
- const redirectRows = await ctx.db.query('redirects').withIndex('by_redirect').collect()
163
- const redirects = redirectRows
164
- .filter((row) => row.enabled)
165
- .map((row) => ({ id: row.redirectId, from: row.from, to: row.to, permanent: row.permanent }))
161
+ // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
162
+ // stored but never live, in either perspective. EC-616: read through the redirect
163
+ // kind's one server-side reader (records + the marked legacy tolerant read) and
164
+ // projected by the ONE enabled-redirect reader, `liveRedirectRecords` (EC-590 / EC-627 —
165
+ // this was the last hand-built copy on Convex).
166
+ const redirects = liveRedirectRecords(await loadRedirectEntries(ctx, projectId))
166
167
 
167
168
  return {
168
169
  projectId,
@@ -214,7 +215,9 @@ export const draftSnapshot = query({
214
215
  if (expected === undefined || expected.length === 0) {
215
216
  throw new Error('unauthorized: draft preview is not configured on this deployment')
216
217
  }
217
- if (args.token !== expected) {
218
+ // EC-617: the one timing-safe comparison (`constantTimeEquals`, the MAC-compare the
219
+ // revalidation and ingest verifiers rest on) — never a string `!==`.
220
+ if (!(await constantTimeEquals(args.token, expected))) {
218
221
  throw new Error('unauthorized: invalid draft preview token')
219
222
  }
220
223
  return loadSnapshot(ctx, { projectId: args.projectId }, DRAFT_PERSPECTIVE)
@@ -39,8 +39,7 @@ const modules = {
39
39
 
40
40
  const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
41
41
  const getDocument = makeFunctionReference<'query'>('cms:getDocument')
42
- const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
43
- const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
42
+ const publishDocument = makeFunctionReference<'mutation'>('cms:publishDocument')
44
43
  const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
45
44
  const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
46
45
  const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
@@ -104,19 +103,18 @@ async function saveDraft({ asAdmin }: Backend, collection: string, id: string, v
104
103
  })
105
104
  }
106
105
 
106
+ /**
107
+ * Publish `values` the way the one publish operation does (EC-585): the draft holds them,
108
+ * then the one-step `publishDocument` records a publish-kind version and pins it.
109
+ */
107
110
  async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
108
- const version = (await backend.asAdmin.mutation(saveVersion, {
111
+ await saveDraft(backend, collection, id, values)
112
+ const current = (await backend.asAdmin.query(getDocument, { collection, docId: id })) as { revision: number }
113
+ await backend.asAdmin.mutation(publishDocument, {
109
114
  collection,
110
115
  docId: id,
111
- snapshot: doc(collection, id, values),
112
- createdAt: STAMP,
113
- })) as { version: number }
114
- await backend.asAdmin.mutation(publishVersion, {
115
- collection,
116
- docId: id,
117
- version: version.version,
118
- updatedAt: STAMP,
119
- publishedAt: STAMP,
116
+ expectedRevision: current.revision,
117
+ at: STAMP,
120
118
  })
121
119
  }
122
120
 
package/convex/guard.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { getAuthUserId } from '@convex-dev/auth/server'
2
- import { defaultRolePolicy, roleGrantsCapability } from '@elytracms/core/persistence'
2
+ import { roleGrantsCapability } from '@elytracms/core/persistence'
3
3
  import type { OperationCapabilityId, ProjectRole, RolePolicy } from '@elytracms/core/persistence'
4
4
  import type { QueryCtx } from './functions'
5
+ import { deployedRolePolicy } from './roles'
5
6
 
6
7
  /**
7
8
  * Server-side authorization guards (EC-150). The operations chokepoint in the
@@ -18,9 +19,10 @@ import type { QueryCtx } from './functions'
18
19
  * imported here rather than re-declared. Handlers gate on CAPABILITIES
19
20
  * (`content.read` / `content.write` / `project.administer`), not literal roles
20
21
  * (EC-240c), so a project's custom role set enforces server-side by the same
21
- * policy the operations layer uses. The Convex trust boundary reads the shipped
22
- * {@link defaultRolePolicy}; a project overriding roles injects its policy (the
23
- * documented follow-up seam).
22
+ * policy the operations layer uses. EC-605: the policy is the project's `roles`
23
+ * declaration as DEPLOYED — the copy the `elytra` CLI provisions into
24
+ * `ELYTRA_ROLE_POLICY` (`./roles` `deployedRolePolicy`: the default when unset, a
25
+ * thrown, located error when malformed — never a silent default).
24
26
  *
25
27
  * Failures throw plain `Error('unauthorized: …')` — Convex transports the
26
28
  * message; the app's adapters normalize thrown backend errors onto the
@@ -78,13 +80,13 @@ export async function memberRole(ctx: GuardCtx, userId: string): Promise<Project
78
80
  * This is the ONE role-enforcement primitive at the trust boundary — every
79
81
  * handler gates on a capability rather than a literal role, so a project's
80
82
  * custom role set is honoured by the shared policy. `policy` defaults to the
81
- * shipped {@link defaultRolePolicy}; the override seam (a project injecting its
82
- * own policy) is the documented follow-up.
83
+ * deployment's policy (EC-605, {@link deployedRolePolicy}); the parameter stays for a
84
+ * caller that must judge against another (tests).
83
85
  */
84
86
  export async function requireCapability(
85
87
  ctx: GuardCtx,
86
88
  capability: OperationCapabilityId,
87
- policy: RolePolicy = defaultRolePolicy,
89
+ policy: RolePolicy = deployedRolePolicy(),
88
90
  ): Promise<string> {
89
91
  const userId = await requireUserId(ctx)
90
92
  const actual = await memberRole(ctx, userId)
@@ -33,9 +33,20 @@ function routeTable(http: HttpRouter): string[] {
33
33
  .sort()
34
34
  }
35
35
 
36
- describe('defineStudioHttp — the engine registers no HTTP routes of its own (EC-481)', () => {
37
- it('contributes an EMPTY route table beyond what the injected auth registers', () => {
38
- expect(routeTable(defineStudioHttp({ auth: silentAuth }))).toEqual([])
36
+ /** EC-617: the engine's ONE route — the public-ingest receiver, an exact path (the EC-481 bar: a real client, a narrow prefix). */
37
+ const ENGINE_ROUTES = ['POST /elytra/ingest']
38
+
39
+ describe('defineStudioHttp — the engine registers exactly one HTTP route of its own (EC-481 → EC-617)', () => {
40
+ it('contributes ONLY the ingest receiver beyond what the injected auth registers', () => {
41
+ expect(routeTable(defineStudioHttp({ auth: silentAuth }))).toEqual(ENGINE_ROUTES)
42
+ })
43
+
44
+ it('the receiver is an EXACT path, POST only — it claims no prefix', () => {
45
+ const http = defineStudioHttp({ auth: silentAuth })
46
+ expect(http.lookup('/elytra/ingest', 'POST')).not.toBeNull()
47
+ expect(http.lookup('/elytra/ingest/anything', 'POST')).toBeNull()
48
+ expect(http.lookup('/elytra/', 'POST')).toBeNull()
49
+ expect(http.lookup('/elytra/ingest', 'GET')).toBeNull()
39
50
  })
40
51
 
41
52
  it('control: the recorder DOES see routes, so the empty result above is real', () => {
@@ -45,10 +56,9 @@ describe('defineStudioHttp — the engine registers no HTTP routes of its own (E
45
56
  http.route({ pathPrefix: '/api/auth/callback/', method: 'POST', handler })
46
57
  },
47
58
  }
48
- expect(routeTable(defineStudioHttp({ auth }))).toEqual([
49
- 'GET /.well-known/jwks.json',
50
- 'POST /api/auth/callback/*',
51
- ])
59
+ expect(routeTable(defineStudioHttp({ auth }))).toEqual(
60
+ ['GET /.well-known/jwks.json', 'POST /api/auth/callback/*', ...ENGINE_ROUTES].sort(),
61
+ )
52
62
  })
53
63
 
54
64
  it('passes the auth routes through untouched — nothing is added, dropped, or reordered', () => {
@@ -61,7 +71,7 @@ describe('defineStudioHttp — the engine registers no HTTP routes of its own (E
61
71
  }
62
72
  },
63
73
  }
64
- expect(routeTable(defineStudioHttp({ auth }))).toEqual([...registered].sort())
74
+ expect(routeTable(defineStudioHttp({ auth }))).toEqual([...registered, ...ENGINE_ROUTES].sort())
65
75
  })
66
76
  })
67
77
 
package/convex/http.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { httpRouter } from 'convex/server'
2
2
  import type { HttpRouter } from 'convex/server'
3
+ import { INGEST_RECEIVER_PATH } from '@elytracms/core/ingest'
4
+ import { ingestReceiver } from './ingest'
5
+ import type { IngestHandlers } from './ingest'
3
6
 
4
7
  /** The slice of the Convex Auth object the studio's HTTP routes need. */
5
8
  interface AuthHttpRoutes {
@@ -8,33 +11,36 @@ interface AuthHttpRoutes {
8
11
 
9
12
  /**
10
13
  * EC-252 — the studio's HTTP routes as a factory. Pure engine infra ships in the
11
- * package; only the consumer's `auth` (provider choice) is injected. The consumer's
12
- * `convex/http.ts` is a two-liner:
14
+ * package; only the consumer's `auth` (provider choice) and — since EC-617 — its ingest
15
+ * handlers are injected. The consumer's `convex/http.ts`:
13
16
  *
14
17
  * ```ts
15
18
  * import { auth } from './auth'
16
19
  * export default defineStudioHttp({ auth })
20
+ * // with an addon that handles ingest payloads (one import, one entry):
21
+ * // import { formsIngest } from '@elytracms/forms/convex'
22
+ * // export default defineStudioHttp({ auth, ingest: { submission: formsIngest } })
17
23
  * ```
18
24
  *
19
- * ## The engine registers NO HTTP routes of its own (EC-481)
25
+ * ## The engine registers exactly ONE route of its own (EC-481 → EC-617)
20
26
  *
21
- * Everything on this router comes from Convex Auth's `addHttpRoutes` — the JWT
22
- * discovery endpoints (`/.well-known/*`, EC-150) and, only when a consumer supplies
23
- * an OAuth provider, `/api/auth/signin/*` + `/api/auth/callback/*`.
27
+ * Everything else on this router comes from Convex Auth's `addHttpRoutes` — the JWT
28
+ * discovery endpoints (`/.well-known/*`, EC-150) and, only when a consumer supplies an
29
+ * OAuth provider, `/api/auth/signin/*` + `/api/auth/callback/*`.
24
30
  *
25
- * The engine used to add one more: `GET /api/projects/*`, the EC-157 Bearer-token
26
- * registry-sync endpoint backing `builder typegen`/`builder check`. EC-470 deleted
27
- * that CLI and with it the endpoint's only client; the handler had in any case
28
- * answered with config-owned placeholders since EC-287 retired the project graph
29
- * (`project: null`, `collections: []`). EC-481 removed the route and its handler
30
- * together rather than leave a Bearer-authenticated surface serving stub data on
31
- * every deployment. `http-routes.test.ts` pins that shut.
32
- *
33
- * A future engine-owned HTTP surface is fine — but it needs a real client and a
34
- * narrower prefix than `/api/projects/`.
31
+ * EC-481 removed the engine's former `GET /api/projects/*` (a Bearer-authenticated
32
+ * registry-sync endpoint with no client left) and set the bar for any future one: "a
33
+ * real client and a narrower prefix". EC-617's public-ingest RECEIVER is that route:
34
+ * `POST /elytra/ingest` — an EXACT path, not a prefix; its client is the site's
35
+ * `createIngestRoute` (`@elytracms/next`), which signs every payload with the
36
+ * server-held secret, and `elytra doctor`'s probe. It is mounted on every deployment,
37
+ * with or without handlers: closed (401) until `ELYTRA_INGEST_SECRET` is set, and a
38
+ * payload kind with no registered handler is refused visibly (422), never dropped.
39
+ * `http-routes.test.ts` pins the table.
35
40
  */
36
- export function defineStudioHttp({ auth }: { auth: AuthHttpRoutes }): HttpRouter {
41
+ export function defineStudioHttp({ auth, ingest }: { auth: AuthHttpRoutes; ingest?: IngestHandlers }): HttpRouter {
37
42
  const http = httpRouter()
38
43
  auth.addHttpRoutes(http)
44
+ http.route({ path: INGEST_RECEIVER_PATH, method: 'POST', handler: ingestReceiver(ingest) })
39
45
  return http
40
46
  }
@@ -0,0 +1,326 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { z } from 'zod'
5
+ import {
6
+ INGEST_MAX_BODY_BYTES,
7
+ INGEST_RECEIVER_PATH,
8
+ INGEST_SIGNATURE_HEADER,
9
+ signedIngestRequest,
10
+ } from '@elytracms/core/ingest'
11
+ import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
12
+ import { defineRecordKind } from '@elytracms/core/persistence'
13
+ import schema from './schema'
14
+ import { internalMutation } from './functions'
15
+ import { defineStudioHttp } from './http'
16
+ import { claimIngestUpload, ingestHandlerArgs, parseIngestPayload, recordIngestUpload, unclaimedIngestUploads } from './ingest'
17
+ import type { IngestHandlerRef } from './ingest'
18
+ import { saveRecord } from './record-functions'
19
+ import { createIngestRoute } from '../../next/src/ingest'
20
+
21
+ beforeEach(() => {
22
+ vi.useFakeTimers()
23
+ vi.setSystemTime(new Date('2026-10-26T12:00:00.000Z'))
24
+ })
25
+ afterEach(() => {
26
+ vi.useRealTimers()
27
+ delete env()['ELYTRA_INGEST_SECRET']
28
+ delete env()['DRAFT_PREVIEW_TOKEN']
29
+ })
30
+
31
+ /**
32
+ * EC-617 — the ONE inbound receiver (`POST /elytra/ingest`, `defineStudioHttp`), on
33
+ * convex-test with a project-registered handler (a test module standing in for an addon's
34
+ * `./convex` entry): 401 unconfigured / unsigned / wrong secret, 413, 400, the probe, a
35
+ * kind with NO handler refused visibly (422 — EC-618's gate), a handler's write and its
36
+ * refusal relayed, the upload URL issued after the handler accepted, and the claim checks.
37
+ * Then the site's route (`createIngestRoute`) driven END TO END into this receiver.
38
+ *
39
+ * convex-test runs `crypto.subtle` under edge-runtime; that the Convex default runtime has
40
+ * it too is the operator's dev-deployment check (the revalidation signer already runs there).
41
+ */
42
+
43
+ const SECRET = 'ec617-shared-secret'
44
+ function env(): Record<string, string | undefined> {
45
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
46
+ }
47
+
48
+ /** The submission an addon would declare (forms, EC-613) — here a test kind. */
49
+ const testSubmissionKind = defineRecordKind({
50
+ kind: 'testSubmission',
51
+ module: 'testIngest',
52
+ keys: ['form'],
53
+ fields: {
54
+ form: z.string().min(1),
55
+ values: z.record(z.string(), z.unknown()),
56
+ files: z.array(z.string()).optional(),
57
+ },
58
+ })
59
+
60
+ /** The addon's handler module: one internal mutation, the shape `IngestHandlerRef` names. */
61
+ const testIngestModule = {
62
+ handle: internalMutation({
63
+ args: ingestHandlerArgs,
64
+ handler: async (ctx, args): Promise<IngestHandlerResult> => {
65
+ const payload: IngestPayload = parseIngestPayload(args.payload)
66
+ if (payload.kind === 'upload') {
67
+ return recordIngestUpload(ctx, payload, args.receivedAt, { maxBytes: 1000, contentTypes: ['image/*'] })
68
+ }
69
+ if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
70
+ if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
71
+ const files: string[] = []
72
+ for (const claim of payload.uploads ?? []) {
73
+ const claimed = await claimIngestUpload(ctx, claim, payload.target, args.receivedAt)
74
+ if (!claimed.ok) return claimed
75
+ files.push(claimed.storageId)
76
+ }
77
+ const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
78
+ await saveRecord(
79
+ ctx,
80
+ testSubmissionKind,
81
+ id,
82
+ { form: payload.target, values: payload.data, ...(files.length ? { files } : {}) },
83
+ { createdAt: args.receivedAt, updatedAt: args.receivedAt },
84
+ )
85
+ return { ok: true, id }
86
+ },
87
+ }),
88
+ }
89
+
90
+ const handle = makeFunctionReference<'mutation'>('testIngest:handle') as unknown as IngestHandlerRef
91
+ const silentAuth = { addHttpRoutes: () => {} }
92
+
93
+ function backend(handlers: Parameters<typeof defineStudioHttp>[0]['ingest'] = { submission: handle }) {
94
+ return convexTest(schema, {
95
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
96
+ './_generated/api.ts': async () => ({}),
97
+ './testIngest.ts': async () => testIngestModule,
98
+ './http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: handlers }) }),
99
+ })
100
+ }
101
+
102
+ async function signedPost(t: ReturnType<typeof backend>, body: Parameters<typeof signedIngestRequest>[0], secret = SECRET) {
103
+ const signed = await signedIngestRequest(body, secret)
104
+ return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
105
+ }
106
+
107
+ const SUBMISSION = { kind: 'submission', target: 'contact', data: { email: 'ada@example.test' } } as const
108
+
109
+ describe('the ingest receiver — refusals (EC-617)', () => {
110
+ it('401 { configured: false } while ELYTRA_INGEST_SECRET is unset — even for a signed body', async () => {
111
+ const t = backend()
112
+ const response = await signedPost(t, SUBMISSION)
113
+ expect(response.status).toBe(401)
114
+ expect(await response.json()).toMatchObject({ configured: false })
115
+ })
116
+
117
+ it('401 unsigned, or signed with another secret; control: the right secret is accepted', async () => {
118
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
119
+ const t = backend()
120
+ const unsigned = await t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', body: JSON.stringify(SUBMISSION) })
121
+ expect(unsigned.status).toBe(401)
122
+ expect((await signedPost(t, SUBMISSION, 'wrong-secret')).status).toBe(401)
123
+ expect((await signedPost(t, SUBMISSION)).status).toBe(200)
124
+ })
125
+
126
+ it('413 over the body cap', async () => {
127
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
128
+ const t = backend()
129
+ const big = JSON.stringify({ ...SUBMISSION, data: { note: 'x'.repeat(INGEST_MAX_BODY_BYTES) } })
130
+ const response = await t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', body: big })
131
+ expect(response.status).toBe(413)
132
+ })
133
+
134
+ it('400 for a signed body that is not an ingest payload', async () => {
135
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
136
+ const t = backend()
137
+ const raw = JSON.stringify({ kind: 'submission' })
138
+ const { headers } = await signedIngestRequest({ kind: 'probe' }, SECRET) // headers for another body…
139
+ const badShape = await t.fetch(INGEST_RECEIVER_PATH, {
140
+ method: 'POST',
141
+ headers: { ...headers, [INGEST_SIGNATURE_HEADER]: (await signedIngestRequestRaw(raw)) },
142
+ body: raw,
143
+ })
144
+ expect(badShape.status).toBe(400)
145
+ })
146
+
147
+ it('the doctor’s signed probe answers 200 and dispatches nothing', async () => {
148
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
149
+ const t = backend()
150
+ const response = await signedPost(t, { kind: 'probe' })
151
+ expect(response.status).toBe(200)
152
+ expect(await response.json()).toEqual({ ok: true, probe: true })
153
+ expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
154
+ })
155
+
156
+ it('a kind with NO registered handler is refused visibly (422), never dropped — an event without analytics', async () => {
157
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
158
+ const t = backend()
159
+ const response = await signedPost(t, { kind: 'event', data: { page: '/' } })
160
+ expect(response.status).toBe(422)
161
+ expect(await response.json()).toMatchObject({ kind: 'event', handled: false })
162
+ // …and with NO handlers at all, a submission is refused the same way.
163
+ const bare = backend({})
164
+ expect((await signedPost(bare, SUBMISSION)).status).toBe(422)
165
+ })
166
+ })
167
+
168
+ /**
169
+ * Store a file as an upload URL would: Convex records the uploader's Content-Type on the
170
+ * `_storage` row; convex-test 0.0.54's `storage.store` records only size + sha256, so the
171
+ * harness sets the type the real upload would have carried.
172
+ */
173
+ async function storeFile(t: ReturnType<typeof backend>, bytes: number, contentType: string) {
174
+ return t.run(async (ctx) => {
175
+ const id = await ctx.storage.store(new Blob([new Uint8Array(bytes)], { type: contentType }))
176
+ await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
177
+ return id
178
+ })
179
+ }
180
+
181
+ /** Sign an arbitrary raw body (for the invalid-shape case the typed builder refuses to build). */
182
+ async function signedIngestRequestRaw(raw: string): Promise<string> {
183
+ const { signRevalidationBody } = await import('@elytracms/core/revalidation')
184
+ return signRevalidationBody(raw, SECRET)
185
+ }
186
+
187
+ describe('the key rule: a payload Convex cannot carry is a 400, never a failed mutation (EC-617)', () => {
188
+ /** POST a raw body signed with the right secret (the typed builder would refuse it). */
189
+ async function postRaw(t: ReturnType<typeof backend>, payload: unknown) {
190
+ const raw = JSON.stringify(payload)
191
+ return t.fetch(INGEST_RECEIVER_PATH, {
192
+ method: 'POST',
193
+ headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signedIngestRequestRaw(raw) },
194
+ body: raw,
195
+ })
196
+ }
197
+
198
+ it('refuses a `$`-led key, a non-ASCII key and a nested one with 400 naming the key; nothing is dispatched', async () => {
199
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
200
+ const t = backend()
201
+ for (const data of [{ $honeypot: '' }, { straße: 'Hauptstraße 1' }, { address: { 'straße': 'x' } }]) {
202
+ const response = await postRaw(t, { ...SUBMISSION, data })
203
+ expect(response.status, JSON.stringify(data)).toBe(400)
204
+ const body = (await response.json()) as { issues: { path: (string | number)[]; message: string }[] }
205
+ expect(body.issues[0]!.path[0]).toBe('data')
206
+ expect(body.issues[0]!.message).toMatch(/field name/i)
207
+ }
208
+ expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
209
+ })
210
+
211
+ it('refuses data nested deeper than Convex arguments carry, with 400', async () => {
212
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
213
+ const t = backend()
214
+ let deep: Record<string, unknown> = { leaf: 1 }
215
+ for (let level = 0; level < 80; level++) deep = { n: deep }
216
+ expect((await postRaw(t, { ...SUBMISSION, data: deep })).status).toBe(400)
217
+ })
218
+
219
+ it('control: printable-ASCII keys (spaces, dashes, dots included) reach the handler', async () => {
220
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
221
+ const t = backend()
222
+ const response = await postRaw(t, { ...SUBMISSION, data: { 'first name': 'Ada', 'e-mail': 'a@b.c', 'a.b': 1, honeypot$: '' } })
223
+ expect(response.status).toBe(200)
224
+ })
225
+ })
226
+
227
+ describe('the ingest receiver — dispatch (EC-617)', () => {
228
+ it('a submission reaches the project’s handler, which writes a record; its refusal is relayed', async () => {
229
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
230
+ const t = backend()
231
+ const accepted = await signedPost(t, SUBMISSION)
232
+ expect(accepted.status).toBe(200)
233
+ expect(await accepted.json()).toEqual({ ok: true, id: 'sub_1' })
234
+ const stored = await t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((r) => r.kind === 'testSubmission'))
235
+ expect(stored.map((row) => [row.recordId, row.key1, row.data])).toEqual([
236
+ ['sub_1', 'contact', { form: 'contact', values: { email: 'ada@example.test' } }],
237
+ ])
238
+ const refused = await signedPost(t, { ...SUBMISSION, target: 'closed' })
239
+ expect(refused.status).toBe(404)
240
+ expect(await refused.json()).toEqual({ error: 'No form “closed”.' })
241
+ })
242
+
243
+ it('an upload the handler accepts gets ONE storage URL and an upload id; one it refuses gets none', async () => {
244
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
245
+ const t = backend()
246
+ const upload = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
247
+ const issued = await signedPost(t, upload)
248
+ expect(issued.status).toBe(200)
249
+ const body = (await issued.json()) as { uploadId: string; uploadUrl: string; expiresInSeconds: number }
250
+ expect(body.uploadId).toMatch(/^upload_/)
251
+ expect(typeof body.uploadUrl).toBe('string')
252
+ expect(body.expiresInSeconds).toBe(3600)
253
+ expect((await signedPost(t, { ...upload, size: 5000 })).status).toBe(413) // the handler's policy
254
+ expect((await signedPost(t, { ...upload, contentType: 'application/x-msdownload' })).status).toBe(415)
255
+ })
256
+
257
+ it('a submission claims its upload: the stored file must match the declaration; a mismatch is deleted', async () => {
258
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
259
+ const t = backend()
260
+ const declare = async (contentType: string, size: number) =>
261
+ ((await (await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size })).json()) as {
262
+ uploadId: string
263
+ }).uploadId
264
+ const good = await declare('image/png', 500)
265
+ const goodFile = await storeFile(t, 400, 'image/png')
266
+ const ok = await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })
267
+ expect(ok.status).toBe(200)
268
+ // A second claim of the same upload is a conflict.
269
+ expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })).status).toBe(409)
270
+
271
+ const liar = await declare('image/png', 500)
272
+ const wrongType = await storeFile(t, 10, 'text/html')
273
+ expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: liar, storageId: wrongType }] })).status).toBe(415)
274
+ expect(await t.run((ctx) => ctx.storage.get(wrongType))).toBeNull() // deleted, never kept
275
+ })
276
+
277
+ it('an upload no submission claims is listed once its life is over (the visible orphan finding)', async () => {
278
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
279
+ const t = backend()
280
+ await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 10 })
281
+ const later = (offsetMs: number) => new Date(Date.parse('2026-10-26T12:00:00.000Z') + offsetMs).toISOString()
282
+ expect(await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(30 * 60 * 1000)))).toEqual([])
283
+ const orphans = await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(2 * 60 * 60 * 1000)))
284
+ expect(orphans.map((orphan) => orphan.field)).toEqual(['cv'])
285
+ })
286
+ })
287
+
288
+ describe('the site’s route → the receiver, end to end (EC-617)', () => {
289
+ it('a visitor’s submission through createIngestRoute lands a record; a mismatched site secret is a 502', async () => {
290
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
291
+ const t = backend()
292
+ const viaReceiver = (async (url: string, init: RequestInit) =>
293
+ t.fetch(new URL(url).pathname, init as RequestInit)) as unknown as typeof fetch
294
+ const site = createIngestRoute({ secret: SECRET, siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
295
+ const visitor = new Request('https://example.test/api/ingest', {
296
+ method: 'POST',
297
+ headers: { 'content-type': 'application/json', origin: 'https://example.test' },
298
+ body: JSON.stringify(SUBMISSION),
299
+ })
300
+ const answer = await site.POST(visitor)
301
+ expect(answer.status).toBe(200)
302
+ expect(await answer.json()).toEqual({ ok: true, id: 'sub_1' })
303
+
304
+ const misconfigured = createIngestRoute({ secret: 'not-the-same', siteUrl: 'https://happy-otter-123.convex.site', fetchImpl: viaReceiver })
305
+ const rejected = await misconfigured.POST(
306
+ new Request('https://example.test/api/ingest', { method: 'POST', body: JSON.stringify(SUBMISSION) }),
307
+ )
308
+ expect(rejected.status).toBe(502)
309
+ })
310
+ })
311
+
312
+ describe('draftSnapshot’s token check is the timing-safe helper (EC-617)', () => {
313
+ const draftSnapshot = makeFunctionReference<'query'>('delivery:draftSnapshot')
314
+
315
+ it('the right token reads; a wrong, a prefix or an empty token is unauthorized; unset closes it', async () => {
316
+ const t = backend()
317
+ await expect(t.query(draftSnapshot, { projectId: 'demo', token: 'x' })).rejects.toThrow(/not configured/)
318
+ env()['DRAFT_PREVIEW_TOKEN'] = 'preview-token-123'
319
+ await expect(t.query(draftSnapshot, { projectId: 'demo', token: 'preview-token-123' })).resolves.toMatchObject({
320
+ perspective: { variant: 'draft' },
321
+ })
322
+ for (const token of ['preview-token-124', 'preview-token-12', '', 'preview-token-1234']) {
323
+ await expect(t.query(draftSnapshot, { projectId: 'demo', token })).rejects.toThrow(/invalid draft preview token/)
324
+ }
325
+ })
326
+ })