spine-framework 0.3.145 → 0.4.0
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/.env.example +76 -0
- package/.framework/.spine-manifest.json +19 -0
- package/.framework/cli/commands/agents.ts +61 -33
- package/.framework/cli/commands/auth.ts +60 -28
- package/.framework/cli/commands/create-app.ts +212 -80
- package/.framework/cli/commands/dev.ts +184 -68
- package/.framework/cli/commands/doctor.ts +324 -152
- package/.framework/cli/commands/generate.ts +119 -68
- package/.framework/cli/commands/init.ts +141 -242
- package/.framework/cli/commands/install-app.ts +1336 -314
- package/.framework/cli/commands/items.ts +31 -29
- package/.framework/cli/commands/migrate.ts +299 -156
- package/.framework/cli/commands/migrations.ts +62 -30
- package/.framework/cli/commands/pipelines.ts +31 -29
- package/.framework/cli/commands/status.ts +105 -45
- package/.framework/cli/commands/system.ts +47 -42
- package/.framework/cli/commands/test.ts +161 -102
- package/.framework/cli/commands/upgrade.ts +260 -0
- package/.framework/cli/commands/validate-app.ts +517 -161
- package/.framework/cli/context.ts +117 -85
- package/.framework/cli/env-loader.ts +74 -43
- package/.framework/cli/index.ts +70 -46
- package/.framework/cli/lib/project-files.ts +404 -0
- package/.framework/docs/guides/testing-guide.md +1 -1
- package/.framework/docs/runtime-types.md +72 -72
- package/.framework/functions/_shared/access.ts +84 -0
- package/.framework/functions/_shared/agent-runner.ts +5051 -1026
- package/.framework/functions/_shared/app-manifest.ts +214 -94
- package/.framework/functions/_shared/ast.ts +214 -0
- package/.framework/functions/_shared/audit.ts +91 -55
- package/.framework/functions/_shared/cil.ts +368 -127
- package/.framework/functions/_shared/db.ts +337 -68
- package/.framework/functions/_shared/funnel.ts +228 -0
- package/.framework/functions/_shared/geo.ts +105 -0
- package/.framework/functions/_shared/hash-api-key.ts +94 -17
- package/.framework/functions/_shared/index.ts +22 -224
- package/.framework/functions/_shared/kb.ts +832 -58
- package/.framework/functions/_shared/llm.ts +118 -18
- package/.framework/functions/_shared/logging.ts +317 -19
- package/.framework/functions/_shared/middleware.ts +701 -131
- package/.framework/functions/_shared/notify.ts +70 -0
- package/.framework/functions/_shared/outcomes.ts +34 -19
- package/.framework/functions/_shared/permissions.ts +501 -315
- package/.framework/functions/_shared/pipeline-runner.ts +704 -155
- package/.framework/functions/_shared/principal.ts +436 -263
- package/.framework/functions/_shared/rate-limit.ts +233 -0
- package/.framework/functions/_shared/resolve-credentials.ts +86 -19
- package/.framework/functions/_shared/resolve-ids.ts +203 -139
- package/.framework/functions/_shared/retry-deliveries.ts +51 -16
- package/.framework/functions/_shared/schema-utils.ts +602 -521
- package/.framework/functions/_shared/security.ts +345 -0
- package/.framework/functions/_shared/testing.ts +197 -172
- package/.framework/functions/_shared/tracing.ts +351 -21
- package/.framework/functions/_shared/trigger-engine.ts +425 -136
- package/.framework/functions/_shared/webhook-registration.ts +71 -70
- package/.framework/functions/_shared/webhook-registry.ts +126 -83
- package/.framework/functions/account-nodes.ts +30 -30
- package/.framework/functions/adapters/generic-webhook-inbound.ts +49 -16
- package/.framework/functions/adapters/generic-webhook-outbound.ts +17 -17
- package/.framework/functions/adapters/openai-outbound.ts +15 -16
- package/.framework/functions/admin-data.ts +596 -173
- package/.framework/functions/admin-funnel.ts +64 -60
- package/.framework/functions/admin-install-funnel.ts +84 -66
- package/.framework/functions/admin-signals.ts +69 -51
- package/.framework/functions/ai-agents.ts +431 -106
- package/.framework/functions/ai-interactions.ts +186 -112
- package/.framework/functions/api-keys.ts +107 -107
- package/.framework/functions/apps.ts +154 -136
- package/.framework/functions/auth.ts +83 -65
- package/.framework/functions/cil-agent-ingest.ts +189 -232
- package/.framework/functions/cil-agent.ts +433 -177
- package/.framework/functions/cil-callmap.ts +642 -430
- package/.framework/functions/cil-config.ts +139 -135
- package/.framework/functions/cil-context.ts +214 -127
- package/.framework/functions/cil-doc.ts +587 -458
- package/.framework/functions/cil-embeddings.ts +453 -364
- package/.framework/functions/cil-graph.ts +427 -142
- package/.framework/functions/cil-ingest.ts +1418 -1154
- package/.framework/functions/cil-pipeline.ts +276 -21
- package/.framework/functions/cil-project.ts +659 -132
- package/.framework/functions/cil-validate.ts +198 -164
- package/.framework/functions/debug-auth.ts +57 -55
- package/.framework/functions/deliveries.ts +80 -21
- package/.framework/functions/embeddings.ts +1023 -318
- package/.framework/functions/eval-harness.ts +146 -108
- package/.framework/functions/integration-outbound.ts +205 -75
- package/.framework/functions/integration-routes.ts +128 -23
- package/.framework/functions/integrations.ts +125 -100
- package/.framework/functions/invites.ts +1095 -460
- package/.framework/functions/item-progress.ts +158 -102
- package/.framework/functions/kb.ts +34 -10
- package/.framework/functions/logs.ts +133 -133
- package/.framework/functions/observability.ts +152 -122
- package/.framework/functions/pipeline-executions.ts +147 -132
- package/.framework/functions/pipelines.ts +134 -119
- package/.framework/functions/prompt-configs.ts +96 -80
- package/.framework/functions/roles.ts +112 -82
- package/.framework/functions/security.ts +450 -0
- package/.framework/functions/system-cron.ts +117 -80
- package/.framework/functions/system.ts +158 -98
- package/.framework/functions/tests.ts +29 -29
- package/.framework/functions/timers.ts +94 -95
- package/.framework/functions/trigger-scheduler.ts +10 -0
- package/.framework/functions/triggers.ts +179 -149
- package/.framework/functions/types.ts +137 -125
- package/.framework/mcp/index.ts +133 -0
- package/.framework/mcp/load-env.ts +60 -0
- package/.framework/mcp/shared/app-manifest.ts +275 -0
- package/.framework/mcp/shared/db.ts +477 -0
- package/.framework/mcp/shared/kb.ts +118 -0
- package/.framework/mcp/tools/architect.ts +431 -0
- package/.framework/mcp/tools/cil/context.ts +345 -0
- package/.framework/mcp/tools/cil/graph.ts +242 -0
- package/.framework/mcp/tools/cil/ingest.ts +1217 -0
- package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +693 -0
- package/.framework/mcp/tools/cil/pipeline.ts +767 -0
- package/.framework/mcp/tools/cil/project.ts +791 -0
- package/.framework/mcp/tools/cil/retrieval.ts +336 -0
- package/.framework/mcp/tools/cil/search-core.ts +454 -0
- package/.framework/mcp/tools/cil/search.ts +97 -0
- package/.framework/mcp/tools/cil/suggest.ts +186 -0
- package/.framework/mcp/tools/cil/validate.ts +1369 -0
- package/.framework/mcp/tools/kb/delete.ts +139 -0
- package/.framework/mcp/tools/kb/index.ts +52 -0
- package/.framework/mcp/tools/kb/ingest.ts +84 -0
- package/.framework/mcp/tools/kb/list-types.ts +102 -0
- package/.framework/mcp/tools/kb/publish.ts +100 -0
- package/.framework/mcp/tools/kb/reindex.ts +86 -0
- package/.framework/mcp/tools/kb/revoke.ts +85 -0
- package/.framework/mcp/tools/kb/search.ts +101 -0
- package/.framework/mcp/tools/kb/upsert.ts +125 -0
- package/.framework/mcp/tools/observability.ts +510 -0
- package/.framework/migrations/001_core_schema.sql +15 -0
- package/.framework/migrations/002_core_rls_and_functions.sql +15 -0
- package/.framework/migrations/003_core_system_data.sql +16 -1
- package/.framework/migrations/004_core_rpc_and_broker.sql +17 -1
- package/.framework/migrations/005_core_observability.sql +17 -1
- package/.framework/migrations/006_security_hardening_rls.sql +15 -0
- package/.framework/migrations/007_kb_article_list.sql +17 -1
- package/.framework/migrations/008_items_target.sql +15 -0
- package/.framework/migrations/009_roles_slug_unique.sql +15 -0
- package/.framework/migrations/010_auth_user_trigger.sql +15 -0
- package/.framework/migrations/011_spine_system_account.sql +16 -0
- package/.framework/migrations/012_service_role_grants.sql +16 -0
- package/.framework/migrations/013_get_account_apps_role_filter.sql +15 -0
- package/.framework/migrations/014_apps_required_roles.sql +16 -0
- package/.framework/migrations/015_types_kind_slug_unique.sql +15 -0
- package/.framework/migrations/016_cil_types.sql +281 -0
- package/.framework/migrations/017_cil_stable_ids.sql +37 -0
- package/.framework/migrations/018_runtime_tables.sql +129 -0
- package/.framework/migrations/019_agent_prompt_columns.sql +56 -0
- package/.framework/migrations/020_schema_reconciliation.sql +67 -0
- package/.framework/migrations/021_kb_embedding_lifecycle.sql +31 -0
- package/.framework/migrations/032_cil_status_migration.sql +73 -0
- package/.framework/migrations/033_cil_versioning.sql +73 -0
- package/.framework/migrations/034_match_embeddings_knn.sql +115 -0
- package/.framework/migrations/035_rate_limits.sql +55 -0
- package/.framework/migrations/036_demo_record_flags.sql +72 -0
- package/.framework/migrations/037_kb_types_canonical.sql +244 -0
- package/.framework/migrations/038_drop_demo_record_flags.sql +38 -0
- package/.framework/migrations/042_registration_hierarchy.sql +309 -0
- package/.framework/migrations/043_policy_actor_cleanup.sql +39 -0
- package/.framework/migrations/044_match_embeddings_filtered_knn.sql +151 -0
- package/.framework/migrations/045_security_critical_rls.sql +139 -0
- package/.framework/migrations/046_security_missing_rls.sql +79 -0
- package/.framework/migrations/047_people_updated_by.sql +27 -0
- package/.framework/migrations/048_watcher_last_seen_at.sql +24 -0
- package/.framework/migrations/049_security_blocklist.sql +148 -0
- package/.framework/migrations/050_notifications.sql +49 -0
- package/.framework/migrations/051_message_agent_fields.sql +74 -0
- package/.framework/migrations/052_registration_security.sql +421 -0
- package/.framework/migrations/053_builtin_action_seeds.sql +66 -0
- package/.framework/migrations/054_kb_ingest_lease.sql +157 -0
- package/.framework/migrations/055_type_id_drift_reconciliation.sql +33 -0
- package/.framework/migrations/056_slug_column_reconciliation.sql +38 -0
- package/.framework/migrations/057_pipelines_slug_identity.sql +14 -0
- package/.framework/migrations/058_account_data_field_declarations.sql +53 -0
- package/.framework/migrations/059_registration_placement_fixes.sql +269 -0
- package/.framework/migrations/060_account_person_record_permissions.sql +38 -0
- package/.framework/migrations/061_fk_dedup.sql +32 -0
- package/.framework/public/spine-logo.svg +10 -3
- package/.framework/scripts/app-install-cli.ts +190 -93
- package/.framework/scripts/assemble-frontend.sh +127 -44
- package/.framework/scripts/assemble-functions.sh +128 -30
- package/.framework/scripts/boundary-check.sh +6 -2
- package/.framework/scripts/build-manifest.sh +24 -68
- package/.framework/scripts/check-core-integrity.sh +15 -76
- package/.framework/scripts/checksum-manifest.mts +343 -0
- package/.framework/scripts/cil-migrate-markers.ts +403 -0
- package/.framework/scripts/fix-cil-markers.mjs +176 -0
- package/.framework/scripts/verify-integrity.sh +21 -57
- package/.framework/scripts/verify-manifest.mts +305 -0
- package/.framework/seeds/types/funnel_signal_use.json +74 -21
- package/.framework/seeds/types/spine_instance.json +73 -21
- package/.framework/src/App.tsx +118 -72
- package/.framework/src/apps/admin/index.tsx +855 -58
- package/.framework/src/components/AppWrapper.tsx +43 -45
- package/.framework/src/components/CustomAppLoader.tsx +160 -116
- package/.framework/src/components/admin/AdminListPage.tsx +46 -44
- package/.framework/src/components/admin/AdminSidebar.tsx +57 -47
- package/.framework/src/components/admin/AdminStatsCard.tsx +46 -44
- package/.framework/src/components/admin/PageInstructions.tsx +98 -2
- package/.framework/src/components/admin/SortableTableHeader.tsx +44 -45
- package/.framework/src/components/app-shell/GenericAppShell.tsx +64 -65
- package/.framework/src/components/app-shell/GenericDetailPage.tsx +44 -47
- package/.framework/src/components/app-shell/GenericListPage.tsx +45 -49
- package/.framework/src/components/app-sidebar.tsx +42 -45
- package/.framework/src/components/auth/ProtectedRoute.tsx +48 -41
- package/.framework/src/components/auth/TurnstileWidget.tsx +27 -0
- package/.framework/src/components/kb/KBArticleEditor.tsx +231 -22
- package/.framework/src/components/kb/KBArticleList.tsx +251 -10
- package/.framework/src/components/layout/AppShell.tsx +51 -45
- package/.framework/src/components/layout/Header.tsx +42 -43
- package/.framework/src/components/layout/Layout.tsx +46 -46
- package/.framework/src/components/layout/Sidebar.tsx +196 -96
- package/.framework/src/components/runtime/DataDetailHeader.tsx +55 -30
- package/.framework/src/components/runtime/DataDetailPage.tsx +31 -269
- package/.framework/src/components/runtime/DataFilters.tsx +49 -74
- package/.framework/src/components/runtime/DataHeader.tsx +53 -32
- package/.framework/src/components/runtime/DataListPage.tsx +51 -155
- package/.framework/src/components/runtime/DataStats.tsx +66 -44
- package/.framework/src/components/runtime/DataTable.tsx +49 -149
- package/.framework/src/components/runtime/SchemaDetailForm.tsx +45 -75
- package/.framework/src/components/runtime/index.ts +119 -16
- package/.framework/src/components/search-form.tsx +46 -29
- package/.framework/src/components/shared/AgentView.tsx +65 -48
- package/.framework/src/components/shared/FieldRenderer.tsx +144 -50
- package/.framework/src/components/shared/InviteManager.tsx +207 -97
- package/.framework/src/components/shared/MemberList.tsx +97 -0
- package/.framework/src/components/shared/SchemaFields.tsx +107 -86
- package/.framework/src/components/ui/DataTable.tsx +80 -68
- package/.framework/src/components/ui/Form.tsx +157 -146
- package/.framework/src/components/ui/ItemCard.tsx +74 -67
- package/.framework/src/components/ui/ItemListView.tsx +73 -68
- package/.framework/src/components/ui/LoadingSpinner.tsx +43 -48
- package/.framework/src/components/ui/Modal.tsx +78 -68
- package/.framework/src/components/ui/RichTextEditor.tsx +89 -69
- package/.framework/src/components/ui/accordion.tsx +85 -86
- package/.framework/src/components/ui/alert-dialog.tsx +197 -184
- package/.framework/src/components/ui/alert.tsx +105 -94
- package/.framework/src/components/ui/aspect-ratio.tsx +57 -43
- package/.framework/src/components/ui/avatar.tsx +122 -107
- package/.framework/src/components/ui/badge.tsx +60 -55
- package/.framework/src/components/ui/breadcrumb.tsx +127 -128
- package/.framework/src/components/ui/button-group.tsx +86 -72
- package/.framework/src/components/ui/button.tsx +71 -50
- package/.framework/src/components/ui/calendar.tsx +72 -57
- package/.framework/src/components/ui/card.tsx +136 -129
- package/.framework/src/components/ui/carousel.tsx +226 -107
- package/.framework/src/components/ui/chart.tsx +252 -147
- package/.framework/src/components/ui/checkbox.tsx +43 -44
- package/.framework/src/components/ui/collapsible.tsx +86 -71
- package/.framework/src/components/ui/combobox.tsx +282 -249
- package/.framework/src/components/ui/command.tsx +158 -146
- package/.framework/src/components/ui/context-menu.tsx +239 -240
- package/.framework/src/components/ui/dialog.tsx +174 -157
- package/.framework/src/components/ui/direction.tsx +72 -56
- package/.framework/src/components/ui/drawer.tsx +168 -169
- package/.framework/src/components/ui/dropdown-menu.tsx +254 -223
- package/.framework/src/components/ui/empty.tsx +131 -120
- package/.framework/src/components/ui/field.tsx +205 -173
- package/.framework/src/components/ui/hover-card.tsx +71 -72
- package/.framework/src/components/ui/input-group.tsx +148 -133
- package/.framework/src/components/ui/input-otp.tsx +99 -86
- package/.framework/src/components/ui/input.tsx +43 -41
- package/.framework/src/components/ui/item.tsx +207 -185
- package/.framework/src/components/ui/kbd.tsx +57 -56
- package/.framework/src/components/ui/label.tsx +45 -41
- package/.framework/src/components/ui/menubar.tsx +268 -236
- package/.framework/src/components/ui/native-select.tsx +85 -86
- package/.framework/src/components/ui/navigation-menu.tsx +166 -156
- package/.framework/src/components/ui/pagination.tsx +131 -141
- package/.framework/src/components/ui/popover.tsx +125 -128
- package/.framework/src/components/ui/progress.tsx +47 -44
- package/.framework/src/components/ui/radio-group.tsx +60 -55
- package/.framework/src/components/ui/resizable.tsx +85 -72
- package/.framework/src/components/ui/scroll-area.tsx +57 -58
- package/.framework/src/components/ui/select.tsx +182 -169
- package/.framework/src/components/ui/separator.tsx +42 -43
- package/.framework/src/components/ui/sheet.tsx +170 -159
- package/.framework/src/components/ui/sidebar.tsx +395 -398
- package/.framework/src/components/ui/skeleton.tsx +42 -43
- package/.framework/src/components/ui/slider.tsx +61 -44
- package/.framework/src/components/ui/sonner.tsx +44 -44
- package/.framework/src/components/ui/spinner.tsx +47 -42
- package/.framework/src/components/ui/switch.tsx +56 -42
- package/.framework/src/components/ui/table-primitives.tsx +272 -132
- package/.framework/src/components/ui/table.tsx +140 -142
- package/.framework/src/components/ui/tabs.tsx +110 -93
- package/.framework/src/components/ui/textarea.tsx +43 -44
- package/.framework/src/components/ui/toggle-group.tsx +87 -71
- package/.framework/src/components/ui/toggle.tsx +56 -56
- package/.framework/src/components/ui/tooltip.tsx +99 -85
- package/.framework/src/contexts/AppContext.tsx +166 -114
- package/.framework/src/contexts/AuthContext.tsx +232 -137
- package/.framework/src/hooks/use-mobile.ts +43 -44
- package/.framework/src/hooks/useApi.ts +185 -172
- package/.framework/src/hooks/useAppPath.ts +31 -29
- package/.framework/src/hooks/useApps.ts +77 -42
- package/.framework/src/hooks/useEntityList.ts +66 -29
- package/.framework/src/hooks/useEntityRecord.ts +76 -32
- package/.framework/src/hooks/useForm.ts +62 -30
- package/.framework/src/hooks/useListSchema.ts +60 -43
- package/.framework/src/hooks/useSchemaRecord.ts +115 -95
- package/.framework/src/lib/api.ts +258 -134
- package/.framework/src/lib/registration.ts +181 -0
- package/.framework/src/lib/supabase.ts +118 -81
- package/.framework/src/lib/utils.ts +266 -266
- package/.framework/src/main.tsx +43 -44
- package/.framework/src/pages/DashboardPage.tsx +44 -43
- package/.framework/src/pages/NoAppPage.tsx +50 -0
- package/.framework/src/pages/NotFoundPage.tsx +32 -29
- package/.framework/src/pages/SetupPage.tsx +34 -33
- package/.framework/src/pages/admin/AIAgentDetailPage.tsx +69 -58
- package/.framework/src/pages/admin/AIAgentsPage.tsx +43 -40
- package/.framework/src/pages/admin/APIKeyDetailPage.tsx +63 -55
- package/.framework/src/pages/admin/APIKeysPage.tsx +44 -42
- package/.framework/src/pages/admin/AlertsConfigPage.tsx +117 -94
- package/.framework/src/pages/admin/AppDetailPage.tsx +48 -42
- package/.framework/src/pages/admin/AppsPage.tsx +45 -42
- package/.framework/src/pages/admin/DeliveriesPage.tsx +49 -17
- package/.framework/src/pages/admin/DesignedPage.tsx +31 -30
- package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +58 -0
- package/.framework/src/pages/admin/EmbeddingsPage.tsx +49 -0
- package/.framework/src/pages/admin/ExtendedShadcnTestPage.tsx +31 -29
- package/.framework/src/pages/admin/IncrementalShadcnTestPage.tsx +46 -29
- package/.framework/src/pages/admin/IntegratedDashboard.tsx +32 -29
- package/.framework/src/pages/admin/IntegrationDetailPage.tsx +68 -55
- package/.framework/src/pages/admin/IntegrationsPage.tsx +45 -42
- package/.framework/src/pages/admin/InvitesPage.tsx +60 -44
- package/.framework/src/pages/admin/LogsPage.tsx +87 -137
- package/.framework/src/pages/admin/MinimalShadcnTestPage.tsx +31 -29
- package/.framework/src/pages/admin/ObservabilityDashboard.tsx +150 -89
- package/.framework/src/pages/admin/PipelineDetailPage.tsx +77 -104
- package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +44 -43
- package/.framework/src/pages/admin/PipelinesPage.tsx +59 -145
- package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +53 -42
- package/.framework/src/pages/admin/PromptConfigsPage.tsx +47 -117
- package/.framework/src/pages/admin/ProperlyDesignedPage.tsx +31 -44
- package/.framework/src/pages/admin/RoleDetailPage.tsx +49 -92
- package/.framework/src/pages/admin/RolesPage.tsx +47 -164
- package/.framework/src/pages/admin/SecurityPage.tsx +310 -0
- package/.framework/src/pages/admin/SelectTestPage.tsx +30 -32
- package/.framework/src/pages/admin/ShadcnTestPage.tsx +37 -44
- package/.framework/src/pages/admin/SimpleDashboard.tsx +32 -44
- package/.framework/src/pages/admin/TestRunDetailPage.tsx +97 -44
- package/.framework/src/pages/admin/TestingDashboard.tsx +126 -71
- package/.framework/src/pages/admin/TimerDetailPage.tsx +67 -55
- package/.framework/src/pages/admin/TimersPage.tsx +44 -42
- package/.framework/src/pages/admin/TriggerDetailPage.tsx +47 -42
- package/.framework/src/pages/admin/TriggersPage.tsx +58 -57
- package/.framework/src/pages/admin/TypeDetailPage.tsx +91 -55
- package/.framework/src/pages/admin/TypesPage.tsx +45 -42
- package/.framework/src/pages/admin/cil/CILAgentPage.tsx +258 -128
- package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +127 -123
- package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +308 -235
- package/.framework/src/pages/admin/cil/CILFileListPage.tsx +159 -110
- package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +432 -0
- package/.framework/src/pages/admin/cil/CILGraphPage.tsx +200 -178
- package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +286 -239
- package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +211 -90
- package/.framework/src/pages/admin/cil/CILSearchPage.tsx +156 -143
- package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +379 -0
- package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +337 -0
- package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +219 -214
- package/.framework/src/pages/admin/kb/KBAdminPage.tsx +64 -0
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +135 -65
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +39 -31
- package/.framework/src/pages/auth/LoginPage.tsx +38 -32
- package/.framework/src/pages/auth/PendingApprovalPage.tsx +31 -28
- package/.framework/src/pages/auth/RegisterPage.tsx +429 -155
- package/.framework/src/pages/spine-framework/APIPage.tsx +33 -30
- package/.framework/src/pages/spine-framework/CLIPage.tsx +32 -30
- package/.framework/src/types/auth.ts +101 -93
- package/.framework/src/types/types.ts +283 -262
- package/.framework/templates/project.json +87 -0
- package/.framework/templates/spine.config.neutral.json +7 -0
- package/.framework/tests/api/api-surface.test.ts +381 -0
- package/.framework/tests/fixtures/seed.ts +427 -0
- package/.framework/tests/fixtures/teardown.ts +228 -0
- package/.framework/tests/reporter.ts +351 -0
- package/.framework/tests/setup.ts +135 -0
- package/.framework/tests/ui/ui-sweep.ts +278 -0
- package/.framework/tests/unit/admin-data-logger.test.ts +310 -0
- package/.framework/tests/unit/agent-runner.test.ts +2389 -0
- package/.framework/tests/unit/ai-interactions.test.ts +81 -0
- package/.framework/tests/unit/app-manifest.test.ts +359 -0
- package/.framework/tests/unit/assembly-scripts.test.ts +172 -0
- package/.framework/tests/unit/cil-determinism.test.ts +85 -0
- package/.framework/tests/unit/core-isolation.test.ts +153 -0
- package/.framework/tests/unit/funnel-signals.test.ts +135 -0
- package/.framework/tests/unit/install-app.test.ts +1759 -0
- package/.framework/tests/unit/invites-security.test.ts +572 -0
- package/.framework/tests/unit/kb-ingest-lease.test.ts +287 -0
- package/.framework/tests/unit/kb-search.test.ts +119 -0
- package/.framework/tests/unit/migrate.test.ts +509 -0
- package/.framework/tests/unit/notify.test.ts +136 -0
- package/.framework/tests/unit/permissions.test.ts +269 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +755 -0
- package/.framework/tests/unit/principal-cache.test.ts +138 -0
- package/.framework/tests/unit/retrieval-golden.test.ts +520 -0
- package/.framework/tests/unit/runtime-isolation.test.ts +642 -0
- package/.framework/tests/unit/schema-utils.test.ts +225 -0
- package/.framework/tests/unit/trigger-engine.test.ts +513 -0
- package/.framework/tests/unit/validate-app.test.ts +527 -0
- package/README.md +34 -3
- package/bin/spine-framework.cjs +1 -0
- package/config/tailwind.config.cjs +7 -0
- package/config/tsconfig.functions.json +42 -0
- package/config/tsconfig.json +37 -9
- package/config/vite.config.ts +31 -2
- package/netlify.toml +31 -3
- package/package.json +46 -29
- package/scripts/assemble-frontend.sh +127 -44
- package/scripts/assemble-functions.sh +127 -29
- package/scripts/boundary-check.sh +6 -2
- package/scripts/build-manifest.sh +24 -68
- package/scripts/checksum-manifest.mts +345 -0
- package/scripts/dev-clean.sh +86 -0
- package/scripts/dev-netlify.sh +49 -0
- package/scripts/netlify-dev-wrapper.sh +2 -0
- package/scripts/verify-install.ts +358 -0
- package/scripts/verify-integrity.sh +21 -57
- package/scripts/verify-manifest.mts +305 -0
- package/templates/project/gitignore +45 -0
- package/{config → templates/project}/package-lock.json +22335 -9366
- package/vitest.config.ts +46 -0
- package/.framework/public/spine.config.json +0 -91
- package/config/deno.lock +0 -108
- package/config/tsconfig.node.tsbuildinfo +0 -1
- package/config/tsconfig.tsbuildinfo +0 -1
- package/config/vite.config.d.ts +0 -2
- package/custom/.devin/AGENT.md +0 -357
- package/custom/.devin/skills/install-app/SKILL.md +0 -39
- package/custom/.devin/skills/spine-workbench-discovery/SKILL.md +0 -39
- package/custom/.devin/skills/spine-workbench-idea/SKILL.md +0 -36
- package/custom/.devin/skills/spine-workbench-implement/SKILL.md +0 -46
- package/custom/.devin/skills/spine-workbench-initiative/SKILL.md +0 -28
- package/custom/.devin/skills/spine-workbench-plan/SKILL.md +0 -38
- package/custom/.devin/workflows/app-install.yaml +0 -83
- package/custom/README.md +0 -79
- package/docs/README.md +0 -15
- package/scripts/app-install-cli.ts +0 -286
- package/scripts/check-core-integrity.sh +0 -82
- package/scripts/ingest-chunks.cjs +0 -202
- package/scripts/kb-chunk-parser.cjs +0 -312
- package/scripts/kb-chunk-parser.ts +0 -330
- package/scripts/kb-ingest-docs.ts +0 -203
- package/scripts/load-test-app-install.ts +0 -484
- package/scripts/test-login.js +0 -33
- package/spine.config.json +0 -91
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="none"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Unit tests for the registration/invitation server-side hardening batch (migration 052 + invites.ts + principal.ts)"]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Verify principal-bound registration, read-only token preview, account-scoped admin checks, server-side role allowlists, base_url origin allowlist, and invite-stub binding resistance — all with a mocked adminDb."]
|
|
4
|
+
// CIL[DOC-CONTEXT module="tests/unit/invites-security" layer="test-unit" related_files=".framework/functions/invites.ts,.framework/functions/_shared/principal.ts,.framework/migrations/052_registration_security.sql"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="vitest assertions" errors="none" side_effects="Mocks db/audit/rate-limit/security/geo/admin-data/integration-outbound modules"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Handlers wrapped by createHandler/createPublicHandler are invoked through the requestId+principal ctx shortcut; adminDb.from returns per-call queued chainable builders; adminDb.rpc is mocked directly." notable_decisions="RPC-internal logic (052 SQL) is exercised via mocked rpc results — these tests cover the endpoint contract, not plpgsql"]
|
|
7
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.85" flags="none"]
|
|
8
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
// CIL[DOC-AUDIENCE audience="test-engineers"]
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
|
|
12
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
13
|
+
adminDb: {
|
|
14
|
+
from: vi.fn(),
|
|
15
|
+
rpc: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
getUserDb: vi.fn(() => ({})),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
21
|
+
emitAudit: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
emitLog: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
emitSpan: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
emitActivity: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.mock('../../functions/_shared/rate-limit.ts', () => ({
|
|
28
|
+
checkRateLimit: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
RateLimitError: class RateLimitError extends Error {},
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
vi.mock('../../functions/_shared/security.ts', () => ({
|
|
33
|
+
checkRegistrationBlocklist: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
recordRegistrationGeo: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
isBlockedRequest: vi.fn().mockResolvedValue(null),
|
|
36
|
+
isBlockedIp: vi.fn().mockResolvedValue(null),
|
|
37
|
+
touchPersonGeo: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
vi.mock('../../functions/_shared/geo.ts', () => ({
|
|
41
|
+
getClientGeo: vi.fn(() => ({ ip: undefined, country: undefined })),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
vi.mock('../../functions/admin-data.ts', () => ({
|
|
45
|
+
create: vi.fn(),
|
|
46
|
+
list: vi.fn(),
|
|
47
|
+
update: vi.fn(),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
vi.mock('../../functions/integration-outbound.ts', () => ({
|
|
51
|
+
execute: vi.fn().mockResolvedValue({ success: true }),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
import { adminDb } from '../../functions/_shared/db.ts'
|
|
55
|
+
import {
|
|
56
|
+
completeRegistration,
|
|
57
|
+
validateToken,
|
|
58
|
+
createInvite,
|
|
59
|
+
handler,
|
|
60
|
+
} from '../../functions/invites.ts'
|
|
61
|
+
import { resolveInternalPersonId } from '../../functions/_shared/principal.ts'
|
|
62
|
+
import { update as adminDataUpdate, list as adminDataList } from '../../functions/admin-data.ts'
|
|
63
|
+
// CIL[DOC-VECTOR type="context" content="purpose='Unit tests for the registration/invitation server-side hardening batch'; No verified external callers; imports: .framework/functions/invites.ts,.framework/functions/_shared/principal.ts" status="ready"]
|
|
64
|
+
// CIL[DOC-VECTOR type="blast_radius" content="Test file — no production callers; guards the migration-052 endpoint contract" status="ready"]
|
|
65
|
+
// CIL[DOC-VECTOR type="configuration" content="Reads APP_BASE_URL and ALLOWED_ORIGINS env vars via setEnv/afterEach restore" status="ready"]
|
|
66
|
+
// CIL[DOC-VECTOR type="constraint" content="All DB access is mocked — no real Supabase calls; RPC internals (052 SQL) are out of scope" status="ready"]
|
|
67
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:module_header:module_header"]
|
|
68
|
+
|
|
69
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:q:function" type="function" identity="q" parent="none"]
|
|
70
|
+
// CIL[DOC-MACRO purpose="Build a chainable adminDb query mock resolving to a fixed result"]
|
|
71
|
+
// CIL[DOC-CONTRACT inputs="result: {data?, error?} — resolved value" outputs="chainable query-builder stub" errors="none" side_effects="none"]
|
|
72
|
+
// CIL[DOC-MICRO logic_summary="Every filter/terminal method returns the builder except single/maybeSingle/await which resolve to the configured {data,error} result"]
|
|
73
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
74
|
+
function q(result: { data?: any; error?: any }) {
|
|
75
|
+
const b: any = {}
|
|
76
|
+
const chainers = ['select', 'eq', 'in', 'is', 'ilike', 'filter', 'not', 'order',
|
|
77
|
+
'limit', 'update', 'insert', 'upsert', 'delete', 'neq', 'or', 'contains', 'gte', 'lte']
|
|
78
|
+
for (const m of chainers) b[m] = vi.fn(() => b)
|
|
79
|
+
b.single = vi.fn(async () => result)
|
|
80
|
+
b.maybeSingle = vi.fn(async () => result)
|
|
81
|
+
b.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR)
|
|
82
|
+
return b
|
|
83
|
+
}
|
|
84
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:q:function"]
|
|
85
|
+
|
|
86
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:db_queue:variable" type="variable" identity="db_queue" parent="none"]
|
|
87
|
+
// CIL[DOC-MACRO purpose="Per-test queue of query builders consumed by adminDb.from"]
|
|
88
|
+
// CIL[DOC-CONTRACT inputs="key: env var name for setEnv, value: env var value or undefined to unset for setEnv, setEnv: env-restore helper, queue entries pushed per test" outputs="mocked adminDb.from/adminDb.rpc behavior" errors="none" side_effects="resets mocks per test; restores APP_BASE_URL/ALLOWED_ORIGINS after each"]
|
|
89
|
+
// CIL[DOC-MICRO logic_summary="Each adminDb.from call shifts the next configured builder; created builders are tracked so tests can assert zero writes"]
|
|
90
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
91
|
+
let queue: any[] = []
|
|
92
|
+
let built: any[] = []
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
queue = []
|
|
96
|
+
built = []
|
|
97
|
+
vi.clearAllMocks()
|
|
98
|
+
vi.mocked(adminDb.from).mockImplementation(() => {
|
|
99
|
+
const b = queue.length ? queue.shift() : q({ data: null, error: null })
|
|
100
|
+
built.push(b)
|
|
101
|
+
return b
|
|
102
|
+
})
|
|
103
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({ data: null, error: null } as any)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const savedEnv: Record<string, string | undefined> = {}
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
for (const k of ['APP_BASE_URL', 'ALLOWED_ORIGINS']) {
|
|
109
|
+
if (savedEnv[k] === undefined) delete process.env[k]
|
|
110
|
+
else process.env[k] = savedEnv[k]
|
|
111
|
+
delete savedEnv[k]
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
function setEnv(key: string, value: string | undefined) {
|
|
116
|
+
if (!(key in savedEnv)) savedEnv[key] = process.env[key]
|
|
117
|
+
if (value === undefined) delete process.env[key]
|
|
118
|
+
else process.env[key] = value
|
|
119
|
+
}
|
|
120
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:db_queue:variable"]
|
|
121
|
+
|
|
122
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:makeCtx:function" type="function" identity="makeCtx" parent="none"]
|
|
123
|
+
// CIL[DOC-MACRO purpose="Create a RequestContext-shaped object that satisfies the createHandler/createPublicHandler ctx shortcut"]
|
|
124
|
+
// CIL[DOC-CONTRACT inputs="overrides — partial ctx/principal" outputs="ctx object" errors="none" side_effects="none"]
|
|
125
|
+
// CIL[DOC-MICRO logic_summary="requestId + principal present means the middleware wrapper delegates straight to the inner handler with the second arg as body"]
|
|
126
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
127
|
+
function makeCtx(overrides: any = {}) {
|
|
128
|
+
const principal = {
|
|
129
|
+
id: 'person-1',
|
|
130
|
+
type: 'human',
|
|
131
|
+
accountId: 'acct-1',
|
|
132
|
+
roles: ['member'],
|
|
133
|
+
email: 'user@corp.com',
|
|
134
|
+
provenance: { sourceType: 'jwt', createdBy: 'person-1', invokedAt: new Date().toISOString() },
|
|
135
|
+
...(overrides.principal || {}),
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
requestId: 'req-test',
|
|
139
|
+
accountId: principal.accountId,
|
|
140
|
+
appId: null,
|
|
141
|
+
query: {},
|
|
142
|
+
headers: {},
|
|
143
|
+
...overrides,
|
|
144
|
+
principal,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:makeCtx:function"]
|
|
148
|
+
|
|
149
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:expectNoWrites:function" type="function" identity="expectNoWrites" parent="none"]
|
|
150
|
+
// CIL[DOC-MACRO purpose="Assert no query builder issued a write operation"]
|
|
151
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="vitest assertions" errors="throws on any update/insert/delete mock call" side_effects="none"]
|
|
152
|
+
// CIL[DOC-MICRO logic_summary="Iterates every builder handed out by adminDb.from and asserts update/insert/delete were never invoked"]
|
|
153
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
154
|
+
function expectNoWrites() {
|
|
155
|
+
for (const b of built) {
|
|
156
|
+
expect(b.update?.mock?.calls?.length ?? 0, 'update() calls').toBe(0)
|
|
157
|
+
expect(b.insert?.mock?.calls?.length ?? 0, 'insert() calls').toBe(0)
|
|
158
|
+
expect(b.delete?.mock?.calls?.length ?? 0, 'delete() calls').toBe(0)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:expectNoWrites:function"]
|
|
162
|
+
|
|
163
|
+
// ─── completeRegistration ────────────────────────────────────────────────────
|
|
164
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_completeRegistration:block" type="block" identity="describe_completeRegistration" parent="none"]
|
|
165
|
+
// CIL[DOC-MACRO purpose="completeRegistration binds by principal.id and delegates placement to the RPC"]
|
|
166
|
+
// CIL[DOC-CONTRACT inputs="vitest cases" outputs="assertions" errors="none" side_effects="none"]
|
|
167
|
+
// CIL[DOC-MICRO logic_summary="Covers principal-bound placement, required app/path, RPC error→400 mapping, idempotent retry, and `next` passthrough"]
|
|
168
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
169
|
+
describe('completeRegistration', () => {
|
|
170
|
+
it('binds placement to ctx.principal.id, never the body email', async () => {
|
|
171
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({
|
|
172
|
+
data: { status: 'active', account_slug: 'acme-a1b2c3', role_slug: 'member' },
|
|
173
|
+
error: null,
|
|
174
|
+
} as any)
|
|
175
|
+
|
|
176
|
+
const res = await completeRegistration(makeCtx() as any, {
|
|
177
|
+
app: 'portal',
|
|
178
|
+
path: 'new_account',
|
|
179
|
+
account_name: 'Acme Co',
|
|
180
|
+
email: 'victim@other.example', // spoofed — must not drive placement
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(adminDb.rpc).toHaveBeenCalledWith('complete_registration_placement', {
|
|
184
|
+
p_person_id: 'person-1',
|
|
185
|
+
p_app_slug: 'portal',
|
|
186
|
+
p_path: 'new_account',
|
|
187
|
+
p_account_name: 'Acme Co',
|
|
188
|
+
})
|
|
189
|
+
expect(res).toMatchObject({
|
|
190
|
+
status: 'active',
|
|
191
|
+
account_slug: 'acme-a1b2c3',
|
|
192
|
+
role_slug: 'member',
|
|
193
|
+
})
|
|
194
|
+
// The endpoint performs no direct people/accounts writes.
|
|
195
|
+
expect(adminDb.from).not.toHaveBeenCalled()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('rejects when app or path is missing', async () => {
|
|
199
|
+
await expect(completeRegistration(makeCtx() as any, { path: 'new_account' }))
|
|
200
|
+
.rejects.toThrow('app and path are required')
|
|
201
|
+
await expect(completeRegistration(makeCtx() as any, { app: 'portal' }))
|
|
202
|
+
.rejects.toThrow('app and path are required')
|
|
203
|
+
expect(adminDb.rpc).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('maps RPC errors (unknown app, disabled registration, join_request policy) to a 400', async () => {
|
|
207
|
+
const cases = [
|
|
208
|
+
"Unknown or uninstalled app 'ghost' or no registration policy configured",
|
|
209
|
+
"Registration is not enabled for app 'portal'",
|
|
210
|
+
"App 'portal' does not allow domain-based join requests",
|
|
211
|
+
'No account found for this email domain',
|
|
212
|
+
]
|
|
213
|
+
for (const message of cases) {
|
|
214
|
+
vi.mocked(adminDb.rpc).mockResolvedValueOnce({ data: null, error: { message } } as any)
|
|
215
|
+
const err = await completeRegistration(makeCtx() as any, {
|
|
216
|
+
app: 'portal', path: 'join_request',
|
|
217
|
+
}).then(() => null).catch((e: any) => e)
|
|
218
|
+
expect(err).toBeTruthy()
|
|
219
|
+
expect(err.statusCode).toBe(400)
|
|
220
|
+
expect(err.message).toBe(message)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('returns the existing placement unchanged on idempotent retry', async () => {
|
|
225
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({
|
|
226
|
+
data: { status: 'active', account_slug: 'acme-a1b2c3', role_slug: 'member_admin' },
|
|
227
|
+
error: null,
|
|
228
|
+
} as any)
|
|
229
|
+
const res = await completeRegistration(makeCtx() as any, {
|
|
230
|
+
app: 'portal', path: 'new_account', account_name: 'Acme Co',
|
|
231
|
+
})
|
|
232
|
+
expect(res).toMatchObject({ status: 'active', account_slug: 'acme-a1b2c3', role_slug: 'member_admin' })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('passes through a `next` field when the RPC supplies one', async () => {
|
|
236
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({
|
|
237
|
+
data: { status: 'pending', account_slug: 'corp-zz99', role_slug: 'member', next: 'pending_approval' },
|
|
238
|
+
error: null,
|
|
239
|
+
} as any)
|
|
240
|
+
const res = await completeRegistration(makeCtx() as any, {
|
|
241
|
+
app: 'portal', path: 'join_request',
|
|
242
|
+
})
|
|
243
|
+
expect(res).toMatchObject({ status: 'pending', next: 'pending_approval' })
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_completeRegistration:block"]
|
|
247
|
+
|
|
248
|
+
// ─── validateToken ───────────────────────────────────────────────────────────
|
|
249
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_validateToken:block" type="block" identity="describe_validateToken" parent="none"]
|
|
250
|
+
// CIL[DOC-MACRO purpose="validateToken is read-only: zero writes on preview, expired, blocked, and legacy attempt-cap paths"]
|
|
251
|
+
// CIL[DOC-CONTRACT inputs="data: invite metadata merged onto a stub person fixture (invitePerson); vitest cases" outputs="assertions" errors="none" side_effects="none"]
|
|
252
|
+
// CIL[DOC-MICRO logic_summary="Success/blocked/expired/legacy-attempt-cap previews all assert expectNoWrites"]
|
|
253
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
254
|
+
describe('validateToken', () => {
|
|
255
|
+
function invitePerson(data: any) {
|
|
256
|
+
return {
|
|
257
|
+
id: 'p-inv', email: 'inv@corp.com', account_id: 'acct-1', role_id: 'r1',
|
|
258
|
+
status: 'invited', data,
|
|
259
|
+
account: { display_name: 'Corp' }, role: { name: 'Member' },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it('performs zero writes on a successful preview', async () => {
|
|
264
|
+
queue.push(q({
|
|
265
|
+
data: invitePerson({ invite_token: 'tok', invite_kind: 'invite', invite_attempts: 0 }),
|
|
266
|
+
}))
|
|
267
|
+
const ctx: any = makeCtx()
|
|
268
|
+
ctx.query = { token: 'tok' }
|
|
269
|
+
const res = await validateToken(ctx, undefined)
|
|
270
|
+
expect(res).toMatchObject({ email: 'inv@corp.com', account_name: 'Corp', role_name: 'Member' })
|
|
271
|
+
expectNoWrites()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('rejects blocked and expired invites without writing', async () => {
|
|
275
|
+
queue.push(q({
|
|
276
|
+
data: invitePerson({ invite_token: 'tok', invite_blocked_at: new Date().toISOString() }),
|
|
277
|
+
}))
|
|
278
|
+
const ctx: any = makeCtx()
|
|
279
|
+
ctx.query = { token: 'tok' }
|
|
280
|
+
await expect(validateToken(ctx, undefined)).rejects.toThrow('blocked')
|
|
281
|
+
expectNoWrites()
|
|
282
|
+
|
|
283
|
+
queue.push(q({
|
|
284
|
+
data: invitePerson({ invite_token: 'tok', invite_expires_at: '2000-01-01T00:00:00Z' }),
|
|
285
|
+
}))
|
|
286
|
+
const ctx2: any = makeCtx()
|
|
287
|
+
ctx2.query = { token: 'tok' }
|
|
288
|
+
await expect(validateToken(ctx2, undefined)).rejects.toThrow('expired')
|
|
289
|
+
expectNoWrites()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('surfaces the blocked state for legacy rows at the attempts cap without writing', async () => {
|
|
293
|
+
queue.push(q({
|
|
294
|
+
data: invitePerson({ invite_token: 'tok', invite_attempts: 5 }),
|
|
295
|
+
}))
|
|
296
|
+
const ctx: any = makeCtx()
|
|
297
|
+
ctx.query = { token: 'tok' }
|
|
298
|
+
await expect(validateToken(ctx, undefined)).rejects.toThrow('Too many failed attempts')
|
|
299
|
+
expectNoWrites()
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_validateToken:block"]
|
|
303
|
+
|
|
304
|
+
// ─── Account-scoped admin checks ─────────────────────────────────────────────
|
|
305
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_accountScopedAdmin:block" type="block" identity="describe_accountScopedAdmin" parent="none"]
|
|
306
|
+
// CIL[DOC-MACRO purpose="All invite/member mutations require an admin of the TARGET account (or system_admin)"]
|
|
307
|
+
// CIL[DOC-CONTRACT inputs="vitest cases" outputs="assertions" errors="none" side_effects="none"]
|
|
308
|
+
// CIL[DOC-MICRO logic_summary="Matrix: 7 mutations × (cross-account admin rejects / same-account admin accepts / system_admin accepts / member rejects) plus listMembers scope, reinvite status gate, and updateMember role guards"]
|
|
309
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
310
|
+
describe('account-scoped admin checks', () => {
|
|
311
|
+
const otherAccountPerson = { id: 'p2', email: 't@corp.com', account_id: 'acct-2', status: 'invited', data: {} }
|
|
312
|
+
|
|
313
|
+
const mutationCases: Array<{ action: string; person: any }> = [
|
|
314
|
+
{ action: 'revoke-invite', person: { ...otherAccountPerson, status: 'invited' } },
|
|
315
|
+
{ action: 'reinvite', person: { ...otherAccountPerson, status: 'invited' } },
|
|
316
|
+
{ action: 'approve', person: { ...otherAccountPerson, status: 'pending' } },
|
|
317
|
+
{ action: 'block-invite', person: { ...otherAccountPerson } },
|
|
318
|
+
{ action: 'deny', person: { ...otherAccountPerson, status: 'pending' } },
|
|
319
|
+
{ action: 'update-member', person: { ...otherAccountPerson, status: 'active' } },
|
|
320
|
+
{ action: 'deactivate-member', person: { ...otherAccountPerson, status: 'active' } },
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
for (const { action, person } of mutationCases) {
|
|
324
|
+
it(`${action}: rejects an *_admin of a different account`, async () => {
|
|
325
|
+
queue.push(q({ data: person })) // target person lookup
|
|
326
|
+
const ctx: any = makeCtx({
|
|
327
|
+
principal: { accountId: 'acct-1', roles: ['member_admin'] },
|
|
328
|
+
query: { action },
|
|
329
|
+
})
|
|
330
|
+
await expect(handler(ctx, { person_id: 'p2', full_name: 'X' }))
|
|
331
|
+
.rejects.toThrow('Insufficient permissions')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it(`${action}: accepts an *_admin of the target account`, async () => {
|
|
335
|
+
queue.push(q({ data: person })) // target person lookup
|
|
336
|
+
queue.push(q({ data: null, error: null })) // update result / allowlist read
|
|
337
|
+
queue.push(q({ data: [{ id: 'r-member', slug: 'member', is_system: false }] }))
|
|
338
|
+
vi.mocked(adminDataUpdate).mockResolvedValue({ id: 'p2' } as any)
|
|
339
|
+
const ctx: any = makeCtx({
|
|
340
|
+
principal: { accountId: 'acct-2', roles: ['member_admin'] },
|
|
341
|
+
query: { action },
|
|
342
|
+
})
|
|
343
|
+
const res = await handler(ctx, { person_id: 'p2', full_name: 'X' })
|
|
344
|
+
expect(res).toBeTruthy()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it(`${action}: accepts system_admin regardless of account`, async () => {
|
|
348
|
+
queue.push(q({ data: person }))
|
|
349
|
+
queue.push(q({ data: null, error: null }))
|
|
350
|
+
queue.push(q({ data: [{ id: 'r-member', slug: 'member', is_system: false }] }))
|
|
351
|
+
vi.mocked(adminDataUpdate).mockResolvedValue({ id: 'p2' } as any)
|
|
352
|
+
const ctx: any = makeCtx({
|
|
353
|
+
principal: { accountId: 'acct-9', roles: ['system_admin'], permissions: ['system'] },
|
|
354
|
+
query: { action },
|
|
355
|
+
})
|
|
356
|
+
const res = await handler(ctx, { person_id: 'p2', full_name: 'X' })
|
|
357
|
+
expect(res).toBeTruthy()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it(`${action}: rejects a plain member`, async () => {
|
|
361
|
+
queue.push(q({ data: person }))
|
|
362
|
+
const ctx: any = makeCtx({
|
|
363
|
+
principal: { accountId: 'acct-2', roles: ['member'] },
|
|
364
|
+
query: { action },
|
|
365
|
+
})
|
|
366
|
+
await expect(handler(ctx, { person_id: 'p2' }))
|
|
367
|
+
.rejects.toThrow('Insufficient permissions')
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
it('list-members: rejects an *_admin of a different account', async () => {
|
|
372
|
+
const ctx: any = makeCtx({
|
|
373
|
+
principal: { accountId: 'acct-1', roles: ['member_admin'] },
|
|
374
|
+
query: { action: 'list-members', account_id: 'acct-2' },
|
|
375
|
+
})
|
|
376
|
+
await expect(handler(ctx, {})).rejects.toThrow('Insufficient permissions')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('list-members: accepts an *_admin of the queried account', async () => {
|
|
380
|
+
queue.push(q({ data: [{ id: 'r-member', slug: 'member', name: 'Member' }] }))
|
|
381
|
+
vi.mocked(adminDataList).mockResolvedValue([{ id: 'p2', role_id: 'r-member' }] as any)
|
|
382
|
+
const ctx: any = makeCtx({
|
|
383
|
+
principal: { accountId: 'acct-2', roles: ['member_admin'] },
|
|
384
|
+
query: { action: 'list-members', account_id: 'acct-2' },
|
|
385
|
+
})
|
|
386
|
+
const res = await handler(ctx, {})
|
|
387
|
+
expect(res.data?.[0]?.role).toMatchObject({ slug: 'member' })
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('reinvite: rejects when the target person is active', async () => {
|
|
391
|
+
queue.push(q({ data: { ...otherAccountPerson, status: 'active' } }))
|
|
392
|
+
const ctx: any = makeCtx({
|
|
393
|
+
principal: { accountId: 'acct-2', roles: ['member_admin'] },
|
|
394
|
+
query: { action: 'reinvite' },
|
|
395
|
+
})
|
|
396
|
+
await expect(handler(ctx, { person_id: 'p2' }))
|
|
397
|
+
.rejects.toThrow('reinvitable')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('update-member: rejects a system_admin role_slug', async () => {
|
|
401
|
+
queue.push(q({ data: { ...otherAccountPerson, status: 'active' } })) // target lookup
|
|
402
|
+
queue.push(q({ data: [] })) // roleAllowlistForAccount
|
|
403
|
+
const ctx: any = makeCtx({
|
|
404
|
+
principal: { accountId: 'acct-2', roles: ['member_admin'] },
|
|
405
|
+
query: { action: 'update-member' },
|
|
406
|
+
})
|
|
407
|
+
await expect(handler(ctx, { person_id: 'p2', role_slug: 'system_admin' }))
|
|
408
|
+
.rejects.toThrow('not permitted')
|
|
409
|
+
expect(vi.mocked(adminDataUpdate)).not.toHaveBeenCalled()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('update-member: rejects an is_system role_slug', async () => {
|
|
413
|
+
queue.push(q({ data: { ...otherAccountPerson, status: 'active' } }))
|
|
414
|
+
queue.push(q({ data: [] })) // roleAllowlistForAccount → no declared allowlist
|
|
415
|
+
queue.push(q({ data: [{ id: 'r-eng', slug: 'engineering', is_system: true }] }))
|
|
416
|
+
const ctx: any = makeCtx({
|
|
417
|
+
principal: { accountId: 'acct-2', roles: ['member_admin'] },
|
|
418
|
+
query: { action: 'update-member' },
|
|
419
|
+
})
|
|
420
|
+
await expect(handler(ctx, { person_id: 'p2', role_slug: 'engineering' }))
|
|
421
|
+
.rejects.toThrow('not permitted')
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_accountScopedAdmin:block"]
|
|
425
|
+
|
|
426
|
+
// ─── createInvite ────────────────────────────────────────────────────────────
|
|
427
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_createInvite:block" type="block" identity="describe_createInvite" parent="none"]
|
|
428
|
+
// CIL[DOC-MACRO purpose="createInvite enforces account admin scope, server role allowlists, and base_url origin allowlist"]
|
|
429
|
+
// CIL[DOC-CONTRACT inputs="opts: optional {appRow} queued for the apps allowlist read (queueHappyPath); vitest cases" outputs="assertions" errors="none" side_effects="none"]
|
|
430
|
+
// CIL[DOC-MICRO logic_summary="Covers system_admin/is_system role rejection, app allowlist enforcement, and resolveInviteBaseUrl origin handling incl. yourapp.com absence"]
|
|
431
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
432
|
+
describe('createInvite', () => {
|
|
433
|
+
const baseBody = { email: 'new@corp.com', account_id: 'acct-1', role_slug: 'member' }
|
|
434
|
+
const adminCtx = () => makeCtx({ principal: { accountId: 'acct-1', roles: ['member_admin'] } })
|
|
435
|
+
|
|
436
|
+
// Queue order mirrors from() call order in createInvite: optional apps
|
|
437
|
+
// allowlist read (only when body.app/ctx.appId is set) → roles → existing
|
|
438
|
+
// person → person type → integrations.
|
|
439
|
+
function queueHappyPath(opts: { appRow?: any } = {}) {
|
|
440
|
+
if ('appRow' in opts) queue.push(q({ data: opts.appRow }))
|
|
441
|
+
queue.push(q({ data: [{ id: 'r-member', slug: 'member', is_system: false }] })) // roles
|
|
442
|
+
queue.push(q({ data: null })) // existing person
|
|
443
|
+
queue.push(q({ data: { id: 't-person' } })) // person type
|
|
444
|
+
queue.push(q({ data: null })) // integrations
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
it('rejects callers that are not admins of the target account', async () => {
|
|
448
|
+
await expect(createInvite(makeCtx() as any, baseBody))
|
|
449
|
+
.rejects.toThrow('Insufficient permissions')
|
|
450
|
+
const foreignAdmin = makeCtx({ principal: { accountId: 'acct-9', roles: ['member_admin'] } })
|
|
451
|
+
await expect(createInvite(foreignAdmin as any, baseBody))
|
|
452
|
+
.rejects.toThrow('Insufficient permissions')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('rejects a forged system_admin role_slug', async () => {
|
|
456
|
+
await expect(createInvite(adminCtx() as any, { ...baseBody, role_slug: 'system_admin' }))
|
|
457
|
+
.rejects.toThrow('not permitted')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('rejects an is_system role even when the slug exists', async () => {
|
|
461
|
+
queue.push(q({ data: [{ id: 'r-eng', slug: 'engineering', is_system: true }] }))
|
|
462
|
+
await expect(createInvite(adminCtx() as any, { ...baseBody, role_slug: 'engineering' }))
|
|
463
|
+
.rejects.toThrow('not permitted')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('rejects roles outside the app allowlist', async () => {
|
|
467
|
+
queue.push(q({ data: { config: { invitation: { allowed_roles: ['member'] } } } }))
|
|
468
|
+
await expect(createInvite(adminCtx() as any, { ...baseBody, app: 'portal', role_slug: 'member_admin' }))
|
|
469
|
+
.rejects.toThrow('not permitted')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('creates an invite and builds the link on an allowlisted origin', async () => {
|
|
473
|
+
setEnv('APP_BASE_URL', 'https://app.example.com')
|
|
474
|
+
queueHappyPath({ appRow: { config: { registration: { roles: ['member'] } } } })
|
|
475
|
+
const { create: adminDataCreate } = await import('../../functions/admin-data.ts')
|
|
476
|
+
vi.mocked(adminDataCreate).mockResolvedValue({ id: 'stub-1' } as any)
|
|
477
|
+
|
|
478
|
+
const res = await createInvite(adminCtx() as any, {
|
|
479
|
+
...baseBody, app: 'portal', base_url: 'https://app.example.com/some/path',
|
|
480
|
+
})
|
|
481
|
+
expect(res.invite_url).toMatch(/^https:\/\/app\.example\.com\/register\?t=/)
|
|
482
|
+
expect(res.invite_url).not.toContain('yourapp.com')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('allows localhost base_url for dev', async () => {
|
|
486
|
+
queueHappyPath()
|
|
487
|
+
const { create: adminDataCreate } = await import('../../functions/admin-data.ts')
|
|
488
|
+
vi.mocked(adminDataCreate).mockResolvedValue({ id: 'stub-1' } as any)
|
|
489
|
+
const res = await createInvite(adminCtx() as any, {
|
|
490
|
+
...baseBody, base_url: 'http://localhost:3000',
|
|
491
|
+
})
|
|
492
|
+
expect(res.invite_url).toMatch(/^http:\/\/localhost:3000\/register\?t=/)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('falls back to APP_BASE_URL for disallowed origins (never yourapp.com)', async () => {
|
|
496
|
+
setEnv('APP_BASE_URL', 'https://app.example.com')
|
|
497
|
+
queueHappyPath()
|
|
498
|
+
const { create: adminDataCreate } = await import('../../functions/admin-data.ts')
|
|
499
|
+
vi.mocked(adminDataCreate).mockResolvedValue({ id: 'stub-1' } as any)
|
|
500
|
+
const res = await createInvite(adminCtx() as any, {
|
|
501
|
+
...baseBody, base_url: 'https://phish.example.org',
|
|
502
|
+
})
|
|
503
|
+
expect(res.invite_url).toMatch(/^https:\/\/app\.example\.com\/register\?t=/)
|
|
504
|
+
expect(res.invite_url).not.toContain('yourapp.com')
|
|
505
|
+
expect(res.invite_url).not.toContain('phish.example.org')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('falls back to http://localhost:8888 when nothing is configured or allowed', async () => {
|
|
509
|
+
setEnv('APP_BASE_URL', undefined)
|
|
510
|
+
setEnv('ALLOWED_ORIGINS', undefined)
|
|
511
|
+
queueHappyPath()
|
|
512
|
+
const { create: adminDataCreate } = await import('../../functions/admin-data.ts')
|
|
513
|
+
vi.mocked(adminDataCreate).mockResolvedValue({ id: 'stub-1' } as any)
|
|
514
|
+
const res = await createInvite(adminCtx() as any, {
|
|
515
|
+
...baseBody, base_url: 'not-a-url',
|
|
516
|
+
})
|
|
517
|
+
expect(res.invite_url).toMatch(/^http:\/\/localhost:8888\/register\?t=/)
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_createInvite:block"]
|
|
521
|
+
|
|
522
|
+
// ─── resolveInternalPersonId ─────────────────────────────────────────────────
|
|
523
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_resolveInternalPersonId:block" type="block" identity="describe_resolveInternalPersonId" parent="none"]
|
|
524
|
+
// CIL[DOC-MACRO purpose="resolveInternalPersonId resolves auth_uid first and never binds invite stubs by email"]
|
|
525
|
+
// CIL[DOC-CONTRACT inputs="vitest cases" outputs="assertions" errors="none" side_effects="none"]
|
|
526
|
+
// CIL[DOC-MICRO logic_summary="auth_uid hit short-circuits; invited/pending/token-carrying stubs return authUserId unbound; non-stub unbound people still get auth_uid linked in a single write"]
|
|
527
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
528
|
+
describe('resolveInternalPersonId', () => {
|
|
529
|
+
it('resolves by auth_uid first without touching the email path', async () => {
|
|
530
|
+
queue.push(q({ data: { id: 'p-bound' } }))
|
|
531
|
+
const id = await resolveInternalPersonId('auth-1', 'someone@corp.com')
|
|
532
|
+
expect(id).toBe('p-bound')
|
|
533
|
+
expect(adminDb.from).toHaveBeenCalledTimes(1)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('does not bind an unclaimed invited stub by email', async () => {
|
|
537
|
+
queue.push(q({ data: null })) // auth_uid lookup → miss
|
|
538
|
+
queue.push(q({
|
|
539
|
+
data: { id: 'stub-1', auth_uid: null, status: 'invited', data: { invite_token: 'abc' } },
|
|
540
|
+
}))
|
|
541
|
+
const id = await resolveInternalPersonId('auth-1', 'inv@corp.com')
|
|
542
|
+
expect(id).toBe('auth-1')
|
|
543
|
+
expectNoWrites()
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('does not bind an unclaimed pending stub by email', async () => {
|
|
547
|
+
queue.push(q({ data: null }))
|
|
548
|
+
queue.push(q({ data: { id: 'stub-2', auth_uid: null, status: 'pending', data: {} } }))
|
|
549
|
+
const id = await resolveInternalPersonId('auth-1', 'req@corp.com')
|
|
550
|
+
expect(id).toBe('auth-1')
|
|
551
|
+
expectNoWrites()
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('still binds a non-stub unbound person by email', async () => {
|
|
555
|
+
queue.push(q({ data: null }))
|
|
556
|
+
queue.push(q({ data: { id: 'p3', auth_uid: null, status: 'active', data: null } }))
|
|
557
|
+
queue.push(q({ data: null, error: null })) // update
|
|
558
|
+
const id = await resolveInternalPersonId('auth-1', 'active@corp.com')
|
|
559
|
+
expect(id).toBe('p3')
|
|
560
|
+
const updateBuilder = built.find(b => b.update?.mock?.calls?.length)
|
|
561
|
+
expect(updateBuilder).toBeTruthy()
|
|
562
|
+
expect(updateBuilder.update).toHaveBeenCalledWith({ auth_uid: 'auth-1' })
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('falls back to the auth id when nothing matches', async () => {
|
|
566
|
+
queue.push(q({ data: null }))
|
|
567
|
+
queue.push(q({ data: null }))
|
|
568
|
+
const id = await resolveInternalPersonId('auth-1', 'ghost@corp.com')
|
|
569
|
+
expect(id).toBe('auth-1')
|
|
570
|
+
})
|
|
571
|
+
})
|
|
572
|
+
// CIL[END stable_id="~/.framework/tests/unit/invites-security.test.ts:describe_resolveInternalPersonId:block"]
|