@elytracms/studio 0.0.93 → 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/auth.ts +5 -2
- package/convex/authority.ts +35 -11
- package/convex/contract-parity.test.ts +7 -0
- package/convex/delivery.ts +12 -9
- 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/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 +30 -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 +2 -2
- 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 +6 -6
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +1 -1
- package/dist/components/ai/translate-skipped.js +2 -2
- package/dist/components/ai/translate-skipped.js.map +1 -1
- 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 +2 -2
- 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-thumb.js +3 -3
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +2 -2
- 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 +2 -2
- package/dist/components/assets/assets-store.js +6 -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.js +1 -1
- package/dist/components/assets/replace-file.js.map +1 -1
- 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 +1 -1
- package/dist/components/builder/blocks-model.js +3 -3
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.d.ts +1 -1
- package/dist/components/builder/blocks-panel.js +4 -4
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.js +2 -2
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-breakpoints.js +1 -1
- package/dist/components/builder/canvas-breakpoints.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +2 -2
- package/dist/components/builder/canvas-dnd.js +1 -1
- 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 +1 -1
- package/dist/components/builder/canvas-render-context.js +4 -4
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- 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 +3 -3
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +2 -2
- package/dist/components/builder/clipboard-actions.js +4 -4
- 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 +1 -1
- package/dist/components/builder/composition-field-editor.js +3 -3
- package/dist/components/builder/composition-field-editor.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 +1 -1
- package/dist/components/builder/insert-context.js +3 -3
- 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 +1 -1
- package/dist/components/builder/page-tree.js +5 -5
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/placement-refusal.js +3 -3
- package/dist/components/builder/placement-refusal.js.map +1 -1
- package/dist/components/builder/property-inspector.js +4 -4
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +4 -4
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.js +1 -1
- 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.js +1 -1
- package/dist/components/builder/run-inspector.js.map +1 -1
- 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.js +2 -2
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker.js +2 -2
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/bulk-actions.d.ts +1 -1
- package/dist/components/cms/bulk-actions.js +2 -2
- 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 +2 -2
- package/dist/components/cms/cms-store.js +10 -10
- 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 -14
- 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.js +5 -5
- 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-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.js +1 -1
- 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.js +1 -1
- 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 +2 -2
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/prop-fields.js +2 -2
- 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.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/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 +1 -1
- package/dist/components/cms/use-publish-controller.js +1 -1
- 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 +3 -3
- 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 +2 -2
- 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 +3 -3
- package/dist/components/components/transfer-dialog.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/index.d.ts +1 -1
- package/dist/lib/ai/index.js +1 -1
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/node-handler.d.ts +1 -1
- package/dist/lib/ai/node-handler.js.map +1 -1
- package/dist/lib/ai/translate-document.js +1 -1
- package/dist/lib/ai/translate-document.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.js +2 -2
- package/dist/lib/clipboard/paste-target.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 +12 -12
- 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 +7 -2
- package/dist/lib/collection-view/collection-view-source.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/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 +4 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +4 -0
- package/dist/lib/i18n/messages.en.js +8 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/project-runtime-config.d.ts +9 -0
- package/dist/lib/operations/project-runtime-config.js +13 -0
- package/dist/lib/operations/project-runtime-config.js.map +1 -1
- package/dist/lib/operations/studio-operations.d.ts +9 -1
- package/dist/lib/operations/studio-operations.js +6 -1
- 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 +78 -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 +1 -1
- 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 +6 -0
- package/dist/lib/workspace/index.js +8 -0
- 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/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
|
@@ -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
|
+
})
|
package/convex/ingest.ts
ADDED
|
@@ -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
|
|
15
|
-
* write boundary (`invites.create`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|