@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
|
@@ -135,8 +135,12 @@ describe('EC-510 — the write chokepoint revalidates on every redirect write',
|
|
|
135
135
|
// is the gate working rather than the instrument being blind.
|
|
136
136
|
expect(await scheduledRevalidations(t)).toEqual([])
|
|
137
137
|
// The write itself still happened — silence is at the webhook, not the save.
|
|
138
|
+
// EC-616: a redirect is stored as a `records` row of kind `redirect` now.
|
|
138
139
|
expect(
|
|
139
|
-
await t.run(
|
|
140
|
+
await t.run(
|
|
141
|
+
async (ctx) =>
|
|
142
|
+
(await ctx.db.query('records').collect()).filter((row) => row.kind === 'redirect').length,
|
|
143
|
+
),
|
|
140
144
|
).toBe(1)
|
|
141
145
|
})
|
|
142
146
|
})
|
package/convex/redirects.ts
CHANGED
|
@@ -1,92 +1,133 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { internalMutationGeneric } from 'convex/server'
|
|
2
|
+
import type { MutationBuilder } from 'convex/server'
|
|
3
|
+
import {
|
|
4
|
+
PersistenceValidationError,
|
|
5
|
+
recordColumnsOf,
|
|
6
|
+
recordSlots,
|
|
7
|
+
redirectRecordKind,
|
|
8
|
+
resolveRecordQuery,
|
|
9
|
+
resolveRecordSave,
|
|
10
|
+
} from '@elytracms/core/persistence'
|
|
11
|
+
import type { RedirectEntry } from '@elytracms/core/persistence'
|
|
12
|
+
import type { DataModel, MutationCtx, QueryCtx } from './functions'
|
|
13
|
+
import { defineRecordFunctions, findRecordDoc, listRecordWire } from './record-functions'
|
|
14
|
+
import type { LegacyRecord, LegacyRecordSource } from './record-functions'
|
|
15
|
+
import { recordEntryFromWire } from '@elytracms/core/persistence'
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
|
-
* Editorial redirects persistence (EC-303)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Editorial redirects persistence (EC-303) — since EC-616 the redirect RECORD KIND over
|
|
19
|
+
* the generic record store (`redirectRecordKind`, one `records` row per redirect). The
|
|
20
|
+
* functions keep their names and wire (`redirects:save/get/list/remove`, `redirectId` +
|
|
21
|
+
* the columns flat), so the studio's adapter and every consumer's
|
|
22
|
+
* `export * from '@elytracms/studio/convex/redirects'` line are unchanged. Content-gated
|
|
23
|
+
* (a viewer reads, an editor writes), NOT admin-only — redirects are content.
|
|
24
|
+
*
|
|
25
|
+
* ## The move from the `redirects` table (stored data — KON and NK hold real redirects)
|
|
26
|
+
*
|
|
27
|
+
* The EC-587 precedent, with no gap:
|
|
28
|
+
*
|
|
29
|
+
* - **writes go to `records`** (kind `redirect`); nothing writes the legacy table again;
|
|
30
|
+
* - **reads union the legacy rows no record shadows** — the TOLERANT READ below, marked:
|
|
31
|
+
* a deployment with these functions pushed and the migration NOT yet run serves the
|
|
32
|
+
* same redirects it served before (`list`, `get`, the delivery snapshot);
|
|
33
|
+
* - a save of a legacy-only redirect lifts it (record from its columns, legacy row
|
|
34
|
+
* deleted, one transaction); a remove deletes both;
|
|
35
|
+
* - **`redirects:migrateLegacy`** moves every legacy row in one idempotent internal
|
|
36
|
+
* mutation and reports counts — run per deployment (both consumers + the prod harness:
|
|
37
|
+
* owner-run); the legacy table and this tolerant read are dropped together once it is
|
|
38
|
+
* reported run everywhere (a gated item, like EC-587's alt column).
|
|
16
39
|
*/
|
|
17
40
|
|
|
18
|
-
|
|
19
|
-
* Create or modify a redirect (EC-577). The ONE write rule (`resolveRecordWrite`,
|
|
20
|
-
* shared with the in-memory adapter) runs here, inside the transaction: a column
|
|
21
|
-
* absent from the args keeps its stored value — `permanent`/`enabled` included,
|
|
22
|
-
* whose defaults (the record schema's `.default(true)`) apply only when this save
|
|
23
|
-
* creates the redirect — `null` clears the optional `meta` bag, a value sets. ONE
|
|
24
|
-
* `db.patch` or ONE `db.insert`, so the EC-510 revalidation trigger fires once.
|
|
25
|
-
*/
|
|
26
|
-
export const save = mutation({
|
|
27
|
-
args: {
|
|
28
|
-
redirectId: v.string(),
|
|
29
|
-
from: v.optional(v.string()),
|
|
30
|
-
to: v.optional(v.string()),
|
|
31
|
-
permanent: v.optional(v.boolean()),
|
|
32
|
-
enabled: v.optional(v.boolean()),
|
|
33
|
-
// Schema-driven additive-field bag (EC-311 Tier 2): present replaces the bag,
|
|
34
|
-
// `null` clears it, absent keeps it.
|
|
35
|
-
meta: v.optional(v.any()),
|
|
36
|
-
createdAt: v.string(),
|
|
37
|
-
updatedAt: v.string(),
|
|
38
|
-
},
|
|
39
|
-
handler: async (ctx, { redirectId, createdAt, updatedAt, ...write }) => {
|
|
40
|
-
await requireCapability(ctx, 'content.write')
|
|
41
|
-
const existing = await ctx.db
|
|
42
|
-
.query('redirects')
|
|
43
|
-
.withIndex('by_redirect', (q) => q.eq('redirectId', redirectId))
|
|
44
|
-
.first()
|
|
45
|
-
const resolved = resolveRecordWrite(redirectRowSchema, existing ?? undefined, write)
|
|
46
|
-
if (!resolved.ok) {
|
|
47
|
-
throw new ConvexError({
|
|
48
|
-
code: 'validation',
|
|
49
|
-
message: rejectedWriteMessage('redirect', redirectId, resolved.issues),
|
|
50
|
-
issues: resolved.issues,
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
if (existing) {
|
|
54
|
-
await ctx.db.patch(existing._id, { ...(resolved.patch as Partial<RedirectDoc>), updatedAt })
|
|
55
|
-
return ctx.db.get(existing._id)
|
|
56
|
-
}
|
|
57
|
-
const row: WithoutSystemFields<RedirectDoc> = { ...resolved.row, redirectId, createdAt, updatedAt }
|
|
58
|
-
const id = await ctx.db.insert('redirects', row)
|
|
59
|
-
return ctx.db.get(id)
|
|
60
|
-
},
|
|
61
|
-
})
|
|
41
|
+
type LegacyRedirectDoc = DataModel['redirects']['document']
|
|
62
42
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
67
|
-
return ctx.db
|
|
43
|
+
/** TOLERANT READ (marked) — the legacy `redirects` table as the kind's legacy source. */
|
|
44
|
+
const legacyRedirects: LegacyRecordSource = {
|
|
45
|
+
find: async (ctx, recordId) => {
|
|
46
|
+
const row = await ctx.db
|
|
68
47
|
.query('redirects')
|
|
69
|
-
.withIndex('by_redirect', (q) => q.eq('redirectId',
|
|
48
|
+
.withIndex('by_redirect', (q) => q.eq('redirectId', recordId))
|
|
70
49
|
.first()
|
|
50
|
+
return row ? legacyRecord(row) : null
|
|
71
51
|
},
|
|
72
|
-
|
|
52
|
+
list: async (ctx) => (await ctx.db.query('redirects').withIndex('by_redirect').collect()).map(legacyRecord),
|
|
53
|
+
remove: async (ctx, record) => {
|
|
54
|
+
await ctx.db.delete(record.handle as LegacyRedirectDoc['_id'])
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function legacyRecord(row: LegacyRedirectDoc): LegacyRecord {
|
|
59
|
+
return {
|
|
60
|
+
recordId: row.redirectId,
|
|
61
|
+
columns: recordColumnsOf(redirectRecordKind, row),
|
|
62
|
+
createdAt: row.createdAt,
|
|
63
|
+
updatedAt: row.updatedAt,
|
|
64
|
+
handle: row._id,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
73
67
|
|
|
74
|
-
export const list =
|
|
68
|
+
export const { save, get, list, remove } = defineRecordFunctions(redirectRecordKind, { legacy: legacyRedirects })
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every stored redirect of this deployment (records + the tolerant legacy read), ordered
|
|
72
|
+
* by `from` — the ONE server-side reader; the delivery snapshot projects it through
|
|
73
|
+
* `liveRedirectRecords` (EC-590 / EC-627: one enabled-redirect reader).
|
|
74
|
+
*/
|
|
75
|
+
export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Promise<RedirectEntry[]> {
|
|
76
|
+
const rows = await listRecordWire(ctx, redirectRecordKind, resolveRecordQuery(redirectRecordKind), legacyRedirects)
|
|
77
|
+
return rows.map((row) => recordEntryFromWire(redirectRecordKind, row, projectId))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* EC-616 — move every legacy `redirects` row into `records` (kind `redirect`), the
|
|
82
|
+
* redirect move's migration. Per row: a record of the same id already exists (a save
|
|
83
|
+
* lifted it, or an earlier run) → the legacy row is dropped as shadowed; otherwise the
|
|
84
|
+
* row's columns are written as a record CREATE through the one rule (`resolveRecordSave`
|
|
85
|
+
* — a legacy row the current schema refuses is REPORTED and left where it is, still
|
|
86
|
+
* served by the tolerant read, never dropped silently) and the legacy row deleted.
|
|
87
|
+
* Idempotent: a second run scans nothing.
|
|
88
|
+
*
|
|
89
|
+
* Internal: an admin runs it from the CLI, once per deployment —
|
|
90
|
+
* `npx convex run redirects:migrateLegacy` (no args). Built on the RAW internal-mutation
|
|
91
|
+
* builder, deliberately outside the write-chokepoint triggers: the served redirect set is
|
|
92
|
+
* identical before and after by construction (the tolerant read), so the move needs no
|
|
93
|
+
* revalidation — through the triggers it would schedule two sweeps per row (an insert
|
|
94
|
+
* into `records`, a delete from `redirects`). One transaction: Convex bounds it at 8192
|
|
95
|
+
* written rows, far above any editorial redirect set (KON's import holds 192).
|
|
96
|
+
*/
|
|
97
|
+
export const migrateLegacy = (internalMutationGeneric as MutationBuilder<DataModel, 'internal'>)({
|
|
75
98
|
args: {},
|
|
76
99
|
handler: async (ctx) => {
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
const legacy = await ctx.db.query('redirects').withIndex('by_redirect').collect()
|
|
101
|
+
const moved: string[] = []
|
|
102
|
+
const shadowed: string[] = []
|
|
103
|
+
const refused: Array<{ redirectId: string; message: string }> = []
|
|
104
|
+
for (const row of legacy) {
|
|
105
|
+
const record = legacyRecord(row)
|
|
106
|
+
if (await findRecordDoc(ctx as unknown as MutationCtx, redirectRecordKind, record.recordId)) {
|
|
107
|
+
await ctx.db.delete(row._id)
|
|
108
|
+
shadowed.push(record.recordId)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
let columns: Record<string, unknown>
|
|
112
|
+
try {
|
|
113
|
+
columns = resolveRecordSave(redirectRecordKind, record.recordId, undefined, record.columns)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!(error instanceof PersistenceValidationError)) throw error
|
|
116
|
+
refused.push({ redirectId: record.recordId, message: error.message })
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const slots = recordSlots(redirectRecordKind, columns, record.createdAt)
|
|
120
|
+
await ctx.db.insert('records', {
|
|
121
|
+
kind: redirectRecordKind.kind,
|
|
122
|
+
recordId: record.recordId,
|
|
123
|
+
data: columns,
|
|
124
|
+
at: slots.at,
|
|
125
|
+
createdAt: record.createdAt,
|
|
126
|
+
updatedAt: record.updatedAt,
|
|
127
|
+
})
|
|
128
|
+
await ctx.db.delete(row._id)
|
|
129
|
+
moved.push(record.recordId)
|
|
130
|
+
}
|
|
131
|
+
return { scanned: legacy.length, moved: moved.length, shadowed: shadowed.length, refused, movedIds: moved }
|
|
91
132
|
},
|
|
92
133
|
})
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { makeFunctionReference } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import type { GenericId } from 'convex/values'
|
|
7
|
+
import {
|
|
8
|
+
ROLE_POLICY_ENV,
|
|
9
|
+
defaultRolePolicy,
|
|
10
|
+
rolePolicyFingerprint,
|
|
11
|
+
serializeRolePolicy,
|
|
12
|
+
} from '@elytracms/core/persistence'
|
|
13
|
+
import type { RolePolicy } from '@elytracms/core/persistence'
|
|
14
|
+
import { resolveProject } from '@elytracms/core/studio-core'
|
|
15
|
+
import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
|
|
16
|
+
import { gateSessionCreation } from './auth'
|
|
17
|
+
import { requireCapability } from './guard'
|
|
18
|
+
import { deployedRolePolicy } from './roles'
|
|
19
|
+
import schema from './schema'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* EC-605 — the Convex trust boundary reads the project's role policy from the copy the
|
|
23
|
+
* CLI provisions into `ELYTRA_ROLE_POLICY`, and only that copy.
|
|
24
|
+
*
|
|
25
|
+
* The REAL `members` / `invites` / `roles` handlers and the REAL guard + integrity floor
|
|
26
|
+
* run on convex-test's in-memory backend; the env is stubbed per test (set, unset,
|
|
27
|
+
* malformed). Two member rows hold DIFFERENT custom roles, so a decision that silently
|
|
28
|
+
* fell back to the default policy (where both roles are unknown) is visible.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const modules = {
|
|
32
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
33
|
+
'./_generated/api.ts': async () => ({}),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ISSUER = 'https://convex.test'
|
|
37
|
+
|
|
38
|
+
/** The fourth-role proof: a `reviewer` that reads, between viewer and editor. */
|
|
39
|
+
const FOUR_ROLE: RolePolicy = {
|
|
40
|
+
roles: [
|
|
41
|
+
{ id: 'viewer', label: 'Viewer', capabilities: ['content.read'] },
|
|
42
|
+
{ id: 'reviewer', label: 'Reviewer', capabilities: ['content.read'] },
|
|
43
|
+
{ id: 'editor', label: 'Editor', capabilities: ['content.read', 'content.write'] },
|
|
44
|
+
{ id: 'admin', label: 'Admin', capabilities: ['content.read', 'content.write', 'project.administer'] },
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An administering role BELOW the strongest — what the floor's rank invariant guards. */
|
|
49
|
+
const RANKED: RolePolicy = {
|
|
50
|
+
roles: [
|
|
51
|
+
{ id: 'author', capabilities: ['content.read', 'content.write'] },
|
|
52
|
+
{ id: 'manager', capabilities: ['content.read', 'content.write', 'project.administer'] },
|
|
53
|
+
{ id: 'owner', capabilities: ['content.read', 'content.write', 'project.administer'] },
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const membersSet = makeFunctionReference<'mutation'>('members:set')
|
|
58
|
+
const membersList = makeFunctionReference<'query'>('members:list')
|
|
59
|
+
const invitesCreate = makeFunctionReference<'mutation'>('invites:create')
|
|
60
|
+
const fingerprintQuery = makeFunctionReference<'query'>('roles:fingerprint')
|
|
61
|
+
|
|
62
|
+
const NOW = '2026-10-26T00:00:00.000Z'
|
|
63
|
+
|
|
64
|
+
async function backendWith(rows: Array<{ userId: string; role: string }>) {
|
|
65
|
+
const t = convexTest(schema, modules)
|
|
66
|
+
await t.run(async (ctx) => {
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
await ctx.db.insert('members', { ...row, createdAt: NOW, updatedAt: NOW })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return t
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const as = (t: ReturnType<typeof convexTest>, userId: string) =>
|
|
75
|
+
t.withIdentity({ subject: userId, issuer: ISSUER })
|
|
76
|
+
|
|
77
|
+
/** Run the guard itself as `userId` — the one primitive every engine handler gates on. */
|
|
78
|
+
async function canDo(
|
|
79
|
+
t: ReturnType<typeof convexTest>,
|
|
80
|
+
userId: string,
|
|
81
|
+
capability: 'content.read' | 'content.write' | 'project.administer',
|
|
82
|
+
): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
await as(t, userId).run((ctx) => requireCapability(ctx, capability))
|
|
85
|
+
return true
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.unstubAllEnvs()
|
|
93
|
+
})
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.unstubAllEnvs()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('the deployed role policy (EC-605) — absent → the default, byte-identical', () => {
|
|
99
|
+
it('reads the default policy when ELYTRA_ROLE_POLICY is unset or blank', () => {
|
|
100
|
+
expect(deployedRolePolicy({})).toBe(defaultRolePolicy)
|
|
101
|
+
expect(deployedRolePolicy({ [ROLE_POLICY_ENV]: ' ' })).toBe(defaultRolePolicy)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keeps the historical tiers with no env: viewer reads, editor writes, admin administers', async () => {
|
|
105
|
+
const t = await backendWith([
|
|
106
|
+
{ userId: 'u_viewer', role: 'viewer' },
|
|
107
|
+
{ userId: 'u_editor', role: 'editor' },
|
|
108
|
+
{ userId: 'u_admin', role: 'admin' },
|
|
109
|
+
])
|
|
110
|
+
expect(await canDo(t, 'u_viewer', 'content.read')).toBe(true)
|
|
111
|
+
expect(await canDo(t, 'u_viewer', 'content.write')).toBe(false)
|
|
112
|
+
expect(await canDo(t, 'u_editor', 'content.write')).toBe(true)
|
|
113
|
+
expect(await canDo(t, 'u_editor', 'project.administer')).toBe(false)
|
|
114
|
+
expect(await canDo(t, 'u_admin', 'project.administer')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('refuses to store a role the default policy does not declare (members:set, invites:create)', async () => {
|
|
118
|
+
const t = await backendWith([{ userId: 'u_admin', role: 'admin' }])
|
|
119
|
+
await expect(
|
|
120
|
+
as(t, 'u_admin').mutation(membersSet, { userId: 'u_x', role: 'reviewer', createdAt: NOW, updatedAt: NOW }),
|
|
121
|
+
).rejects.toThrow(/invalid-role: role "reviewer" is not declared/)
|
|
122
|
+
await expect(
|
|
123
|
+
as(t, 'u_admin').mutation(invitesCreate, {
|
|
124
|
+
email: 'x@example.com',
|
|
125
|
+
role: 'reviewer',
|
|
126
|
+
createdAt: NOW,
|
|
127
|
+
updatedAt: NOW,
|
|
128
|
+
}),
|
|
129
|
+
).rejects.toThrow(/invalid-role/)
|
|
130
|
+
// Control: a declared role is stored.
|
|
131
|
+
const stored = await as(t, 'u_admin').mutation(membersSet, {
|
|
132
|
+
userId: 'u_x',
|
|
133
|
+
role: 'editor',
|
|
134
|
+
createdAt: NOW,
|
|
135
|
+
updatedAt: NOW,
|
|
136
|
+
})
|
|
137
|
+
expect(stored).toMatchObject({ userId: 'u_x', role: 'editor' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('answers the default fingerprint, sourced from the default', async () => {
|
|
141
|
+
const t = convexTest(schema, modules)
|
|
142
|
+
expect(await t.query(fingerprintQuery, {})).toEqual({
|
|
143
|
+
status: 'ok',
|
|
144
|
+
fingerprint: rolePolicyFingerprint(defaultRolePolicy),
|
|
145
|
+
source: 'default',
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('a provisioned four-role policy — honoured by the guard with two custom-role members', () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
vi.stubEnv(ROLE_POLICY_ENV, serializeRolePolicy(FOUR_ROLE))
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('stores the custom role (members:set) and the guard grants by it', async () => {
|
|
156
|
+
const t = await backendWith([{ userId: 'u_admin', role: 'admin' }])
|
|
157
|
+
for (const [userId, role] of [
|
|
158
|
+
['u_reviewer', 'reviewer'],
|
|
159
|
+
['u_editor', 'editor'],
|
|
160
|
+
] as const) {
|
|
161
|
+
const stored = await as(t, 'u_admin').mutation(membersSet, { userId, role, createdAt: NOW, updatedAt: NOW })
|
|
162
|
+
expect(stored).toMatchObject({ userId, role })
|
|
163
|
+
}
|
|
164
|
+
// The reviewer reads (a real handler) but cannot write; the editor writes.
|
|
165
|
+
expect(await as(t, 'u_reviewer').query(membersList, {})).toHaveLength(3)
|
|
166
|
+
expect(await canDo(t, 'u_reviewer', 'content.read')).toBe(true)
|
|
167
|
+
expect(await canDo(t, 'u_reviewer', 'content.write')).toBe(false)
|
|
168
|
+
expect(await canDo(t, 'u_editor', 'content.write')).toBe(true)
|
|
169
|
+
expect(await canDo(t, 'u_editor', 'project.administer')).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('control: without the env the same reviewer row grants NOTHING (unknown role fails closed)', async () => {
|
|
173
|
+
vi.unstubAllEnvs()
|
|
174
|
+
const t = await backendWith([{ userId: 'u_reviewer', role: 'reviewer' }])
|
|
175
|
+
expect(await canDo(t, 'u_reviewer', 'content.read')).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('answers the provisioned policy’s fingerprint, sourced from the env', async () => {
|
|
179
|
+
const t = convexTest(schema, modules)
|
|
180
|
+
expect(await t.query(fingerprintQuery, {})).toEqual({
|
|
181
|
+
status: 'ok',
|
|
182
|
+
fingerprint: rolePolicyFingerprint(FOUR_ROLE),
|
|
183
|
+
source: 'env',
|
|
184
|
+
})
|
|
185
|
+
expect(rolePolicyFingerprint(FOUR_ROLE)).not.toBe(rolePolicyFingerprint(defaultRolePolicy))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('stores a custom role through the production adapter unchanged (no silent viewer floor)', async () => {
|
|
189
|
+
const t = await backendWith([{ userId: 'u_admin', role: 'admin' }])
|
|
190
|
+
const asAdmin = as(t, 'u_admin')
|
|
191
|
+
const client = {
|
|
192
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
|
|
193
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
|
|
194
|
+
asAdmin.mutation(ref, args ?? {}),
|
|
195
|
+
} as unknown as ConvexHttpClient
|
|
196
|
+
// users:get joins the auth `users` row; give the member an identity row first.
|
|
197
|
+
const userId = await t.run(async (ctx) => {
|
|
198
|
+
const id = await ctx.db.insert('users', { email: 'rev@example.com' })
|
|
199
|
+
return String(id)
|
|
200
|
+
})
|
|
201
|
+
const adapter = new ConvexPersistenceAdapter(client, 'role-policy')
|
|
202
|
+
await adapter.users.saveUser('role-policy', { id: userId, roles: ['reviewer'] })
|
|
203
|
+
const row = await t.run((ctx) =>
|
|
204
|
+
ctx.db
|
|
205
|
+
.query('members')
|
|
206
|
+
.withIndex('by_user', (q) => q.eq('userId', userId))
|
|
207
|
+
.first(),
|
|
208
|
+
)
|
|
209
|
+
expect(row?.role).toBe('reviewer')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('the integrity floor under a ranked custom policy (an administering role below the strongest)', () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
vi.stubEnv(ROLE_POLICY_ENV, serializeRolePolicy(RANKED))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('a manager assigns roles up to its own rank, never above (members:set and invites:create)', async () => {
|
|
219
|
+
const t = await backendWith([
|
|
220
|
+
{ userId: 'u_owner', role: 'owner' },
|
|
221
|
+
{ userId: 'u_manager', role: 'manager' },
|
|
222
|
+
])
|
|
223
|
+
const manager = as(t, 'u_manager')
|
|
224
|
+
await expect(
|
|
225
|
+
manager.mutation(membersSet, { userId: 'u_new', role: 'author', createdAt: NOW, updatedAt: NOW }),
|
|
226
|
+
).resolves.toMatchObject({ role: 'author' })
|
|
227
|
+
await expect(
|
|
228
|
+
manager.mutation(membersSet, { userId: 'u_new', role: 'manager', createdAt: NOW, updatedAt: NOW }),
|
|
229
|
+
).resolves.toMatchObject({ role: 'manager' })
|
|
230
|
+
await expect(
|
|
231
|
+
manager.mutation(membersSet, { userId: 'u_new', role: 'owner', createdAt: NOW, updatedAt: NOW }),
|
|
232
|
+
).rejects.toThrow(/cannot assign the “owner” role/)
|
|
233
|
+
await expect(
|
|
234
|
+
manager.mutation(invitesCreate, { email: 'boss@example.com', role: 'owner', createdAt: NOW, updatedAt: NOW }),
|
|
235
|
+
).rejects.toThrow(/cannot assign the “owner” role/)
|
|
236
|
+
// Control: the owner (strongest) may invite an owner.
|
|
237
|
+
await expect(
|
|
238
|
+
as(t, 'u_owner').mutation(invitesCreate, { email: 'boss@example.com', role: 'owner', createdAt: NOW, updatedAt: NOW }),
|
|
239
|
+
).resolves.toMatchObject({ role: 'owner' })
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('counts every administering role for "the last administrator" — demoting one of two is allowed, the last is not', async () => {
|
|
243
|
+
const t = await backendWith([
|
|
244
|
+
{ userId: 'u_owner', role: 'owner' },
|
|
245
|
+
{ userId: 'u_manager', role: 'manager' },
|
|
246
|
+
])
|
|
247
|
+
// Two administrators: the owner demotes the manager to author.
|
|
248
|
+
await expect(
|
|
249
|
+
as(t, 'u_owner').mutation(membersSet, { userId: 'u_manager', role: 'author', createdAt: NOW, updatedAt: NOW }),
|
|
250
|
+
).resolves.toMatchObject({ role: 'author' })
|
|
251
|
+
// Now the owner is the last administrator: demoting themselves is refused.
|
|
252
|
+
await expect(
|
|
253
|
+
as(t, 'u_owner').mutation(membersSet, { userId: 'u_owner', role: 'manager', createdAt: NOW, updatedAt: NOW }),
|
|
254
|
+
).resolves.toMatchObject({ role: 'manager' })
|
|
255
|
+
await expect(
|
|
256
|
+
as(t, 'u_owner').mutation(membersSet, { userId: 'u_owner', role: 'author', createdAt: NOW, updatedAt: NOW }),
|
|
257
|
+
).rejects.toThrow(/last admin/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('bootstraps the first user into the strongest role (owner), not a literal "admin"', async () => {
|
|
261
|
+
const t = convexTest(schema, modules)
|
|
262
|
+
const userId = await t.run((ctx) => ctx.db.insert('users', { email: 'first@example.com' }))
|
|
263
|
+
await t.run((ctx) => gateSessionCreation(ctx as never, { userId: userId as GenericId<'users'> }))
|
|
264
|
+
const row = await t.run((ctx) => ctx.db.query('members').first())
|
|
265
|
+
expect(row?.role).toBe('owner')
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('a malformed or invalid deployed copy throws — never a silent default', () => {
|
|
270
|
+
it('every guarded function fails, naming ELYTRA_ROLE_POLICY', async () => {
|
|
271
|
+
vi.stubEnv(ROLE_POLICY_ENV, '{not json')
|
|
272
|
+
const t = await backendWith([{ userId: 'u_admin', role: 'admin' }])
|
|
273
|
+
await expect(as(t, 'u_admin').query(membersList, {})).rejects.toThrow(/ELYTRA_ROLE_POLICY is not valid JSON/)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('an invalid policy (no administering role) is refused with the located rule', () => {
|
|
277
|
+
const invalid = JSON.stringify({ v: 1, roles: [{ id: 'viewer', capabilities: ['content.read'] }] })
|
|
278
|
+
expect(() => deployedRolePolicy({ [ROLE_POLICY_ENV]: invalid })).toThrow(/No role grants “project.administer”/)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('the fingerprint query answers `invalid` with the reason instead of throwing', async () => {
|
|
282
|
+
vi.stubEnv(ROLE_POLICY_ENV, '{"v":2,"roles":[]}')
|
|
283
|
+
const t = convexTest(schema, modules)
|
|
284
|
+
const answer = (await t.query(fingerprintQuery, {})) as { status: string; detail: string }
|
|
285
|
+
expect(answer.status).toBe('invalid')
|
|
286
|
+
expect(answer.detail).toMatch(/ELYTRA_ROLE_POLICY is not a serialised role policy/)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('a changed env is read on the next call (the parse is cached per string, not per process)', () => {
|
|
290
|
+
const four = serializeRolePolicy(FOUR_ROLE)
|
|
291
|
+
expect(deployedRolePolicy({ [ROLE_POLICY_ENV]: four }).roles.map((r) => r.id)).toContain('reviewer')
|
|
292
|
+
expect(deployedRolePolicy({ [ROLE_POLICY_ENV]: serializeRolePolicy(RANKED) }).roles.map((r) => r.id)).toEqual([
|
|
293
|
+
'author',
|
|
294
|
+
'manager',
|
|
295
|
+
'owner',
|
|
296
|
+
])
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('one declaration, two readers — the config entry reaches the guard through the provisioned copy', () => {
|
|
301
|
+
it('resolveProject → serializeRolePolicy → ELYTRA_ROLE_POLICY → the guard grants the declared reviewer', async () => {
|
|
302
|
+
const resolved = resolveProject({
|
|
303
|
+
name: 'Acme',
|
|
304
|
+
slug: 'acme',
|
|
305
|
+
locales: { default: 'en', locales: ['en'] },
|
|
306
|
+
roles: FOUR_ROLE.roles.map((role) => ({ ...role, capabilities: [...role.capabilities] })),
|
|
307
|
+
})
|
|
308
|
+
expect(resolved.ok).toBe(true)
|
|
309
|
+
if (!resolved.ok) return
|
|
310
|
+
vi.stubEnv(ROLE_POLICY_ENV, serializeRolePolicy(resolved.project.roles!))
|
|
311
|
+
const t = await backendWith([{ userId: 'u_reviewer', role: 'reviewer' }])
|
|
312
|
+
expect(await canDo(t, 'u_reviewer', 'content.read')).toBe(true)
|
|
313
|
+
expect(await canDo(t, 'u_reviewer', 'content.write')).toBe(false)
|
|
314
|
+
})
|
|
315
|
+
})
|
package/convex/roles.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ROLE_POLICY_ENV,
|
|
3
|
+
defaultRolePolicy,
|
|
4
|
+
parseRolePolicy,
|
|
5
|
+
rolePolicyFingerprint,
|
|
6
|
+
} from '@elytracms/core/persistence'
|
|
7
|
+
import type { RolePolicy } from '@elytracms/core/persistence'
|
|
8
|
+
import { query } from './functions'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The role policy this deployment ENFORCES (EC-605) — the Convex trust boundary's
|
|
12
|
+
* reader of the project's `roles` declaration.
|
|
13
|
+
*
|
|
14
|
+
* Convex functions cannot import `elytra.config.ts`, and the nine engine modules that
|
|
15
|
+
* gate on a capability import `./guard` inside the package, so the declaration reaches
|
|
16
|
+
* the deployment as a serialised copy in its env: `ELYTRA_ROLE_POLICY`, provisioned by
|
|
17
|
+
* the `elytra` CLI from the resolved project (`elytra init`, and `elytra build` before
|
|
18
|
+
* every Convex deploy). `guard.ts`, `authority.ts`, `members.ts`, `invites.ts` and the
|
|
19
|
+
* first-user bootstrap (`auth.ts`) all read it through {@link deployedRolePolicy}.
|
|
20
|
+
*
|
|
21
|
+
* - **Absent (or blank)** → the shipped default policy, byte-identical to before EC-605.
|
|
22
|
+
* - **Present but malformed or invalid** → THROWS, naming every reason
|
|
23
|
+
* (`parseRolePolicy`): every guarded function then fails loudly rather than enforcing
|
|
24
|
+
* a policy nobody declared. Never a silent default.
|
|
25
|
+
*
|
|
26
|
+
* The env is read per CALL (a provisioned change takes effect without a redeploy) and
|
|
27
|
+
* the parse is cached per serialised string, so the steady state costs one string
|
|
28
|
+
* comparison.
|
|
29
|
+
*
|
|
30
|
+
* The public {@link fingerprint} query is how a drift between this copy and the config
|
|
31
|
+
* becomes visible: `elytra doctor` and the studio compare it with the config's own
|
|
32
|
+
* fingerprint. Consumers reach it through a one-line re-export (`convex/roles.ts`); the
|
|
33
|
+
* readers above work without it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
let cached: { readonly serialized: string; readonly policy: RolePolicy } | undefined
|
|
37
|
+
|
|
38
|
+
/** The deployment env, reached via `globalThis` so this module needs no Node types at runtime. */
|
|
39
|
+
function deploymentEnv(): Record<string, string | undefined> {
|
|
40
|
+
return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The deployed role policy: the parsed `ELYTRA_ROLE_POLICY`, or the default when it is
|
|
45
|
+
* unset. Throws on a malformed or invalid value (see the module comment). `env` is
|
|
46
|
+
* injectable for tests.
|
|
47
|
+
*/
|
|
48
|
+
export function deployedRolePolicy(env: Record<string, string | undefined> = deploymentEnv()): RolePolicy {
|
|
49
|
+
const serialized = env[ROLE_POLICY_ENV]
|
|
50
|
+
if (serialized === undefined || serialized.trim() === '') return defaultRolePolicy
|
|
51
|
+
if (cached?.serialized === serialized) return cached.policy
|
|
52
|
+
const policy = parseRolePolicy(serialized)
|
|
53
|
+
cached = { serialized, policy }
|
|
54
|
+
return policy
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What {@link fingerprint} answers — the deployed policy's identity, never its content. */
|
|
58
|
+
export type DeployedRolePolicyFingerprint =
|
|
59
|
+
| { readonly status: 'ok'; readonly fingerprint: string; readonly source: 'env' | 'default' }
|
|
60
|
+
| { readonly status: 'invalid'; readonly detail: string }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* PUBLIC, unauthenticated: the fingerprint of the role policy this deployment enforces
|
|
64
|
+
* (`rolePolicyFingerprint` — 16 hex chars over ids, rank and capabilities; labels and
|
|
65
|
+
* the policy itself never leave the deployment) and whether it comes from
|
|
66
|
+
* `ELYTRA_ROLE_POLICY` or the default. `elytra doctor` reads it without a session (the
|
|
67
|
+
* doctor's env rule — names only, values never printed — stands: this is not a value),
|
|
68
|
+
* and the studio reads it at boot. A malformed or invalid deployed copy answers
|
|
69
|
+
* `invalid` with the located reasons instead of throwing, so the drift check can say
|
|
70
|
+
* exactly what is wrong.
|
|
71
|
+
*/
|
|
72
|
+
export const fingerprint = query({
|
|
73
|
+
args: {},
|
|
74
|
+
handler: async (): Promise<DeployedRolePolicyFingerprint> => {
|
|
75
|
+
const env = deploymentEnv()
|
|
76
|
+
const source = env[ROLE_POLICY_ENV]?.trim() ? 'env' : 'default'
|
|
77
|
+
try {
|
|
78
|
+
return { status: 'ok', fingerprint: rolePolicyFingerprint(deployedRolePolicy(env)), source }
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { status: 'invalid', detail: error instanceof Error ? error.message : String(error) }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
})
|