@elytracms/studio 0.0.93 → 0.0.95

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 (541) hide show
  1. package/convex/analytics-cost.test.ts +817 -0
  2. package/convex/asset-alt-migration.test.ts +2 -2
  3. package/convex/asset-meta-patch.test.ts +218 -0
  4. package/convex/asset-replace-delete-race.test.ts +127 -0
  5. package/convex/assets.ts +27 -2
  6. package/convex/auth.ts +5 -2
  7. package/convex/authority.ts +35 -11
  8. package/convex/contract-parity.test.ts +7 -0
  9. package/convex/delivery-redirects.test.ts +167 -0
  10. package/convex/delivery.ts +23 -10
  11. package/convex/guard.ts +9 -7
  12. package/convex/http-routes.test.ts +18 -8
  13. package/convex/http.ts +23 -17
  14. package/convex/ingest.test.ts +326 -0
  15. package/convex/ingest.ts +281 -0
  16. package/convex/invites.ts +13 -5
  17. package/convex/members.ts +10 -4
  18. package/convex/rail-write-parity.test.ts +6 -6
  19. package/convex/record-functions.ts +325 -0
  20. package/convex/records.test.ts +156 -0
  21. package/convex/redirect-migration.test.ts +205 -0
  22. package/convex/redirect-revalidate.test.ts +5 -1
  23. package/convex/redirects.ts +120 -79
  24. package/convex/role-policy.test.ts +315 -0
  25. package/convex/roles.ts +83 -0
  26. package/convex/schema.ts +36 -0
  27. package/convex/triggers.ts +21 -1
  28. package/dist/addon.d.ts +71 -0
  29. package/dist/addon.js +67 -0
  30. package/dist/addon.js.map +1 -0
  31. package/dist/components/account/account-settings.js +2 -2
  32. package/dist/components/account/account-settings.js.map +1 -1
  33. package/dist/components/account/credential-panel.js +2 -2
  34. package/dist/components/account/credential-panel.js.map +1 -1
  35. package/dist/components/account/self-account-schema.d.ts +6 -15
  36. package/dist/components/account/self-account-schema.js +52 -34
  37. package/dist/components/account/self-account-schema.js.map +1 -1
  38. package/dist/components/account/self-account-view-source.d.ts +1 -1
  39. package/dist/components/account/self-account-view-source.js +6 -4
  40. package/dist/components/account/self-account-view-source.js.map +1 -1
  41. package/dist/components/ai/ai-chat-panel-lazy.js +1 -1
  42. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  43. package/dist/components/ai/ai-chat-panel.js +6 -6
  44. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  45. package/dist/components/ai/ai-cms-panel.js +2 -2
  46. package/dist/components/ai/ai-cms-panel.js.map +1 -1
  47. package/dist/components/ai/ai-panel-toggle.js +4 -4
  48. package/dist/components/ai/ai-panel-toggle.js.map +1 -1
  49. package/dist/components/ai/chat-ui.js +2 -2
  50. package/dist/components/ai/chat-ui.js.map +1 -1
  51. package/dist/components/ai/proposal-card.d.ts +2 -2
  52. package/dist/components/ai/proposal-card.js +2 -2
  53. package/dist/components/ai/proposal-card.js.map +1 -1
  54. package/dist/components/ai/proposal-issues.js +2 -2
  55. package/dist/components/ai/proposal-issues.js.map +1 -1
  56. package/dist/components/ai/translate-document-action.js +2 -2
  57. package/dist/components/ai/translate-document-action.js.map +1 -1
  58. package/dist/components/ai/translate-document-dialog.js +6 -6
  59. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  60. package/dist/components/ai/translate-skipped.d.ts +1 -1
  61. package/dist/components/ai/translate-skipped.js +2 -2
  62. package/dist/components/ai/translate-skipped.js.map +1 -1
  63. package/dist/components/app-views/default-modules.d.ts +50 -2
  64. package/dist/components/app-views/default-modules.js +65 -20
  65. package/dist/components/app-views/default-modules.js.map +1 -1
  66. package/dist/components/app-views/module.d.ts +53 -3
  67. package/dist/components/app-views/module.js +14 -7
  68. package/dist/components/app-views/module.js.map +1 -1
  69. package/dist/components/app-views/sidebar.js +1 -1
  70. package/dist/components/app-views/sidebar.js.map +1 -1
  71. package/dist/components/app-views/studio-config.d.ts +11 -2
  72. package/dist/components/app-views/studio-config.js.map +1 -1
  73. package/dist/components/assets/asset-detail.js +28 -13
  74. package/dist/components/assets/asset-detail.js.map +1 -1
  75. package/dist/components/assets/asset-grid.js +2 -2
  76. package/dist/components/assets/asset-grid.js.map +1 -1
  77. package/dist/components/assets/asset-locale.d.ts +9 -0
  78. package/dist/components/assets/asset-locale.js +16 -0
  79. package/dist/components/assets/asset-locale.js.map +1 -0
  80. package/dist/components/assets/asset-meta-label.d.ts +12 -0
  81. package/dist/components/assets/asset-meta-label.js +15 -0
  82. package/dist/components/assets/asset-meta-label.js.map +1 -0
  83. package/dist/components/assets/asset-metadata.d.ts +69 -15
  84. package/dist/components/assets/asset-metadata.js +99 -11
  85. package/dist/components/assets/asset-metadata.js.map +1 -1
  86. package/dist/components/assets/asset-search.js +8 -2
  87. package/dist/components/assets/asset-search.js.map +1 -1
  88. package/dist/components/assets/asset-thumb.js +9 -5
  89. package/dist/components/assets/asset-thumb.js.map +1 -1
  90. package/dist/components/assets/assets-collection-view-source.d.ts +25 -11
  91. package/dist/components/assets/assets-collection-view-source.js +78 -26
  92. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  93. package/dist/components/assets/assets-module.js +1 -1
  94. package/dist/components/assets/assets-module.js.map +1 -1
  95. package/dist/components/assets/assets-store.d.ts +2 -2
  96. package/dist/components/assets/assets-store.js +22 -9
  97. package/dist/components/assets/assets-store.js.map +1 -1
  98. package/dist/components/assets/assets-view-preference.js +1 -1
  99. package/dist/components/assets/assets-view-preference.js.map +1 -1
  100. package/dist/components/assets/replace-file.js +1 -1
  101. package/dist/components/assets/replace-file.js.map +1 -1
  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 +1 -1
  106. package/dist/components/builder/blocks-model.js +3 -3
  107. package/dist/components/builder/blocks-model.js.map +1 -1
  108. package/dist/components/builder/blocks-panel.d.ts +1 -1
  109. package/dist/components/builder/blocks-panel.js +4 -4
  110. package/dist/components/builder/blocks-panel.js.map +1 -1
  111. package/dist/components/builder/builder-store.d.ts +21 -0
  112. package/dist/components/builder/builder-store.js +24 -2
  113. package/dist/components/builder/builder-store.js.map +1 -1
  114. package/dist/components/builder/canvas-breakpoints.js +1 -1
  115. package/dist/components/builder/canvas-breakpoints.js.map +1 -1
  116. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  117. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  118. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  119. package/dist/components/builder/canvas-dnd.d.ts +2 -2
  120. package/dist/components/builder/canvas-dnd.js +1 -1
  121. package/dist/components/builder/canvas-dnd.js.map +1 -1
  122. package/dist/components/builder/canvas-inline-editing.d.ts +1 -1
  123. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  124. package/dist/components/builder/canvas-overlay-geometry.d.ts +1 -1
  125. package/dist/components/builder/canvas-overlay-geometry.js.map +1 -1
  126. package/dist/components/builder/canvas-rebase.d.ts +77 -0
  127. package/dist/components/builder/canvas-rebase.js +169 -0
  128. package/dist/components/builder/canvas-rebase.js.map +1 -0
  129. package/dist/components/builder/canvas-render-context.d.ts +7 -4
  130. package/dist/components/builder/canvas-render-context.js +21 -13
  131. package/dist/components/builder/canvas-render-context.js.map +1 -1
  132. package/dist/components/builder/canvas-theme-notice.js +2 -2
  133. package/dist/components/builder/canvas-theme-notice.js.map +1 -1
  134. package/dist/components/builder/canvas.js +3 -3
  135. package/dist/components/builder/canvas.js.map +1 -1
  136. package/dist/components/builder/clipboard-actions.d.ts +2 -2
  137. package/dist/components/builder/clipboard-actions.js +4 -4
  138. package/dist/components/builder/clipboard-actions.js.map +1 -1
  139. package/dist/components/builder/composition-field-editor-lazy.js +1 -1
  140. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  141. package/dist/components/builder/composition-field-editor.d.ts +21 -3
  142. package/dist/components/builder/composition-field-editor.js +48 -30
  143. package/dist/components/builder/composition-field-editor.js.map +1 -1
  144. package/dist/components/builder/drag-session.d.ts +1 -1
  145. package/dist/components/builder/drag-session.js.map +1 -1
  146. package/dist/components/builder/fixture-thumbnail.js +1 -1
  147. package/dist/components/builder/fixture-thumbnail.js.map +1 -1
  148. package/dist/components/builder/insert-context.d.ts +1 -1
  149. package/dist/components/builder/insert-context.js +3 -3
  150. package/dist/components/builder/insert-context.js.map +1 -1
  151. package/dist/components/builder/layout-inspector.js +1 -1
  152. package/dist/components/builder/layout-inspector.js.map +1 -1
  153. package/dist/components/builder/node-factory.js +15 -3
  154. package/dist/components/builder/node-factory.js.map +1 -1
  155. package/dist/components/builder/node-title.js +1 -1
  156. package/dist/components/builder/node-title.js.map +1 -1
  157. package/dist/components/builder/page-tree.d.ts +1 -1
  158. package/dist/components/builder/page-tree.js +5 -5
  159. package/dist/components/builder/page-tree.js.map +1 -1
  160. package/dist/components/builder/placement-refusal.js +3 -3
  161. package/dist/components/builder/placement-refusal.js.map +1 -1
  162. package/dist/components/builder/property-inspector.js +7 -5
  163. package/dist/components/builder/property-inspector.js.map +1 -1
  164. package/dist/components/builder/reuse-context.js +4 -4
  165. package/dist/components/builder/reuse-context.js.map +1 -1
  166. package/dist/components/builder/reuse.js +1 -1
  167. package/dist/components/builder/reuse.js.map +1 -1
  168. package/dist/components/builder/rich-text-toolbar.js +1 -1
  169. package/dist/components/builder/rich-text-toolbar.js.map +1 -1
  170. package/dist/components/builder/run-inspector.js +11 -7
  171. package/dist/components/builder/run-inspector.js.map +1 -1
  172. package/dist/components/builder/selection-overlay.js +3 -3
  173. package/dist/components/builder/selection-overlay.js.map +1 -1
  174. package/dist/components/builder/spacing-handles.d.ts +2 -2
  175. package/dist/components/builder/spacing-handles.js +3 -3
  176. package/dist/components/builder/spacing-handles.js.map +1 -1
  177. package/dist/components/cms/asset-field.d.ts +5 -2
  178. package/dist/components/cms/asset-field.js +36 -19
  179. package/dist/components/cms/asset-field.js.map +1 -1
  180. package/dist/components/cms/asset-picker.js +5 -3
  181. package/dist/components/cms/asset-picker.js.map +1 -1
  182. package/dist/components/cms/bulk-actions.d.ts +1 -1
  183. package/dist/components/cms/bulk-actions.js +2 -2
  184. package/dist/components/cms/bulk-actions.js.map +1 -1
  185. package/dist/components/cms/bulk-bar.d.ts +2 -2
  186. package/dist/components/cms/bulk-bar.js +2 -2
  187. package/dist/components/cms/bulk-bar.js.map +1 -1
  188. package/dist/components/cms/canvas-commit.d.ts +56 -0
  189. package/dist/components/cms/canvas-commit.js +35 -0
  190. package/dist/components/cms/canvas-commit.js.map +1 -0
  191. package/dist/components/cms/cell-edit.d.ts +1 -1
  192. package/dist/components/cms/cell-edit.js +1 -1
  193. package/dist/components/cms/cell-edit.js.map +1 -1
  194. package/dist/components/cms/cell.d.ts +1 -1
  195. package/dist/components/cms/cell.js +3 -3
  196. package/dist/components/cms/cell.js.map +1 -1
  197. package/dist/components/cms/cms-index.js +2 -2
  198. package/dist/components/cms/cms-index.js.map +1 -1
  199. package/dist/components/cms/cms-labels.js +2 -2
  200. package/dist/components/cms/cms-labels.js.map +1 -1
  201. package/dist/components/cms/cms-store.d.ts +2 -2
  202. package/dist/components/cms/cms-store.js +10 -10
  203. package/dist/components/cms/cms-store.js.map +1 -1
  204. package/dist/components/cms/collection-table.js +30 -14
  205. package/dist/components/cms/collection-table.js.map +1 -1
  206. package/dist/components/cms/commit-policy.d.ts +6 -2
  207. package/dist/components/cms/commit-policy.js +1 -1
  208. package/dist/components/cms/commit-policy.js.map +1 -1
  209. package/dist/components/cms/detail-editor.d.ts +5 -2
  210. package/dist/components/cms/detail-editor.js +31 -15
  211. package/dist/components/cms/detail-editor.js.map +1 -1
  212. package/dist/components/cms/details-pane.js +1 -1
  213. package/dist/components/cms/details-pane.js.map +1 -1
  214. package/dist/components/cms/doc-state.js +1 -1
  215. package/dist/components/cms/doc-state.js.map +1 -1
  216. package/dist/components/cms/document-diff-view.js +5 -5
  217. package/dist/components/cms/document-diff-view.js.map +1 -1
  218. package/dist/components/cms/document-editor-context.js +1 -1
  219. package/dist/components/cms/document-editor-context.js.map +1 -1
  220. package/dist/components/cms/document-staging.js +1 -1
  221. package/dist/components/cms/document-staging.js.map +1 -1
  222. package/dist/components/cms/document-url-path.js +2 -2
  223. package/dist/components/cms/document-url-path.js.map +1 -1
  224. package/dist/components/cms/duplicate-document.d.ts +1 -1
  225. package/dist/components/cms/duplicate-document.js.map +1 -1
  226. package/dist/components/cms/editable-cell.js +3 -3
  227. package/dist/components/cms/editable-cell.js.map +1 -1
  228. package/dist/components/cms/field-collaboration.d.ts +1 -1
  229. package/dist/components/cms/field-collaboration.js +1 -1
  230. package/dist/components/cms/field-collaboration.js.map +1 -1
  231. package/dist/components/cms/field-connection.d.ts +1 -1
  232. package/dist/components/cms/field-connection.js +2 -2
  233. package/dist/components/cms/field-connection.js.map +1 -1
  234. package/dist/components/cms/field-control-lib.js +1 -1
  235. package/dist/components/cms/field-control-lib.js.map +1 -1
  236. package/dist/components/cms/field-control.js +4 -4
  237. package/dist/components/cms/field-control.js.map +1 -1
  238. package/dist/components/cms/field-renderer.d.ts +1 -1
  239. package/dist/components/cms/field-renderer.js +2 -2
  240. package/dist/components/cms/field-renderer.js.map +1 -1
  241. package/dist/components/cms/filter-builder.d.ts +1 -1
  242. package/dist/components/cms/filter-builder.js +3 -3
  243. package/dist/components/cms/filter-builder.js.map +1 -1
  244. package/dist/components/cms/gate-validation.js +1 -1
  245. package/dist/components/cms/gate-validation.js.map +1 -1
  246. package/dist/components/cms/import-export.js +2 -2
  247. package/dist/components/cms/import-export.js.map +1 -1
  248. package/dist/components/cms/issue-copy.js +1 -1
  249. package/dist/components/cms/issue-copy.js.map +1 -1
  250. package/dist/components/cms/issues-pane.js +3 -3
  251. package/dist/components/cms/issues-pane.js.map +1 -1
  252. package/dist/components/cms/object-array-field.js +2 -2
  253. package/dist/components/cms/object-array-field.js.map +1 -1
  254. package/dist/components/cms/prop-fields.js +2 -2
  255. package/dist/components/cms/prop-fields.js.map +1 -1
  256. package/dist/components/cms/reference-confirm.d.ts +1 -1
  257. package/dist/components/cms/reference-confirm.js +2 -2
  258. package/dist/components/cms/reference-confirm.js.map +1 -1
  259. package/dist/components/cms/relation-picker.js +2 -2
  260. package/dist/components/cms/relation-picker.js.map +1 -1
  261. package/dist/components/cms/rich-text/block-style.js +1 -1
  262. package/dist/components/cms/rich-text/block-style.js.map +1 -1
  263. package/dist/components/cms/rich-text/component-embed.js +4 -4
  264. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  265. package/dist/components/cms/rich-text/editor.js +3 -3
  266. package/dist/components/cms/rich-text/editor.js.map +1 -1
  267. package/dist/components/cms/rich-text/embed-picker.js +3 -3
  268. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  269. package/dist/components/cms/rich-text/embed-props.js +3 -3
  270. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  271. package/dist/components/cms/rich-text/extensions.js +1 -1
  272. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  273. package/dist/components/cms/rich-text/font-size-control.d.ts +1 -1
  274. package/dist/components/cms/rich-text/font-size-control.js +2 -2
  275. package/dist/components/cms/rich-text/font-size-control.js.map +1 -1
  276. package/dist/components/cms/rich-text/lazy.js +1 -1
  277. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  278. package/dist/components/cms/rich-text/link-control.js +2 -2
  279. package/dist/components/cms/rich-text/link-control.js.map +1 -1
  280. package/dist/components/cms/rich-text/slash-command.js +3 -3
  281. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  282. package/dist/components/cms/rich-text/toolbar.js +1 -1
  283. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  284. package/dist/components/cms/rich-text/unknown-node.js +2 -2
  285. package/dist/components/cms/rich-text/unknown-node.js.map +1 -1
  286. package/dist/components/cms/rich-text-preview.js +2 -2
  287. package/dist/components/cms/rich-text-preview.js.map +1 -1
  288. package/dist/components/cms/row-actions.d.ts +1 -1
  289. package/dist/components/cms/row-actions.js.map +1 -1
  290. package/dist/components/cms/scalar-value-editor.d.ts +1 -1
  291. package/dist/components/cms/scalar-value-editor.js +2 -2
  292. package/dist/components/cms/scalar-value-editor.js.map +1 -1
  293. package/dist/components/cms/use-discard-controller.d.ts +1 -1
  294. package/dist/components/cms/use-discard-controller.js +1 -1
  295. package/dist/components/cms/use-discard-controller.js.map +1 -1
  296. package/dist/components/cms/use-document-draft.d.ts +69 -5
  297. package/dist/components/cms/use-document-draft.js +118 -34
  298. package/dist/components/cms/use-document-draft.js.map +1 -1
  299. package/dist/components/cms/use-document-versions.js +1 -1
  300. package/dist/components/cms/use-document-versions.js.map +1 -1
  301. package/dist/components/cms/use-field-focus.d.ts +1 -1
  302. package/dist/components/cms/use-field-focus.js +2 -2
  303. package/dist/components/cms/use-field-focus.js.map +1 -1
  304. package/dist/components/cms/use-live-url.d.ts +7 -2
  305. package/dist/components/cms/use-live-url.js +15 -12
  306. package/dist/components/cms/use-live-url.js.map +1 -1
  307. package/dist/components/cms/use-publish-controller.d.ts +1 -1
  308. package/dist/components/cms/use-publish-controller.js +1 -1
  309. package/dist/components/cms/use-publish-controller.js.map +1 -1
  310. package/dist/components/cms/use-published-perspective.d.ts +1 -1
  311. package/dist/components/cms/use-published-perspective.js.map +1 -1
  312. package/dist/components/cms/use-restore-version.d.ts +1 -1
  313. package/dist/components/cms/use-restore-version.js +1 -1
  314. package/dist/components/cms/use-restore-version.js.map +1 -1
  315. package/dist/components/cms/version-compare-lazy.js +1 -1
  316. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  317. package/dist/components/cms/version-compare.js +1 -1
  318. package/dist/components/cms/version-compare.js.map +1 -1
  319. package/dist/components/cms/versions-pane.js +3 -3
  320. package/dist/components/cms/versions-pane.js.map +1 -1
  321. package/dist/components/cms/view-bar.js +3 -3
  322. package/dist/components/cms/view-bar.js.map +1 -1
  323. package/dist/components/collection-view/cms-collection-view-source.d.ts +1 -1
  324. package/dist/components/collection-view/cms-collection-view-source.js +11 -7
  325. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  326. package/dist/components/collection-view/collection-view-context.d.ts +1 -1
  327. package/dist/components/collection-view/collection-view-context.js +1 -1
  328. package/dist/components/collection-view/collection-view-context.js.map +1 -1
  329. package/dist/components/collection-view/collection-view-surface.d.ts +13 -0
  330. package/dist/components/collection-view/collection-view-surface.js +18 -0
  331. package/dist/components/collection-view/collection-view-surface.js.map +1 -0
  332. package/dist/components/components/component-detail.js +10 -10
  333. package/dist/components/components/component-detail.js.map +1 -1
  334. package/dist/components/components/component-preview.d.ts +2 -2
  335. package/dist/components/components/component-preview.js +5 -5
  336. package/dist/components/components/component-preview.js.map +1 -1
  337. package/dist/components/components/component-status.d.ts +1 -1
  338. package/dist/components/components/component-status.js +1 -1
  339. package/dist/components/components/component-status.js.map +1 -1
  340. package/dist/components/components/components-index.js +2 -2
  341. package/dist/components/components/components-index.js.map +1 -1
  342. package/dist/components/components/field-inspector.js +1 -1
  343. package/dist/components/components/field-inspector.js.map +1 -1
  344. package/dist/components/components/registry-browser.js +3 -3
  345. package/dist/components/components/registry-browser.js.map +1 -1
  346. package/dist/components/components/taxonomy.d.ts +1 -1
  347. package/dist/components/components/taxonomy.js.map +1 -1
  348. package/dist/components/components/transfer-dialog.js +3 -3
  349. package/dist/components/components/transfer-dialog.js.map +1 -1
  350. package/dist/components/current-user-menu.js +1 -1
  351. package/dist/components/current-user-menu.js.map +1 -1
  352. package/dist/components/delivery-health-banner.js +2 -2
  353. package/dist/components/delivery-health-banner.js.map +1 -1
  354. package/dist/components/deployment-setup-gate.js +13 -4
  355. package/dist/components/deployment-setup-gate.js.map +1 -1
  356. package/dist/components/dev-login-badge.js +2 -2
  357. package/dist/components/dev-login-badge.js.map +1 -1
  358. package/dist/components/guards.js +1 -1
  359. package/dist/components/guards.js.map +1 -1
  360. package/dist/components/inspector/document-panes.d.ts +54 -0
  361. package/dist/components/inspector/document-panes.js +41 -0
  362. package/dist/components/inspector/document-panes.js.map +1 -0
  363. package/dist/components/inspector/inspector-sidebar.js +1 -1
  364. package/dist/components/inspector/inspector-sidebar.js.map +1 -1
  365. package/dist/components/nav-issues-registry.d.ts +53 -0
  366. package/dist/components/nav-issues-registry.js +69 -0
  367. package/dist/components/nav-issues-registry.js.map +1 -0
  368. package/dist/components/nav-issues.js +4 -4
  369. package/dist/components/nav-issues.js.map +1 -1
  370. package/dist/components/operation-error.d.ts +1 -1
  371. package/dist/components/operation-error.js +4 -4
  372. package/dist/components/operation-error.js.map +1 -1
  373. package/dist/components/playground-badge.js +2 -2
  374. package/dist/components/playground-badge.js.map +1 -1
  375. package/dist/components/project-list.js +3 -3
  376. package/dist/components/project-list.js.map +1 -1
  377. package/dist/components/project-session.d.ts +2 -2
  378. package/dist/components/project-session.js +5 -5
  379. package/dist/components/project-session.js.map +1 -1
  380. package/dist/components/project-switcher.js +2 -2
  381. package/dist/components/project-switcher.js.map +1 -1
  382. package/dist/components/rail-store-status.js +1 -1
  383. package/dist/components/rail-store-status.js.map +1 -1
  384. package/dist/components/rail-view-source.d.ts +6 -5
  385. package/dist/components/rail-view-source.js +4 -3
  386. package/dist/components/rail-view-source.js.map +1 -1
  387. package/dist/components/records/record-lens.d.ts +51 -0
  388. package/dist/components/records/record-lens.js +151 -0
  389. package/dist/components/records/record-lens.js.map +1 -0
  390. package/dist/components/records/record-rail.d.ts +86 -0
  391. package/dist/components/records/record-rail.js +241 -0
  392. package/dist/components/records/record-rail.js.map +1 -0
  393. package/dist/components/redirects/redirects-schema.js +1 -1
  394. package/dist/components/redirects/redirects-schema.js.map +1 -1
  395. package/dist/components/references/references-panel.js +2 -2
  396. package/dist/components/references/references-panel.js.map +1 -1
  397. package/dist/components/section-nav.js +27 -11
  398. package/dist/components/section-nav.js.map +1 -1
  399. package/dist/components/section-state.js +2 -2
  400. package/dist/components/section-state.js.map +1 -1
  401. package/dist/components/section-surface.js +2 -2
  402. package/dist/components/section-surface.js.map +1 -1
  403. package/dist/components/session-expired-overlay.js +3 -3
  404. package/dist/components/session-expired-overlay.js.map +1 -1
  405. package/dist/components/settings-form.js +1 -1
  406. package/dist/components/settings-form.js.map +1 -1
  407. package/dist/components/sign-in-needed-badge.js +1 -1
  408. package/dist/components/sign-in-needed-badge.js.map +1 -1
  409. package/dist/components/sign-in-view.d.ts +2 -2
  410. package/dist/components/sign-in-view.js +5 -5
  411. package/dist/components/sign-in-view.js.map +1 -1
  412. package/dist/components/studio-context.js +3 -3
  413. package/dist/components/studio-context.js.map +1 -1
  414. package/dist/components/studio-project-screen.js +30 -3
  415. package/dist/components/studio-project-screen.js.map +1 -1
  416. package/dist/components/studio-shell.js +6 -5
  417. package/dist/components/studio-shell.js.map +1 -1
  418. package/dist/components/theme-context.d.ts +1 -1
  419. package/dist/components/theme-context.js +2 -2
  420. package/dist/components/theme-context.js.map +1 -1
  421. package/dist/components/use-hydration-phase.d.ts +1 -1
  422. package/dist/components/use-hydration-phase.js +1 -1
  423. package/dist/components/use-hydration-phase.js.map +1 -1
  424. package/dist/components/users/invite-user-bar.d.ts +27 -0
  425. package/dist/components/users/invite-user-bar.js +25 -9
  426. package/dist/components/users/invite-user-bar.js.map +1 -1
  427. package/dist/components/users/role-policy-drift-issues.d.ts +38 -0
  428. package/dist/components/users/role-policy-drift-issues.js +71 -0
  429. package/dist/components/users/role-policy-drift-issues.js.map +1 -0
  430. package/dist/components/users/users-collection-view-source.d.ts +1 -1
  431. package/dist/components/users/users-collection-view-source.js +4 -2
  432. package/dist/components/users/users-collection-view-source.js.map +1 -1
  433. package/dist/components/users/users-module.js +1 -1
  434. package/dist/components/users/users-module.js.map +1 -1
  435. package/dist/components/users/users-schema.d.ts +10 -1
  436. package/dist/components/users/users-schema.js +45 -25
  437. package/dist/components/users/users-schema.js.map +1 -1
  438. package/dist/components/users/users-store.js +3 -3
  439. package/dist/components/users/users-store.js.map +1 -1
  440. package/dist/components/workspace-projects.d.ts +1 -1
  441. package/dist/components/workspace-projects.js +1 -1
  442. package/dist/components/workspace-projects.js.map +1 -1
  443. package/dist/lib/ai/index.d.ts +1 -1
  444. package/dist/lib/ai/index.js +1 -1
  445. package/dist/lib/ai/index.js.map +1 -1
  446. package/dist/lib/ai/node-handler.d.ts +1 -1
  447. package/dist/lib/ai/node-handler.js.map +1 -1
  448. package/dist/lib/ai/translate-document.js +1 -1
  449. package/dist/lib/ai/translate-document.js.map +1 -1
  450. package/dist/lib/assets/upload.d.ts +25 -7
  451. package/dist/lib/assets/upload.js +30 -21
  452. package/dist/lib/assets/upload.js.map +1 -1
  453. package/dist/lib/clipboard/paste-issues.js +1 -1
  454. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  455. package/dist/lib/clipboard/paste-target.js +2 -2
  456. package/dist/lib/clipboard/paste-target.js.map +1 -1
  457. package/dist/lib/cms/published-documents.d.ts +24 -6
  458. package/dist/lib/cms/published-documents.js +39 -6
  459. package/dist/lib/cms/published-documents.js.map +1 -1
  460. package/dist/lib/cms-views/view-model.d.ts +12 -99
  461. package/dist/lib/cms-views/view-model.js +22 -103
  462. package/dist/lib/cms-views/view-model.js.map +1 -1
  463. package/dist/lib/collection-view/collection-view-source.d.ts +13 -3
  464. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  465. package/dist/lib/data/content-session-refresh.d.ts +1 -1
  466. package/dist/lib/data/content-session-refresh.js.map +1 -1
  467. package/dist/lib/data/playground-unload-guard.d.ts +1 -1
  468. package/dist/lib/data/playground-unload-guard.js +1 -1
  469. package/dist/lib/data/playground-unload-guard.js.map +1 -1
  470. package/dist/lib/engine-version.d.ts +1 -1
  471. package/dist/lib/engine-version.js +1 -1
  472. package/dist/lib/engine-version.js.map +1 -1
  473. package/dist/lib/i18n/i18n.js +1 -1
  474. package/dist/lib/i18n/i18n.js.map +1 -1
  475. package/dist/lib/i18n/messages.de.js +11 -3
  476. package/dist/lib/i18n/messages.de.js.map +1 -1
  477. package/dist/lib/i18n/messages.en.d.ts +9 -1
  478. package/dist/lib/i18n/messages.en.js +16 -4
  479. package/dist/lib/i18n/messages.en.js.map +1 -1
  480. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  481. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  482. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  483. package/dist/lib/operations/project-runtime-config.d.ts +9 -0
  484. package/dist/lib/operations/project-runtime-config.js +13 -0
  485. package/dist/lib/operations/project-runtime-config.js.map +1 -1
  486. package/dist/lib/operations/studio-operations.d.ts +9 -1
  487. package/dist/lib/operations/studio-operations.js +6 -1
  488. package/dist/lib/operations/studio-operations.js.map +1 -1
  489. package/dist/lib/persistence/convex-adapter.d.ts +16 -3
  490. package/dist/lib/persistence/convex-adapter.js +78 -47
  491. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  492. package/dist/lib/persistence/deployment-readiness.js +14 -11
  493. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  494. package/dist/lib/persistence/index.js +14 -1
  495. package/dist/lib/persistence/index.js.map +1 -1
  496. package/dist/lib/persona/labels.js +2 -1
  497. package/dist/lib/persona/labels.js.map +1 -1
  498. package/dist/lib/query/index.d.ts +2 -2
  499. package/dist/lib/query/index.js +1 -1
  500. package/dist/lib/query/index.js.map +1 -1
  501. package/dist/lib/query/operations-query.d.ts +27 -1
  502. package/dist/lib/query/operations-query.js +13 -1
  503. package/dist/lib/query/operations-query.js.map +1 -1
  504. package/dist/lib/references/reference-model.js +1 -1
  505. package/dist/lib/references/reference-model.js.map +1 -1
  506. package/dist/lib/references/references-store.js +1 -1
  507. package/dist/lib/references/references-store.js.map +1 -1
  508. package/dist/lib/registry/index.js +2 -2
  509. package/dist/lib/registry/index.js.map +1 -1
  510. package/dist/lib/registry/preview.d.ts +10 -0
  511. package/dist/lib/registry/preview.js +13 -4
  512. package/dist/lib/registry/preview.js.map +1 -1
  513. package/dist/lib/routes/route-issues.js +6 -3
  514. package/dist/lib/routes/route-issues.js.map +1 -1
  515. package/dist/lib/workspace/index.d.ts +6 -0
  516. package/dist/lib/workspace/index.js +8 -0
  517. package/dist/lib/workspace/index.js.map +1 -1
  518. package/dist/lib/workspace/workspace-source.js +6 -7
  519. package/dist/lib/workspace/workspace-source.js.map +1 -1
  520. package/dist/mount.d.ts +28 -0
  521. package/dist/mount.js +27 -0
  522. package/dist/mount.js.map +1 -0
  523. package/dist/previews/entry-node.d.ts +14 -0
  524. package/dist/previews/entry-node.js +22 -0
  525. package/dist/previews/entry-node.js.map +1 -0
  526. package/dist/previews/harness.js +2 -16
  527. package/dist/previews/harness.js.map +1 -1
  528. package/dist/router/root.js +10 -10
  529. package/dist/router/root.js.map +1 -1
  530. package/dist/ui/badge.d.ts +1 -1
  531. package/dist/ui/button.d.ts +2 -2
  532. package/package.json +15 -11
  533. package/dist/components/redirects/redirects-collection-view-source.d.ts +0 -53
  534. package/dist/components/redirects/redirects-collection-view-source.js +0 -146
  535. package/dist/components/redirects/redirects-collection-view-source.js.map +0 -1
  536. package/dist/components/redirects/redirects-module.d.ts +0 -21
  537. package/dist/components/redirects/redirects-module.js +0 -40
  538. package/dist/components/redirects/redirects-module.js.map +0 -1
  539. package/dist/components/redirects/redirects-store.d.ts +0 -68
  540. package/dist/components/redirects/redirects-store.js +0 -132
  541. package/dist/components/redirects/redirects-store.js.map +0 -1
