@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.
- package/convex/analytics-cost.test.ts +817 -0
- package/convex/asset-alt-migration.test.ts +127 -0
- package/convex/asset-replace.test.ts +168 -0
- package/convex/asset-revalidate.test.ts +6 -6
- package/convex/assets.ts +41 -5
- package/convex/auth.ts +5 -2
- package/convex/authority.ts +35 -11
- package/convex/cms.ts +25 -11
- package/convex/contract-parity.test.ts +25 -3
- package/convex/delivery.ts +12 -9
- package/convex/document-revalidate.test.ts +10 -12
- package/convex/guard.ts +9 -7
- package/convex/http-routes.test.ts +18 -8
- package/convex/http.ts +23 -17
- package/convex/ingest.test.ts +326 -0
- package/convex/ingest.ts +281 -0
- package/convex/invites.ts +13 -5
- package/convex/members.ts +10 -4
- package/convex/orphan-hydration.test.ts +116 -0
- package/convex/rail-write-parity.test.ts +11 -8
- package/convex/record-functions.ts +325 -0
- package/convex/records.test.ts +156 -0
- package/convex/redirect-migration.test.ts +205 -0
- package/convex/redirect-revalidate.test.ts +5 -1
- package/convex/redirects.ts +120 -79
- package/convex/role-policy.test.ts +315 -0
- package/convex/roles.ts +83 -0
- package/convex/schema.ts +36 -0
- package/convex/triggers.ts +21 -1
- package/dist/addon.d.ts +71 -0
- package/dist/addon.js +67 -0
- package/dist/addon.js.map +1 -0
- package/dist/components/account/account-settings.js +2 -2
- package/dist/components/account/account-settings.js.map +1 -1
- package/dist/components/account/credential-panel.js +2 -2
- package/dist/components/account/credential-panel.js.map +1 -1
- package/dist/components/account/self-account-schema.d.ts +6 -15
- package/dist/components/account/self-account-schema.js +52 -34
- package/dist/components/account/self-account-schema.js.map +1 -1
- package/dist/components/account/self-account-view-source.d.ts +1 -1
- package/dist/components/account/self-account-view-source.js +6 -4
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/ai/ai-chat-panel-lazy.js +1 -1
- package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +6 -6
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/ai-cms-panel.js +2 -2
- package/dist/components/ai/ai-cms-panel.js.map +1 -1
- package/dist/components/ai/ai-panel-toggle.js +4 -4
- package/dist/components/ai/ai-panel-toggle.js.map +1 -1
- package/dist/components/ai/chat-ui.js +2 -2
- package/dist/components/ai/chat-ui.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +2 -2
- package/dist/components/ai/proposal-card.js +4 -3
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/ai/proposal-issues.js +2 -2
- package/dist/components/ai/proposal-issues.js.map +1 -1
- package/dist/components/ai/translate-document-action.js +2 -2
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.js +10 -8
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -0
- package/dist/components/ai/translate-skipped.js +46 -0
- package/dist/components/ai/translate-skipped.js.map +1 -0
- package/dist/components/app-views/default-modules.d.ts +50 -2
- package/dist/components/app-views/default-modules.js +65 -20
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +53 -3
- package/dist/components/app-views/module.js +14 -7
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.js +1 -1
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +11 -2
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/asset-detail.js +20 -10
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-grid.js +2 -2
- package/dist/components/assets/asset-grid.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +15 -4
- package/dist/components/assets/asset-metadata.js +15 -2
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +4 -1
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-selection.d.ts +0 -8
- package/dist/components/assets/asset-selection.js +4 -23
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/asset-thumb.js +6 -5
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +5 -5
- package/dist/components/assets/assets-collection-view-source.js +9 -5
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-module.js +1 -1
- package/dist/components/assets/assets-module.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +16 -2
- package/dist/components/assets/assets-store.js +28 -6
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/assets-view-preference.js +1 -1
- package/dist/components/assets/assets-view-preference.js.map +1 -1
- package/dist/components/assets/replace-file.d.ts +22 -0
- package/dist/components/assets/replace-file.js +59 -0
- package/dist/components/assets/replace-file.js.map +1 -0
- package/dist/components/auth-context.d.ts +1 -1
- package/dist/components/auth-context.js +6 -6
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +15 -27
- package/dist/components/builder/blocks-model.js +38 -44
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.d.ts +8 -4
- package/dist/components/builder/blocks-panel.js +10 -9
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +104 -15
- package/dist/components/builder/builder-store.js +157 -32
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-body.d.ts +30 -0
- package/dist/components/builder/canvas-body.js +38 -0
- package/dist/components/builder/canvas-body.js.map +1 -0
- package/dist/components/builder/canvas-breakpoints.js +1 -1
- package/dist/components/builder/canvas-breakpoints.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.js +7 -0
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +9 -9
- package/dist/components/builder/canvas-dnd.js +6 -6
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-inline-editing.d.ts +1 -1
- package/dist/components/builder/canvas-inline-editing.js.map +1 -1
- package/dist/components/builder/canvas-overlay-geometry.d.ts +1 -1
- package/dist/components/builder/canvas-overlay-geometry.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +28 -20
- package/dist/components/builder/canvas-render-context.js +46 -47
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas-runs.d.ts +45 -0
- package/dist/components/builder/canvas-runs.js +51 -0
- package/dist/components/builder/canvas-runs.js.map +1 -0
- package/dist/components/builder/canvas-theme-notice.js +2 -2
- package/dist/components/builder/canvas-theme-notice.js.map +1 -1
- package/dist/components/builder/canvas.js +35 -8
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +16 -1
- package/dist/components/builder/clipboard-actions.js +48 -11
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor-lazy.js +1 -1
- package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +4 -16
- package/dist/components/builder/composition-field-editor.js +32 -43
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/delete-key.d.ts +5 -0
- package/dist/components/builder/delete-key.js +4 -0
- package/dist/components/builder/delete-key.js.map +1 -1
- package/dist/components/builder/drag-session.d.ts +1 -1
- package/dist/components/builder/drag-session.js.map +1 -1
- package/dist/components/builder/fixture-thumbnail.js +1 -1
- package/dist/components/builder/fixture-thumbnail.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +18 -26
- package/dist/components/builder/insert-context.js +47 -94
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/layout-inspector.js +1 -1
- package/dist/components/builder/layout-inspector.js.map +1 -1
- package/dist/components/builder/node-title.js +1 -1
- package/dist/components/builder/node-title.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +4 -4
- package/dist/components/builder/page-tree.js +8 -8
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/placement-refusal.d.ts +18 -0
- package/dist/components/builder/placement-refusal.js +46 -0
- package/dist/components/builder/placement-refusal.js.map +1 -0
- package/dist/components/builder/property-inspector.js +8 -4
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +68 -30
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +79 -1
- package/dist/components/builder/reuse.js +70 -3
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/builder/rich-text-toolbar.js +1 -1
- package/dist/components/builder/rich-text-toolbar.js.map +1 -1
- package/dist/components/builder/run-inspector.d.ts +11 -0
- package/dist/components/builder/run-inspector.js +33 -0
- package/dist/components/builder/run-inspector.js.map +1 -0
- package/dist/components/builder/selection-overlay.js +3 -3
- package/dist/components/builder/selection-overlay.js.map +1 -1
- package/dist/components/builder/spacing-handles.d.ts +2 -2
- package/dist/components/builder/spacing-handles.js +3 -3
- package/dist/components/builder/spacing-handles.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +38 -1
- package/dist/components/cms/asset-field.js +184 -60
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +16 -0
- package/dist/components/cms/asset-picker-core.js +20 -0
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +14 -20
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/bulk-actions.d.ts +4 -20
- package/dist/components/cms/bulk-actions.js +5 -26
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/bulk-bar.d.ts +2 -2
- package/dist/components/cms/bulk-bar.js +2 -2
- package/dist/components/cms/bulk-bar.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +1 -1
- package/dist/components/cms/cell-edit.js +1 -1
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cell.d.ts +1 -1
- package/dist/components/cms/cell.js +3 -3
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-index.js +2 -2
- package/dist/components/cms/cms-index.js.map +1 -1
- package/dist/components/cms/cms-labels.js +2 -2
- package/dist/components/cms/cms-labels.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +7 -6
- package/dist/components/cms/cms-store.js +23 -43
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +7 -7
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.d.ts +5 -2
- package/dist/components/cms/detail-editor.js +20 -15
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/details-pane.js +1 -1
- package/dist/components/cms/details-pane.js.map +1 -1
- package/dist/components/cms/doc-state.js +1 -1
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +3 -1
- package/dist/components/cms/document-diff-view.js +17 -12
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/cms/document-editor-context.js +1 -1
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/document-staging.js +1 -1
- package/dist/components/cms/document-staging.js.map +1 -1
- package/dist/components/cms/document-url-path.js +2 -2
- package/dist/components/cms/document-url-path.js.map +1 -1
- package/dist/components/cms/duplicate-document.d.ts +1 -1
- package/dist/components/cms/duplicate-document.js.map +1 -1
- package/dist/components/cms/editable-cell.js +3 -3
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-anchors.d.ts +7 -4
- package/dist/components/cms/field-anchors.js +9 -7
- package/dist/components/cms/field-anchors.js.map +1 -1
- package/dist/components/cms/field-collaboration.d.ts +1 -1
- package/dist/components/cms/field-collaboration.js +1 -1
- package/dist/components/cms/field-collaboration.js.map +1 -1
- package/dist/components/cms/field-connection.d.ts +1 -1
- package/dist/components/cms/field-connection.js +2 -2
- package/dist/components/cms/field-connection.js.map +1 -1
- package/dist/components/cms/field-control-lib.d.ts +15 -1
- package/dist/components/cms/field-control-lib.js +23 -4
- package/dist/components/cms/field-control-lib.js.map +1 -1
- package/dist/components/cms/field-control.js +4 -4
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/field-renderer.d.ts +1 -1
- package/dist/components/cms/field-renderer.js +2 -2
- package/dist/components/cms/field-renderer.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +1 -1
- package/dist/components/cms/filter-builder.js +3 -3
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +21 -30
- package/dist/components/cms/gate-validation.js +11 -32
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/import-export.js +2 -2
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/issue-copy.js +1 -1
- package/dist/components/cms/issue-copy.js.map +1 -1
- package/dist/components/cms/issues-pane.js +3 -3
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/object-array-field.js +6 -6
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/prop-fields.d.ts +7 -2
- package/dist/components/cms/prop-fields.js +14 -7
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/reference-confirm.d.ts +1 -1
- package/dist/components/cms/reference-confirm.js +2 -2
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/relation-picker.js +2 -2
- package/dist/components/cms/relation-picker.js.map +1 -1
- package/dist/components/cms/rich-text/block-style.js +1 -1
- package/dist/components/cms/rich-text/block-style.js.map +1 -1
- package/dist/components/cms/rich-text/component-embed.js +4 -4
- package/dist/components/cms/rich-text/component-embed.js.map +1 -1
- package/dist/components/cms/rich-text/editor.js +3 -3
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
- package/dist/components/cms/rich-text/embed-picker.js +3 -3
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
- package/dist/components/cms/rich-text/embed-props.js +3 -3
- package/dist/components/cms/rich-text/embed-props.js.map +1 -1
- package/dist/components/cms/rich-text/extensions.js +1 -1
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/font-size-control.d.ts +1 -1
- package/dist/components/cms/rich-text/font-size-control.js +2 -2
- package/dist/components/cms/rich-text/font-size-control.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +20 -9
- package/dist/components/cms/rich-text/insert.js +25 -17
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/rich-text/lazy.js +1 -1
- package/dist/components/cms/rich-text/lazy.js.map +1 -1
- package/dist/components/cms/rich-text/link-control.js +2 -2
- package/dist/components/cms/rich-text/link-control.js.map +1 -1
- package/dist/components/cms/rich-text/slash-command.js +3 -3
- package/dist/components/cms/rich-text/slash-command.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.js +1 -1
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/cms/rich-text/unknown-node.js +2 -2
- package/dist/components/cms/rich-text/unknown-node.js.map +1 -1
- package/dist/components/cms/rich-text-preview.js +2 -2
- package/dist/components/cms/rich-text-preview.js.map +1 -1
- package/dist/components/cms/row-actions.d.ts +1 -1
- package/dist/components/cms/row-actions.js.map +1 -1
- package/dist/components/cms/scalar-value-editor.d.ts +1 -1
- package/dist/components/cms/scalar-value-editor.js +2 -2
- package/dist/components/cms/scalar-value-editor.js.map +1 -1
- package/dist/components/cms/use-discard-controller.d.ts +1 -1
- package/dist/components/cms/use-discard-controller.js +1 -1
- package/dist/components/cms/use-discard-controller.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +7 -1
- package/dist/components/cms/use-document-draft.js +16 -2
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-document-versions.js +1 -1
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/use-field-focus.d.ts +1 -1
- package/dist/components/cms/use-field-focus.js +2 -2
- package/dist/components/cms/use-field-focus.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +1 -1
- package/dist/components/cms/use-live-url.js +3 -3
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +2 -3
- package/dist/components/cms/use-publish-controller.js +8 -20
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/use-published-perspective.d.ts +1 -1
- package/dist/components/cms/use-published-perspective.js.map +1 -1
- package/dist/components/cms/use-restore-version.d.ts +1 -1
- package/dist/components/cms/use-restore-version.js +1 -1
- package/dist/components/cms/use-restore-version.js.map +1 -1
- package/dist/components/cms/version-compare-lazy.js +1 -1
- package/dist/components/cms/version-compare-lazy.js.map +1 -1
- package/dist/components/cms/version-compare.js +1 -1
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +11 -4
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.js +3 -3
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.d.ts +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +5 -3
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/collection-view/collection-view-context.d.ts +1 -1
- package/dist/components/collection-view/collection-view-context.js +1 -1
- package/dist/components/collection-view/collection-view-context.js.map +1 -1
- package/dist/components/collection-view/collection-view-surface.d.ts +13 -0
- package/dist/components/collection-view/collection-view-surface.js +18 -0
- package/dist/components/collection-view/collection-view-surface.js.map +1 -0
- package/dist/components/components/component-detail.js +10 -10
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.d.ts +2 -2
- package/dist/components/components/component-preview.js +5 -5
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/component-status.d.ts +1 -1
- package/dist/components/components/component-status.js +1 -1
- package/dist/components/components/component-status.js.map +1 -1
- package/dist/components/components/components-index.js +2 -2
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/components/field-inspector.js +1 -1
- package/dist/components/components/field-inspector.js.map +1 -1
- package/dist/components/components/registry-browser.js +3 -3
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.d.ts +1 -1
- package/dist/components/components/taxonomy.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +4 -3
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +10 -10
- package/dist/components/components/transfer.js +13 -12
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/current-user-menu.js +1 -1
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/delivery-health-banner.js +2 -2
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +3 -3
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/dev-login-badge.js +2 -2
- package/dist/components/dev-login-badge.js.map +1 -1
- package/dist/components/guards.js +1 -1
- package/dist/components/guards.js.map +1 -1
- package/dist/components/inspector/document-panes.d.ts +54 -0
- package/dist/components/inspector/document-panes.js +41 -0
- package/dist/components/inspector/document-panes.js.map +1 -0
- package/dist/components/inspector/inspector-sidebar.js +1 -1
- package/dist/components/inspector/inspector-sidebar.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +53 -0
- package/dist/components/nav-issues-registry.js +69 -0
- package/dist/components/nav-issues-registry.js.map +1 -0
- package/dist/components/nav-issues.js +4 -4
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/operation-error.d.ts +1 -1
- package/dist/components/operation-error.js +4 -4
- package/dist/components/operation-error.js.map +1 -1
- package/dist/components/playground-badge.js +2 -2
- package/dist/components/playground-badge.js.map +1 -1
- package/dist/components/project-list.js +3 -3
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-session.d.ts +2 -2
- package/dist/components/project-session.js +5 -5
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/project-switcher.js +2 -2
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/rail-store-status.js +1 -1
- package/dist/components/rail-store-status.js.map +1 -1
- package/dist/components/rail-view-source.d.ts +6 -5
- package/dist/components/rail-view-source.js +4 -3
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-lens.d.ts +51 -0
- package/dist/components/records/record-lens.js +156 -0
- package/dist/components/records/record-lens.js.map +1 -0
- package/dist/components/records/record-rail.d.ts +86 -0
- package/dist/components/records/record-rail.js +241 -0
- package/dist/components/records/record-rail.js.map +1 -0
- package/dist/components/redirects/redirects-schema.js +1 -1
- package/dist/components/redirects/redirects-schema.js.map +1 -1
- package/dist/components/references/references-panel.js +2 -2
- package/dist/components/references/references-panel.js.map +1 -1
- package/dist/components/section-nav.js +27 -11
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/section-state.js +2 -2
- package/dist/components/section-state.js.map +1 -1
- package/dist/components/section-surface.js +2 -2
- package/dist/components/section-surface.js.map +1 -1
- package/dist/components/session-expired-overlay.js +3 -3
- package/dist/components/session-expired-overlay.js.map +1 -1
- package/dist/components/settings-form.js +1 -1
- package/dist/components/settings-form.js.map +1 -1
- package/dist/components/sign-in-needed-badge.js +1 -1
- package/dist/components/sign-in-needed-badge.js.map +1 -1
- package/dist/components/sign-in-view.d.ts +2 -2
- package/dist/components/sign-in-view.js +5 -5
- package/dist/components/sign-in-view.js.map +1 -1
- package/dist/components/studio-context.js +3 -3
- package/dist/components/studio-context.js.map +1 -1
- package/dist/components/studio-project-screen.js +30 -3
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +6 -5
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/theme-context.d.ts +1 -1
- package/dist/components/theme-context.js +2 -2
- package/dist/components/theme-context.js.map +1 -1
- package/dist/components/use-hydration-phase.d.ts +1 -1
- package/dist/components/use-hydration-phase.js +1 -1
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/components/users/invite-user-bar.d.ts +27 -0
- package/dist/components/users/invite-user-bar.js +25 -9
- package/dist/components/users/invite-user-bar.js.map +1 -1
- package/dist/components/users/role-policy-drift-issues.d.ts +38 -0
- package/dist/components/users/role-policy-drift-issues.js +71 -0
- package/dist/components/users/role-policy-drift-issues.js.map +1 -0
- package/dist/components/users/users-collection-view-source.d.ts +1 -1
- package/dist/components/users/users-collection-view-source.js +4 -2
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/components/users/users-module.js +1 -1
- package/dist/components/users/users-module.js.map +1 -1
- package/dist/components/users/users-schema.d.ts +10 -1
- package/dist/components/users/users-schema.js +45 -25
- package/dist/components/users/users-schema.js.map +1 -1
- package/dist/components/users/users-store.js +3 -3
- package/dist/components/users/users-store.js.map +1 -1
- package/dist/components/workspace-projects.d.ts +1 -1
- package/dist/components/workspace-projects.js +1 -1
- package/dist/components/workspace-projects.js.map +1 -1
- package/dist/lib/ai/apply-content.js +56 -116
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +8 -0
- package/dist/lib/ai/chat-context.js +37 -6
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +6 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +20 -5
- package/dist/lib/ai/constraints.js +125 -33
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +16 -3
- package/dist/lib/ai/fake-provider.js +22 -6
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/index.d.ts +3 -1
- package/dist/lib/ai/index.js +3 -1
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/leash.d.ts +34 -0
- package/dist/lib/ai/leash.js +28 -0
- package/dist/lib/ai/leash.js.map +1 -0
- package/dist/lib/ai/node-handler.d.ts +1 -1
- package/dist/lib/ai/node-handler.js.map +1 -1
- package/dist/lib/ai/rich-text-input.d.ts +8 -8
- package/dist/lib/ai/rich-text-input.js +2 -1
- package/dist/lib/ai/rich-text-input.js.map +1 -1
- package/dist/lib/ai/rich-text-translate.d.ts +27 -4
- package/dist/lib/ai/rich-text-translate.js +28 -10
- package/dist/lib/ai/rich-text-translate.js.map +1 -1
- package/dist/lib/ai/shape.d.ts +13 -13
- package/dist/lib/ai/shape.js +69 -93
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +36 -41
- package/dist/lib/ai/tools.js +83 -87
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +32 -4
- package/dist/lib/ai/translate-document.js +145 -47
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +34 -23
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +15 -12
- package/dist/lib/ai/update-fields.js +112 -188
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +116 -0
- package/dist/lib/ai/writer-form.js +429 -0
- package/dist/lib/ai/writer-form.js.map +1 -0
- package/dist/lib/assets/upload.d.ts +31 -2
- package/dist/lib/assets/upload.js +61 -6
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
- package/dist/lib/clipboard/os-clipboard.js +8 -4
- package/dist/lib/clipboard/os-clipboard.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.js +1 -1
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/clipboard/paste-target.d.ts +18 -9
- package/dist/lib/clipboard/paste-target.js +10 -9
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms/field-patch.d.ts +0 -10
- package/dist/lib/cms/field-patch.js +4 -30
- package/dist/lib/cms/field-patch.js.map +1 -1
- package/dist/lib/cms/published-documents.js +1 -1
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +9 -9
- package/dist/lib/cms-views/view-model.js +2 -2
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +14 -5
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +3 -2
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/data/content-session-refresh.d.ts +1 -1
- package/dist/lib/data/content-session-refresh.js.map +1 -1
- package/dist/lib/data/playground-unload-guard.d.ts +1 -1
- package/dist/lib/data/playground-unload-guard.js +1 -1
- package/dist/lib/data/playground-unload-guard.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +25 -31
- package/dist/lib/dnd/drop-rules.js +53 -59
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/i18n.js +1 -1
- package/dist/lib/i18n/i18n.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +44 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +38 -2
- package/dist/lib/i18n/messages.en.js +51 -3
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/project-runtime-config.d.ts +34 -6
- package/dist/lib/operations/project-runtime-config.js +50 -10
- package/dist/lib/operations/project-runtime-config.js.map +1 -1
- package/dist/lib/operations/publish-with-revision.d.ts +33 -1
- package/dist/lib/operations/publish-with-revision.js +32 -0
- package/dist/lib/operations/publish-with-revision.js.map +1 -1
- package/dist/lib/operations/runtime-validator.d.ts +19 -68
- package/dist/lib/operations/runtime-validator.js +12 -138
- package/dist/lib/operations/runtime-validator.js.map +1 -1
- package/dist/lib/operations/studio-operations.d.ts +17 -2
- package/dist/lib/operations/studio-operations.js +18 -11
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +16 -3
- package/dist/lib/persistence/convex-adapter.js +83 -47
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/index.js +14 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/persona/labels.js +4 -2
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +1 -1
- package/dist/lib/query/operations-query.js +1 -1
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/references/reference-model.js +1 -1
- package/dist/lib/references/reference-model.js.map +1 -1
- package/dist/lib/references/references-store.js +1 -1
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +14 -11
- package/dist/lib/workspace/index.js +20 -24
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/mount.d.ts +28 -0
- package/dist/mount.js +27 -0
- package/dist/mount.js.map +1 -0
- package/dist/router/root.js +10 -10
- package/dist/router/root.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +2 -2
- package/package.json +15 -11
- package/dist/components/cms/cms-composition-validators.d.ts +0 -16
- package/dist/components/cms/cms-composition-validators.js +0 -133
- package/dist/components/cms/cms-composition-validators.js.map +0 -1
- package/dist/components/cms/publish-clean.d.ts +0 -28
- package/dist/components/cms/publish-clean.js +0 -38
- package/dist/components/cms/publish-clean.js.map +0 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +0 -53
- package/dist/components/redirects/redirects-collection-view-source.js +0 -146
- package/dist/components/redirects/redirects-collection-view-source.js.map +0 -1
- package/dist/components/redirects/redirects-module.d.ts +0 -21
- package/dist/components/redirects/redirects-module.js +0 -40
- package/dist/components/redirects/redirects-module.js.map +0 -1
- package/dist/components/redirects/redirects-store.d.ts +0 -68
- package/dist/components/redirects/redirects-store.js +0 -132
- package/dist/components/redirects/redirects-store.js.map +0 -1
- package/dist/lib/assets/asset-placements.d.ts +0 -28
- package/dist/lib/assets/asset-placements.js +0 -83
- 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 {
|
|
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
|
-
|
|
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
|
|
package/convex/delivery.ts
CHANGED
|
@@ -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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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 {
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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)
|
|
12
|
-
* `convex/http.ts
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
26
|
-
* registry-sync endpoint
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
+
})
|