@@ -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
+ })
@@ -0,0 +1,281 @@
1
+ import { httpActionGeneric, makeFunctionReference } from 'convex/server'
2
+ import type { FunctionReference, PublicHttpAction } from 'convex/server'
3
+ import { v } from 'convex/values'
4
+ import type { GenericId } from 'convex/values'
5
+ import { z } from 'zod'
6
+ import {
7
+ INGEST_MAX_BODY_BYTES,
8
+ INGEST_MAX_UPLOAD_BYTES,
9
+ INGEST_SIGNATURE_HEADER,
10
+ INGEST_UPLOAD_TTL_MS,
11
+ ingestPayloadSchema,
12
+ readIngestReceiverBody,
13
+ verifyIngestSignature,
14
+ } from '@elytracms/core/ingest'
15
+ import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@elytracms/core/ingest'
16
+ import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
17
+ import { createUuidIdFactory } from '@elytracms/core/determinism'
18
+ import type { MutationCtx, QueryCtx } from './functions'
19
+ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
20
+
21
+ /**
22
+ * EC-617 — the ONE inbound receiver of the public ingest seam (ruling EC-565 § 6c), and the
23
+ * helpers an addon's ingest handler is written with. A factory module: it registers no
24
+ * function itself — `defineStudioHttp({ auth, ingest })` (`http.ts`) mounts
25
+ * {@link ingestReceiver} at `INGEST_RECEIVER_PATH` (`POST /elytra/ingest`, an exact path).
26
+ *
27
+ * The receiver, per request:
28
+ *
29
+ * 1. `ELYTRA_INGEST_SECRET` unset → 401 `{ configured: false }` — closed until configured;
30
+ * 2. body over `INGEST_MAX_BODY_BYTES` → 413;
31
+ * 3. the HMAC (`x-elytra-signature: sha256=…`) verified in constant time with the
32
+ * deployment's secret → 401 on anything else (the site's route signs with the same
33
+ * value, `INGEST_SECRET`);
34
+ * 4. the envelope (`@elytracms/core/ingest`) → 400 with its issues;
35
+ * 5. the doctor's `probe` → 200 `{ ok: true, probe: true }`, nothing dispatched;
36
+ * 6. dispatch by kind to the handler the PROJECT registered — `event` / `submission`, and
37
+ * `upload` to the submission handler (uploads are submission-scoped). A kind with no
38
+ * handler is REFUSED (422, naming the kind) — never dropped: an `event` with no
39
+ * analytics addon must fail visibly (EC-618's counsel gate relies on it);
40
+ * 7. an accepted `upload` gets ONE short-lived storage URL (`ctx.storage.generateUploadUrl`)
41
+ * — the bytes go browser → storage, cross-origin by design; the submission that follows
42
+ * claims the stored file with {@link claimIngestUpload}, which checks it against what the
43
+ * upload request declared.
44
+ *
45
+ * The engine never imports an addon: the handler is an internal-mutation REFERENCE the
46
+ * consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
47
+ * submission: formsIngest } })`), exported by the addon's `./convex` entry.
48
+ */
49
+
50
+ /** What an ingest handler receives: the verified, parsed payload and the receipt time. */
51
+ export const ingestHandlerArgs = { payload: v.any(), receivedAt: v.string() }
52
+
53
+ /** An addon's ingest handler: an INTERNAL mutation (never client-callable). */
54
+ export type IngestHandlerRef = FunctionReference<
55
+ 'mutation',
56
+ 'internal',
57
+ { payload: IngestPayload; receivedAt: string },
58
+ IngestHandlerResult
59
+ >
60
+
61
+ /**
62
+ * Name an addon's ingest handler for `defineStudioHttp({ ingest })` — its internal mutation by
63
+ * its module path (`ingestHandler('forms:ingest')`), the way every engine function is
64
+ * addressed (a string ref resolved against the consumer's deployment, EC-223). An addon
65
+ * exports the result from its `./convex` entry so the consumer writes no string.
66
+ */
67
+ export function ingestHandler(name: `${string}:${string}`): IngestHandlerRef {
68
+ return makeFunctionReference<'mutation'>(name) as unknown as IngestHandlerRef
69
+ }
70
+
71
+ /** The handlers a project registers, by payload kind (`upload` goes to `submission`). */
72
+ export interface IngestHandlers {
73
+ event?: IngestHandlerRef
74
+ submission?: IngestHandlerRef
75
+ }
76
+
77
+ /** Re-validate a handler's `payload` argument (it crossed a function boundary as `v.any()`). */
78
+ export function parseIngestPayload(payload: unknown): IngestPayload {
79
+ return ingestPayloadSchema.parse(payload)
80
+ }
81
+
82
+ function json(status: number, body: Record<string, unknown>): Response {
83
+ return new Response(JSON.stringify(body), {
84
+ status,
85
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
86
+ })
87
+ }
88
+
89
+ function ingestSecret(): string | undefined {
90
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
91
+ const value = env['ELYTRA_INGEST_SECRET']?.trim()
92
+ return value ? value : undefined
93
+ }
94
+
95
+ function utf8Length(text: string): number {
96
+ return new TextEncoder().encode(text).byteLength
97
+ }
98
+
99
+ /** The receiver `httpAction` (see the module header). */
100
+ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction {
101
+ return httpActionGeneric(async (ctx, request) => {
102
+ const secret = ingestSecret()
103
+ if (!secret) return json(401, { error: 'Ingest is not configured on this deployment.', configured: false })
104
+
105
+ const declared = Number(request.headers.get('content-length'))
106
+ if (Number.isFinite(declared) && declared > INGEST_MAX_BODY_BYTES) {
107
+ return json(413, { error: 'The payload is too large.' })
108
+ }
109
+ const raw = await request.text()
110
+ if (utf8Length(raw) > INGEST_MAX_BODY_BYTES) return json(413, { error: 'The payload is too large.' })
111
+
112
+ if (!(await verifyIngestSignature(raw, request.headers.get(INGEST_SIGNATURE_HEADER), secret))) {
113
+ return json(401, { error: 'Invalid or missing ingest signature.', configured: true })
114
+ }
115
+ const read = readIngestReceiverBody(raw)
116
+ if (!read.ok) return json(400, { error: read.message, ...(read.issues ? { issues: read.issues } : {}) })
117
+ const body = read.value
118
+ if (body.kind === 'probe') return json(200, { ok: true, probe: true })
119
+
120
+ const handlerKind = body.kind === 'upload' ? 'submission' : body.kind
121
+ const handler = handlers[handlerKind]
122
+ if (!handler) {
123
+ return json(422, {
124
+ error: `No handler is registered for "${body.kind}" ingest payloads on this deployment — install the addon that handles them and pass its handler to defineStudioHttp({ ingest }).`,
125
+ kind: body.kind,
126
+ handled: false,
127
+ })
128
+ }
129
+ const result = await ctx.runMutation(handler, { payload: body, receivedAt: new Date().toISOString() })
130
+ if (!result.ok) {
131
+ return json(result.status, { error: result.message, ...(result.issues ? { issues: result.issues } : {}) })
132
+ }
133
+ if (body.kind === 'upload') {
134
+ if (!result.uploadId) {
135
+ return json(500, { error: 'The submission handler accepted the upload but recorded no upload intent (recordIngestUpload).' })
136
+ }
137
+ const uploadUrl = await ctx.storage.generateUploadUrl()
138
+ return json(200, { ok: true, uploadId: result.uploadId, uploadUrl, expiresInSeconds: INGEST_UPLOAD_TTL_MS / 1000 })
139
+ }
140
+ return json(200, { ok: true, ...(result.id ? { id: result.id } : {}) })
141
+ })
142
+ }
143
+
144
+ // --- Submission-scoped uploads -------------------------------------------------------------
145
+ //
146
+ // The receiver issues a storage URL only for an upload a handler ACCEPTED and RECORDED as an
147
+ // intent (`recordIngestUpload`); a submission CLAIMS it (`claimIngestUpload`), which checks the
148
+ // stored file against the intent. Abuse is bounded by: the route's limiter and bot gate on every
149
+ // URL issued, `INGEST_MAX_UPLOAD_BYTES` per file, the URL's one-hour life, the post-upload check
150
+ // (an over-size or mistyped file is deleted, never kept), and the unclaimed intents an addon can
151
+ // list (`unclaimedIngestUploads`) and surface. Convex returns the storage id to the uploader
152
+ // only, so the bytes of an upload no submission ever claims cannot be located by the engine —
153
+ // that is the documented residue, bounded by the limits above.
154
+
155
+ /** The upload intent — an engine-internal record kind (no public functions). */
156
+ export const ingestUploadRecordKind = defineRecordKind({
157
+ kind: 'ingestUpload',
158
+ module: 'ingest',
159
+ keys: ['target', 'state'],
160
+ time: 'issuedAt',
161
+ fields: {
162
+ target: z.string().min(1),
163
+ field: z.string().min(1),
164
+ contentType: z.string().min(1),
165
+ size: z.number().int().positive(),
166
+ state: z.enum(['issued', 'claimed', 'rejected']),
167
+ issuedAt: z.string().min(1),
168
+ storageId: z.string().optional(),
169
+ },
170
+ })
171
+
172
+ let mintUploadUuid: (() => string) | undefined
173
+
174
+ export interface IngestUploadPolicy {
175
+ /** Largest accepted file in bytes (≤ `INGEST_MAX_UPLOAD_BYTES`). */
176
+ maxBytes?: number
177
+ /** Accepted content types (exact, or `image/*`-style prefixes). Absent → any. */
178
+ contentTypes?: readonly string[]
179
+ }
180
+
181
+ function typeAllowed(contentType: string, allowed: readonly string[] | undefined): boolean {
182
+ if (!allowed) return true
183
+ return allowed.some((entry) => (entry.endsWith('/*') ? contentType.startsWith(entry.slice(0, -1)) : entry === contentType))
184
+ }
185
+
186
+ /**
187
+ * Accept an `upload` payload under the handler's policy and record its intent. Returns the
188
+ * handler result to hand back to the receiver (`{ ok: true, uploadId }`, or a 413/415
189
+ * refusal). Call it from the submission handler's `upload` branch.
190
+ */
191
+ export async function recordIngestUpload(
192
+ ctx: MutationCtx,
193
+ payload: Extract<IngestPayload, { kind: 'upload' }>,
194
+ receivedAt: string,
195
+ policy: IngestUploadPolicy = {},
196
+ ): Promise<IngestHandlerResult> {
197
+ const maxBytes = Math.min(policy.maxBytes ?? INGEST_MAX_UPLOAD_BYTES, INGEST_MAX_UPLOAD_BYTES)
198
+ if (payload.size > maxBytes) return { ok: false, status: 413, message: `The file is larger than ${maxBytes} bytes.` }
199
+ if (!typeAllowed(payload.contentType, policy.contentTypes)) {
200
+ return { ok: false, status: 415, message: `Files of type “${payload.contentType}” are not accepted here.` }
201
+ }
202
+ // EC-578: the one id factory (UUID v4 from crypto.getRandomValues) — never a clock or a counter.
203
+ mintUploadUuid ??= createUuidIdFactory()
204
+ const uploadId = `upload_${mintUploadUuid()}`
205
+ await saveRecord(
206
+ ctx,
207
+ ingestUploadRecordKind,
208
+ uploadId,
209
+ { ...payloadColumns(payload), state: 'issued', issuedAt: receivedAt },
210
+ { createdAt: receivedAt, updatedAt: receivedAt },
211
+ )
212
+ return { ok: true, uploadId }
213
+ }
214
+
215
+ function payloadColumns(payload: Extract<IngestPayload, { kind: 'upload' }>) {
216
+ return { target: payload.target, field: payload.field, contentType: payload.contentType, size: payload.size }
217
+ }
218
+
219
+ /** What a claim yields: the stored file's facts, or a refusal for the handler to return. */
220
+ export type IngestUploadClaimResult =
221
+ | { ok: true; storageId: string; field: string; contentType: string; size: number }
222
+ | { ok: false; status: 409 | 413 | 415 | 422; message: string }
223
+
224
+ /**
225
+ * Claim one upload for a submission to `target`: the intent must exist, be unclaimed,
226
+ * belong to `target`, be younger than `INGEST_UPLOAD_TTL_MS`, and the STORED file must match
227
+ * it (size ≤ declared, same content type). A mismatched file is deleted and the intent
228
+ * marked `rejected` — never kept.
229
+ */
230
+ export async function claimIngestUpload(
231
+ ctx: MutationCtx,
232
+ claim: IngestUploadClaim,
233
+ target: string,
234
+ receivedAt: string,
235
+ ): Promise<IngestUploadClaimResult> {
236
+ const intent = await findRecordDoc(ctx, ingestUploadRecordKind, claim.uploadId)
237
+ const columns = intent?.data as
238
+ | { target: string; field: string; contentType: string; size: number; state: string; issuedAt: string }
239
+ | undefined
240
+ if (!columns || columns.target !== target) return { ok: false, status: 422, message: `Unknown upload “${claim.uploadId}”.` }
241
+ if (columns.state !== 'issued') return { ok: false, status: 409, message: `Upload “${claim.uploadId}” was already claimed.` }
242
+ if (Date.parse(receivedAt) - Date.parse(columns.issuedAt) > INGEST_UPLOAD_TTL_MS) {
243
+ return { ok: false, status: 422, message: `Upload “${claim.uploadId}” expired.` }
244
+ }
245
+ const storageId = claim.storageId as GenericId<'_storage'>
246
+ const file = await ctx.db.system.get(storageId).catch(() => null)
247
+ const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
248
+ if (!file) return { ok: false, status: 422, message: `No stored file “${claim.storageId}”.` }
249
+ const refused =
250
+ file.size > columns.size
251
+ ? ({ status: 413, message: 'The stored file is larger than the upload declared.' } as const)
252
+ : (file.contentType ?? '') !== columns.contentType
253
+ ? ({ status: 415, message: 'The stored file is not of the declared type.' } as const)
254
+ : undefined
255
+ if (refused) {
256
+ await ctx.storage.delete(storageId)
257
+ await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'rejected', storageId: claim.storageId }, stamps)
258
+ return { ok: false, ...refused }
259
+ }
260
+ await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
261
+ return { ok: true, storageId: claim.storageId, field: columns.field, contentType: columns.contentType, size: file.size }
262
+ }
263
+
264
+ /** Upload intents of `target` still unclaimed past their life — the visible orphan finding. */
265
+ export async function unclaimedIngestUploads(
266
+ ctx: QueryCtx | MutationCtx,
267
+ target: string,
268
+ now: string,
269
+ ): Promise<{ uploadId: string; field: string; issuedAt: string }[]> {
270
+ const before = new Date(Date.parse(now) - INGEST_UPLOAD_TTL_MS).toISOString()
271
+ const rows = await listRecordWire(
272
+ ctx,
273
+ ingestUploadRecordKind,
274
+ resolveRecordQuery(ingestUploadRecordKind, { where: { target, state: 'issued' }, before }),
275
+ )
276
+ return rows.map((row) => ({
277
+ uploadId: String(row[ingestUploadRecordKind.idKey]),
278
+ field: String(row['field']),
279
+ issuedAt: String(row['issuedAt']),
280
+ }))
281
+ }
package/convex/invites.ts CHANGED
@@ -3,6 +3,7 @@ import { selectOrphanCredentialPurges } from '@elytracms/core/persistence'
3
3
  import { mutation, query } from './functions'
4
4
  import type { MutationCtx } from './functions'
5
5
  import { requireCapability } from './guard'
6
+ import { assertAuthorityFloor, assertDeclaredRole } from './authority'
6
7
 
7
8
  /**
8
9
  * Invite allowlist management (EC-324). An admin invites an email + role; the
@@ -11,10 +12,14 @@ import { requireCapability } from './guard'
11
12
  * `requireCapability('project.administer')`, the same trust boundary as
12
13
  * member/user management; UI gating alone is never trusted.
13
14
  *
14
- * The invited `role` is validated against the active policy at the operations
15
- * write boundary (`invites.create` input); this store keeps it a lenient string
16
- * (schema-as-lens, EC-277). Keyed by normalized (lowercased) email; `create`
17
- * upserts so re-inviting the same email replaces the pending role.
15
+ * The invited `role` is validated against the project's policy at the operations
16
+ * write boundary (`invites.create`) AND here, against the DEPLOYED policy (EC-605 —
17
+ * `assertDeclaredRole`): an invite grants its role on consumption, so a role nobody
18
+ * declared must never be stored. Granting it is authority, so the integrity floor's
19
+ * "assign only roles you hold" applies too (`assertAuthorityFloor`, no target yet).
20
+ * The table keeps the role a lenient string (schema-as-lens, EC-277). Keyed by
21
+ * normalized (lowercased) email; `create` upserts so re-inviting the same email
22
+ * replaces the pending role.
18
23
  */
19
24
 
20
25
  const normalizeEmail = (email: string): string => email.trim().toLowerCase()
@@ -109,7 +114,10 @@ export const create = mutation({
109
114
  updatedAt: v.string(),
110
115
  },
111
116
  handler: async (ctx, args) => {
112
- await requireCapability(ctx, 'project.administer')
117
+ const callerId = await requireCapability(ctx, 'project.administer')
118
+ // EC-605: only a declared role, and only one the inviter could grant themselves.
119
+ assertDeclaredRole(args.role)
120
+ await assertAuthorityFloor(ctx, { callerId, rolesGranted: [args.role], removesMembership: false })
113
121
  const email = normalizeEmail(args.email)
114
122
  // EC-358: never adopt orphan credentials — purge member-less auth rows for
115
123
  // this email atomically with the invite, so the invitee sets their own password.