spine-framework 0.1.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/.framework/README.md +129 -0
- package/.framework/cli/bin.cjs +14 -0
- package/.framework/cli/commands/agents.ts +153 -0
- package/.framework/cli/commands/auth.ts +94 -0
- package/.framework/cli/commands/create-app.ts +185 -0
- package/.framework/cli/commands/dev.ts +295 -0
- package/.framework/cli/commands/doctor.ts +442 -0
- package/.framework/cli/commands/generate.ts +332 -0
- package/.framework/cli/commands/init.ts +272 -0
- package/.framework/cli/commands/install-app.ts +391 -0
- package/.framework/cli/commands/items.ts +253 -0
- package/.framework/cli/commands/migrations.ts +141 -0
- package/.framework/cli/commands/pipelines.ts +166 -0
- package/.framework/cli/commands/status.ts +197 -0
- package/.framework/cli/commands/system.ts +184 -0
- package/.framework/cli/commands/test.ts +227 -0
- package/.framework/cli/commands/uninstall-app.ts +166 -0
- package/.framework/cli/context.ts +268 -0
- package/.framework/cli/env-loader.ts +36 -0
- package/.framework/cli/index.ts +106 -0
- package/.framework/cli/welcome.cjs +45 -0
- package/.framework/docs/API.md +384 -0
- package/.framework/docs/STABILITY.md +52 -0
- package/.framework/docs/admin-routes.md +76 -0
- package/.framework/docs/api-docs-progress.md +38 -0
- package/.framework/docs/api-governance.md +146 -0
- package/.framework/docs/api-testing-results.md +212 -0
- package/.framework/docs/apis/admin-configs.md +567 -0
- package/.framework/docs/apis/admin-data.md +272 -0
- package/.framework/docs/apis/index.md +231 -0
- package/.framework/docs/apis/internal.md +295 -0
- package/.framework/docs/apis/runtime.md +537 -0
- package/.framework/docs/assembly-launch-guide.md +138 -0
- package/.framework/docs/audit-results.md +590 -0
- package/.framework/docs/authorization-model.md +170 -0
- package/.framework/docs/db-api-inventory.md +95 -0
- package/.framework/docs/examples/custom-app/README.md +77 -0
- package/.framework/docs/examples/custom-function/README.md +27 -0
- package/.framework/docs/examples/custom-function/handler.ts +48 -0
- package/.framework/docs/examples/custom-webhook/README.md +68 -0
- package/.framework/docs/gap-remediation-backlog.md +103 -0
- package/.framework/docs/guides/cli-guide.md +224 -0
- package/.framework/docs/guides/getting-started.md +103 -0
- package/.framework/docs/guides/import-guide.md +193 -0
- package/.framework/docs/guides/testing-guide.md +229 -0
- package/.framework/docs/permission-examples.md +326 -0
- package/.framework/docs/ui-adoption-verification.md +111 -0
- package/.framework/docs/ui-api-coverage.md +84 -0
- package/.framework/docs/v2-compatibility-audit.md +228 -0
- package/.framework/functions/.gitkeep +1 -0
- package/.framework/functions/_shared/agent-runner.ts +1097 -0
- package/.framework/functions/_shared/app-manifest.ts +184 -0
- package/.framework/functions/_shared/audit.ts +150 -0
- package/.framework/functions/_shared/db.ts +174 -0
- package/.framework/functions/_shared/index.ts +382 -0
- package/.framework/functions/_shared/middleware.ts +490 -0
- package/.framework/functions/_shared/permissions.ts +1325 -0
- package/.framework/functions/_shared/pipeline-runner.ts +731 -0
- package/.framework/functions/_shared/principal.ts +760 -0
- package/.framework/functions/_shared/schema-utils.ts +967 -0
- package/.framework/functions/_shared/testing.ts +258 -0
- package/.framework/functions/_shared/trigger-engine.ts +425 -0
- package/.framework/functions/_shared/webhook-registration.ts +168 -0
- package/.framework/functions/_shared/webhook-registry.ts +129 -0
- package/.framework/functions/account-nodes.ts +111 -0
- package/.framework/functions/admin-data.ts +606 -0
- package/.framework/functions/ai-agents.ts +323 -0
- package/.framework/functions/api-keys.ts +376 -0
- package/.framework/functions/apps.ts +483 -0
- package/.framework/functions/auth.ts +196 -0
- package/.framework/functions/debug-auth.ts +107 -0
- package/.framework/functions/embeddings.ts +556 -0
- package/.framework/functions/integration-routes.ts +523 -0
- package/.framework/functions/integrations.ts +319 -0
- package/.framework/functions/item-progress.ts +272 -0
- package/.framework/functions/logs.ts +438 -0
- package/.framework/functions/observability.ts +275 -0
- package/.framework/functions/pipeline-executions.ts +494 -0
- package/.framework/functions/pipelines.ts +485 -0
- package/.framework/functions/prompt-configs.ts +339 -0
- package/.framework/functions/roles.ts +387 -0
- package/.framework/functions/system-cron.ts +742 -0
- package/.framework/functions/system.ts +323 -0
- package/.framework/functions/tests.ts +119 -0
- package/.framework/functions/timers.ts +357 -0
- package/.framework/functions/triggers.ts +563 -0
- package/.framework/functions/types.ts +604 -0
- package/.framework/migrations/000_foundation.sql +1256 -0
- package/.framework/migrations/001_seed.sql +92 -0
- package/.framework/migrations/002_seed_constraints.sql +13 -0
- package/.framework/migrations/003_auth_user_trigger.sql +59 -0
- package/.framework/src/App.tsx +126 -0
- package/.framework/src/apps/admin/index.tsx +173 -0
- package/.framework/src/components/AppWrapper.tsx +56 -0
- package/.framework/src/components/CustomAppLoader.tsx +116 -0
- package/.framework/src/components/admin/AdminListPage.tsx +151 -0
- package/.framework/src/components/admin/AdminSidebar.tsx +166 -0
- package/.framework/src/components/admin/AdminStatsCard.tsx +62 -0
- package/.framework/src/components/admin/SortableTableHeader.tsx +42 -0
- package/.framework/src/components/app-shell/GenericAppShell.tsx +181 -0
- package/.framework/src/components/app-shell/GenericDetailPage.tsx +200 -0
- package/.framework/src/components/app-shell/GenericListPage.tsx +116 -0
- package/.framework/src/components/app-sidebar.tsx +228 -0
- package/.framework/src/components/auth/ProtectedRoute.tsx +88 -0
- package/.framework/src/components/layout/AppShell.tsx +91 -0
- package/.framework/src/components/layout/Header.tsx +88 -0
- package/.framework/src/components/layout/Layout.tsx +95 -0
- package/.framework/src/components/layout/Sidebar.tsx +329 -0
- package/.framework/src/components/runtime/DataDetailHeader.tsx +77 -0
- package/.framework/src/components/runtime/DataDetailPage.tsx +171 -0
- package/.framework/src/components/runtime/DataFilters.tsx +91 -0
- package/.framework/src/components/runtime/DataHeader.tsx +68 -0
- package/.framework/src/components/runtime/DataListPage.tsx +124 -0
- package/.framework/src/components/runtime/DataStats.tsx +70 -0
- package/.framework/src/components/runtime/DataTable.tsx +174 -0
- package/.framework/src/components/runtime/SchemaDetailForm.tsx +134 -0
- package/.framework/src/components/runtime/index.ts +18 -0
- package/.framework/src/components/search-form.tsx +29 -0
- package/.framework/src/components/shared/AgentView.tsx +213 -0
- package/.framework/src/components/shared/FieldRenderer.tsx +478 -0
- package/.framework/src/components/shared/SchemaFields.tsx +226 -0
- package/.framework/src/components/ui/DataTable.tsx +343 -0
- package/.framework/src/components/ui/Form.tsx +281 -0
- package/.framework/src/components/ui/ItemCard.tsx +296 -0
- package/.framework/src/components/ui/ItemListView.tsx +308 -0
- package/.framework/src/components/ui/LoadingSpinner.tsx +52 -0
- package/.framework/src/components/ui/Modal.tsx +61 -0
- package/.framework/src/components/ui/RichTextEditor.tsx +210 -0
- package/.framework/src/components/ui/accordion.tsx +82 -0
- package/.framework/src/components/ui/alert-dialog.tsx +197 -0
- package/.framework/src/components/ui/alert.tsx +76 -0
- package/.framework/src/components/ui/aspect-ratio.tsx +11 -0
- package/.framework/src/components/ui/avatar.tsx +110 -0
- package/.framework/src/components/ui/badge.tsx +49 -0
- package/.framework/src/components/ui/breadcrumb.tsx +122 -0
- package/.framework/src/components/ui/button-group.tsx +83 -0
- package/.framework/src/components/ui/button.tsx +65 -0
- package/.framework/src/components/ui/calendar.tsx +222 -0
- package/.framework/src/components/ui/card.tsx +100 -0
- package/.framework/src/components/ui/carousel.tsx +240 -0
- package/.framework/src/components/ui/chart.tsx +373 -0
- package/.framework/src/components/ui/checkbox.tsx +31 -0
- package/.framework/src/components/ui/collapsible.tsx +33 -0
- package/.framework/src/components/ui/combobox.tsx +299 -0
- package/.framework/src/components/ui/command.tsx +193 -0
- package/.framework/src/components/ui/context-menu.tsx +261 -0
- package/.framework/src/components/ui/dialog.tsx +165 -0
- package/.framework/src/components/ui/direction.tsx +22 -0
- package/.framework/src/components/ui/drawer.tsx +132 -0
- package/.framework/src/components/ui/dropdown-menu.tsx +269 -0
- package/.framework/src/components/ui/empty.tsx +104 -0
- package/.framework/src/components/ui/field.tsx +238 -0
- package/.framework/src/components/ui/hover-card.tsx +42 -0
- package/.framework/src/components/ui/input-group.tsx +153 -0
- package/.framework/src/components/ui/input-otp.tsx +87 -0
- package/.framework/src/components/ui/input.tsx +19 -0
- package/.framework/src/components/ui/item.tsx +196 -0
- package/.framework/src/components/ui/kbd.tsx +26 -0
- package/.framework/src/components/ui/label.tsx +22 -0
- package/.framework/src/components/ui/menubar.tsx +277 -0
- package/.framework/src/components/ui/native-select.tsx +61 -0
- package/.framework/src/components/ui/navigation-menu.tsx +164 -0
- package/.framework/src/components/ui/pagination.tsx +129 -0
- package/.framework/src/components/ui/popover.tsx +87 -0
- package/.framework/src/components/ui/progress.tsx +31 -0
- package/.framework/src/components/ui/radio-group.tsx +42 -0
- package/.framework/src/components/ui/resizable.tsx +50 -0
- package/.framework/src/components/ui/scroll-area.tsx +53 -0
- package/.framework/src/components/ui/select.tsx +195 -0
- package/.framework/src/components/ui/separator.tsx +26 -0
- package/.framework/src/components/ui/sheet.tsx +145 -0
- package/.framework/src/components/ui/sidebar.tsx +706 -0
- package/.framework/src/components/ui/skeleton.tsx +13 -0
- package/.framework/src/components/ui/slider.tsx +59 -0
- package/.framework/src/components/ui/sonner.tsx +47 -0
- package/.framework/src/components/ui/spinner.tsx +10 -0
- package/.framework/src/components/ui/switch.tsx +33 -0
- package/.framework/src/components/ui/table-primitives.tsx +141 -0
- package/.framework/src/components/ui/table.tsx +114 -0
- package/.framework/src/components/ui/tabs.tsx +90 -0
- package/.framework/src/components/ui/textarea.tsx +18 -0
- package/.framework/src/components/ui/toggle-group.tsx +89 -0
- package/.framework/src/components/ui/toggle.tsx +45 -0
- package/.framework/src/components/ui/tooltip.tsx +57 -0
- package/.framework/src/contexts/AppContext.tsx +133 -0
- package/.framework/src/contexts/AuthContext.tsx +371 -0
- package/.framework/src/hooks/use-mobile.ts +19 -0
- package/.framework/src/hooks/useApi.ts +526 -0
- package/.framework/src/hooks/useApps.ts +114 -0
- package/.framework/src/hooks/useEntityList.ts +190 -0
- package/.framework/src/hooks/useEntityRecord.ts +308 -0
- package/.framework/src/hooks/useForm.ts +307 -0
- package/.framework/src/hooks/useListSchema.ts +264 -0
- package/.framework/src/hooks/useSchemaRecord.ts +223 -0
- package/.framework/src/index.css +128 -0
- package/.framework/src/lib/api.ts +156 -0
- package/.framework/src/lib/supabase.ts +94 -0
- package/.framework/src/lib/utils.ts +317 -0
- package/.framework/src/main.tsx +27 -0
- package/.framework/src/pages/DashboardPage.tsx +181 -0
- package/.framework/src/pages/NotFoundPage.tsx +39 -0
- package/.framework/src/pages/admin/AIAgentDetailPage.tsx +161 -0
- package/.framework/src/pages/admin/AIAgentsPage.tsx +318 -0
- package/.framework/src/pages/admin/APIKeyDetailPage.tsx +199 -0
- package/.framework/src/pages/admin/APIKeysPage.tsx +303 -0
- package/.framework/src/pages/admin/AlertsConfigPage.tsx +523 -0
- package/.framework/src/pages/admin/AppDetailPage.tsx +493 -0
- package/.framework/src/pages/admin/AppsPage.tsx +355 -0
- package/.framework/src/pages/admin/DesignedPage.tsx +491 -0
- package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +534 -0
- package/.framework/src/pages/admin/EmbeddingsPage.tsx +424 -0
- package/.framework/src/pages/admin/ExtendedShadcnTestPage.tsx +176 -0
- package/.framework/src/pages/admin/IncrementalShadcnTestPage.tsx +109 -0
- package/.framework/src/pages/admin/IntegratedDashboard.tsx +402 -0
- package/.framework/src/pages/admin/IntegrationDetailPage.tsx +187 -0
- package/.framework/src/pages/admin/IntegrationsPage.tsx +301 -0
- package/.framework/src/pages/admin/LogsPage.tsx +283 -0
- package/.framework/src/pages/admin/MinimalShadcnTestPage.tsx +85 -0
- package/.framework/src/pages/admin/ObservabilityDashboard.tsx +470 -0
- package/.framework/src/pages/admin/PipelineDetailPage.tsx +183 -0
- package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +279 -0
- package/.framework/src/pages/admin/PipelinesPage.tsx +390 -0
- package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +299 -0
- package/.framework/src/pages/admin/PromptConfigsPage.tsx +292 -0
- package/.framework/src/pages/admin/ProperlyDesignedPage.tsx +434 -0
- package/.framework/src/pages/admin/RoleDetailPage.tsx +273 -0
- package/.framework/src/pages/admin/RolesPage.tsx +292 -0
- package/.framework/src/pages/admin/SelectTestPage.tsx +61 -0
- package/.framework/src/pages/admin/ShadcnTestPage.tsx +588 -0
- package/.framework/src/pages/admin/SimpleDashboard.tsx +387 -0
- package/.framework/src/pages/admin/TestRunDetailPage.tsx +172 -0
- package/.framework/src/pages/admin/TestingDashboard.tsx +257 -0
- package/.framework/src/pages/admin/TimerDetailPage.tsx +151 -0
- package/.framework/src/pages/admin/TimersPage.tsx +376 -0
- package/.framework/src/pages/admin/TriggerDetailPage.tsx +149 -0
- package/.framework/src/pages/admin/TriggersPage.tsx +381 -0
- package/.framework/src/pages/admin/TypeDetailPage.tsx +694 -0
- package/.framework/src/pages/admin/TypesPage.tsx +295 -0
- package/.framework/src/pages/auth/LoginPage.tsx +188 -0
- package/.framework/src/pages/auth/RegisterPage.tsx +163 -0
- package/.framework/src/pages/spine-framework/APIPage.tsx +17 -0
- package/.framework/src/pages/spine-framework/CLIPage.tsx +25 -0
- package/.framework/src/types/auth.ts +125 -0
- package/.framework/src/types/types.ts +407 -0
- package/STRUCTURE.md +150 -0
- package/config/components.json +25 -0
- package/config/deno.lock +108 -0
- package/config/package-lock.json +17183 -0
- package/config/postcss.config.cjs +10 -0
- package/config/tailwind.config.cjs +78 -0
- package/config/tsconfig.build.json +32 -0
- package/config/tsconfig.cli.json +18 -0
- package/config/tsconfig.json +41 -0
- package/config/tsconfig.node.json +17 -0
- package/config/tsconfig.node.tsbuildinfo +1 -0
- package/config/tsconfig.tsbuildinfo +1 -0
- package/config/typedoc.json +16 -0
- package/config/vite.config.d.ts +2 -0
- package/config/vite.config.ts +72 -0
- package/dist/cli/commands/agents.d.ts +39 -0
- package/dist/cli/commands/agents.d.ts.map +1 -0
- package/dist/cli/commands/auth.d.ts +36 -0
- package/dist/cli/commands/auth.d.ts.map +1 -0
- package/dist/cli/commands/create-app.d.ts +23 -0
- package/dist/cli/commands/create-app.d.ts.map +1 -0
- package/dist/cli/commands/dev.d.ts +39 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/doctor.d.ts +42 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/generate.d.ts +36 -0
- package/dist/cli/commands/generate.d.ts.map +1 -0
- package/dist/cli/commands/init.d.ts +30 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/install-app.d.ts +30 -0
- package/dist/cli/commands/install-app.d.ts.map +1 -0
- package/dist/cli/commands/items.d.ts +45 -0
- package/dist/cli/commands/items.d.ts.map +1 -0
- package/dist/cli/commands/migrations.d.ts +41 -0
- package/dist/cli/commands/migrations.d.ts.map +1 -0
- package/dist/cli/commands/pipelines.d.ts +40 -0
- package/dist/cli/commands/pipelines.d.ts.map +1 -0
- package/dist/cli/commands/status.d.ts +23 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/system.d.ts +29 -0
- package/dist/cli/commands/system.d.ts.map +1 -0
- package/dist/cli/commands/test.d.ts +46 -0
- package/dist/cli/commands/test.d.ts.map +1 -0
- package/dist/cli/commands/uninstall-app.d.ts +23 -0
- package/dist/cli/commands/uninstall-app.d.ts.map +1 -0
- package/dist/cli/context.d.ts +88 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/env-loader.d.ts +14 -0
- package/dist/cli/env-loader.d.ts.map +1 -0
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/functions/_shared/agent-runner.d.ts +156 -0
- package/dist/functions/_shared/agent-runner.d.ts.map +1 -0
- package/dist/functions/_shared/app-manifest.d.ts +68 -0
- package/dist/functions/_shared/app-manifest.d.ts.map +1 -0
- package/dist/functions/_shared/audit.d.ts +91 -0
- package/dist/functions/_shared/audit.d.ts.map +1 -0
- package/dist/functions/_shared/db.d.ts +125 -0
- package/dist/functions/_shared/db.d.ts.map +1 -0
- package/dist/functions/_shared/index.d.ts +298 -0
- package/dist/functions/_shared/index.d.ts.map +1 -0
- package/dist/functions/_shared/middleware.d.ts +315 -0
- package/dist/functions/_shared/middleware.d.ts.map +1 -0
- package/dist/functions/_shared/permissions.d.ts +626 -0
- package/dist/functions/_shared/permissions.d.ts.map +1 -0
- package/dist/functions/_shared/pipeline-runner.d.ts +124 -0
- package/dist/functions/_shared/pipeline-runner.d.ts.map +1 -0
- package/dist/functions/_shared/principal.d.ts +284 -0
- package/dist/functions/_shared/principal.d.ts.map +1 -0
- package/dist/functions/_shared/schema-utils.d.ts +181 -0
- package/dist/functions/_shared/schema-utils.d.ts.map +1 -0
- package/dist/functions/_shared/testing.d.ts +172 -0
- package/dist/functions/_shared/testing.d.ts.map +1 -0
- package/dist/functions/_shared/trigger-engine.d.ts +140 -0
- package/dist/functions/_shared/trigger-engine.d.ts.map +1 -0
- package/dist/functions/_shared/webhook-registration.d.ts +81 -0
- package/dist/functions/_shared/webhook-registration.d.ts.map +1 -0
- package/dist/functions/_shared/webhook-registry.d.ts +57 -0
- package/dist/functions/_shared/webhook-registry.d.ts.map +1 -0
- package/dist/functions/account-nodes.d.ts +48 -0
- package/dist/functions/account-nodes.d.ts.map +1 -0
- package/dist/functions/admin-data.d.ts +178 -0
- package/dist/functions/admin-data.d.ts.map +1 -0
- package/dist/functions/ai-agents.d.ts +125 -0
- package/dist/functions/ai-agents.d.ts.map +1 -0
- package/dist/functions/api-keys.d.ts +140 -0
- package/dist/functions/api-keys.d.ts.map +1 -0
- package/dist/functions/apps.d.ts +163 -0
- package/dist/functions/apps.d.ts.map +1 -0
- package/dist/functions/auth.d.ts +74 -0
- package/dist/functions/auth.d.ts.map +1 -0
- package/dist/functions/debug-auth.d.ts +33 -0
- package/dist/functions/debug-auth.d.ts.map +1 -0
- package/dist/functions/embeddings.d.ts +205 -0
- package/dist/functions/embeddings.d.ts.map +1 -0
- package/dist/functions/integration-routes.d.ts +45 -0
- package/dist/functions/integration-routes.d.ts.map +1 -0
- package/dist/functions/integrations.d.ts +124 -0
- package/dist/functions/integrations.d.ts.map +1 -0
- package/dist/functions/item-progress.d.ts +41 -0
- package/dist/functions/item-progress.d.ts.map +1 -0
- package/dist/functions/logs.d.ts +162 -0
- package/dist/functions/logs.d.ts.map +1 -0
- package/dist/functions/observability.d.ts +123 -0
- package/dist/functions/observability.d.ts.map +1 -0
- package/dist/functions/pipeline-executions.d.ts +190 -0
- package/dist/functions/pipeline-executions.d.ts.map +1 -0
- package/dist/functions/pipelines.d.ts +171 -0
- package/dist/functions/pipelines.d.ts.map +1 -0
- package/dist/functions/prompt-configs.d.ts +125 -0
- package/dist/functions/prompt-configs.d.ts.map +1 -0
- package/dist/functions/roles.d.ts +118 -0
- package/dist/functions/roles.d.ts.map +1 -0
- package/dist/functions/system-cron.d.ts +65 -0
- package/dist/functions/system-cron.d.ts.map +1 -0
- package/dist/functions/system.d.ts +29 -0
- package/dist/functions/system.d.ts.map +1 -0
- package/dist/functions/tests.d.ts +28 -0
- package/dist/functions/tests.d.ts.map +1 -0
- package/dist/functions/timers.d.ts +139 -0
- package/dist/functions/timers.d.ts.map +1 -0
- package/dist/functions/triggers.d.ts +203 -0
- package/dist/functions/triggers.d.ts.map +1 -0
- package/dist/functions/types.d.ts +151 -0
- package/dist/functions/types.d.ts.map +1 -0
- package/dist/src/types/types.d.ts +364 -0
- package/dist/src/types/types.d.ts.map +1 -0
- package/package.json +192 -0
- package/scripts/app-install-cli.ts +286 -0
- package/scripts/assemble-frontend.sh +79 -0
- package/scripts/assemble-functions.sh +62 -0
- package/scripts/assemble.sh +35 -0
- package/scripts/boundary-check.sh +106 -0
- package/scripts/build-manifest.sh +80 -0
- package/scripts/check-core-integrity.sh +82 -0
- package/scripts/ingest-chunks.cjs +202 -0
- package/scripts/kb-chunk-parser.cjs +312 -0
- package/scripts/kb-chunk-parser.ts +330 -0
- package/scripts/load-test-app-install.ts +484 -0
- package/scripts/netlify-dev-wrapper.sh +22 -0
- package/scripts/verify-integrity.sh +69 -0
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module permissions
|
|
3
|
+
* @audience both
|
|
4
|
+
* @layer shared-core
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Single source of truth for all authorization in Spine. Exports one singleton —
|
|
8
|
+
* `PermissionEngine` — that routes every access check to one of three permission
|
|
9
|
+
* surfaces based on the table being accessed:
|
|
10
|
+
*
|
|
11
|
+
* First surface — runtime data (items, accounts, people, threads, messages…)
|
|
12
|
+
* Schema-driven: permissions are encoded in `design_schema.record_permissions`
|
|
13
|
+
* and `design_schema.fields[x].permissions` stamped on the record at creation.
|
|
14
|
+
*
|
|
15
|
+
* Second surface — config objects (apps, pipelines, triggers, roles, types…)
|
|
16
|
+
* Role-driven: system_admin full access, machine read, others denied.
|
|
17
|
+
*
|
|
18
|
+
* Third surface — system metadata (logs, pipeline_executions, link_types…)
|
|
19
|
+
* Ownership-driven: users read their own, system_admin sees all.
|
|
20
|
+
*
|
|
21
|
+
* INVARIANT: system_admin bypasses ALL surface checks. No other bypass exists.
|
|
22
|
+
* INVARIANT: missing or empty `design_schema` on a first-surface record is an
|
|
23
|
+
* explicit deny — not a free pass. RLS controls row access; design_schema
|
|
24
|
+
* controls what the principal can do with the row.
|
|
25
|
+
* INVARIANT: never import or instantiate `_PermissionEngineInternal` directly.
|
|
26
|
+
* Always import the `PermissionEngine` singleton or the named legacy exports.
|
|
27
|
+
*
|
|
28
|
+
* @seeAlso db.ts (adminDb used for schema and person lookups)
|
|
29
|
+
* @seeAlso principal.ts (Principal interface, isSystemAdmin, getPrincipalDb)
|
|
30
|
+
* @seeAlso middleware.ts (CoreContext shape, ctx.db, ctx.principal)
|
|
31
|
+
* @seeAlso schema-utils.ts (formatFieldData, sanitizeFieldData called during sanitization)
|
|
32
|
+
* @seeAlso index.ts (stable export surface for custom code)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { adminDb } from './db'
|
|
36
|
+
import { Principal } from './principal'
|
|
37
|
+
import { CoreContext } from './middleware'
|
|
38
|
+
|
|
39
|
+
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Result of a permission resolution for a principal + record + action combination.
|
|
43
|
+
*
|
|
44
|
+
* Returned by `resolveFirstSurfacePermissions`. Captures both record-level CRUD
|
|
45
|
+
* flags and per-field read/write flags derived from `design_schema`.
|
|
46
|
+
*
|
|
47
|
+
* All flags default to `false` on any error or missing schema — never assume
|
|
48
|
+
* a missing flag means "allowed".
|
|
49
|
+
*
|
|
50
|
+
* @inputSpec none — this is a pure output type
|
|
51
|
+
* @outputSpec canCreate: boolean — principal may create records of this type
|
|
52
|
+
* @outputSpec canRead: boolean — principal may read this record
|
|
53
|
+
* @outputSpec canUpdate: boolean — principal may update this record
|
|
54
|
+
* @outputSpec canDelete: boolean — principal may delete this record
|
|
55
|
+
* @outputSpec fieldPermissions: Record<fieldName, {read, write}> — per-field flags
|
|
56
|
+
* derived from design_schema.fields[x].permissions merged across all roles
|
|
57
|
+
* @calledBy resolveFirstSurfacePermissions (producer), sanitizeFirstSurfaceRecordData,
|
|
58
|
+
* validateFirstSurfaceUpdatePermissions, canAccessFirstSurfaceRecord (consumers)
|
|
59
|
+
*/
|
|
60
|
+
export interface PermissionResult {
|
|
61
|
+
canCreate: boolean
|
|
62
|
+
canRead: boolean
|
|
63
|
+
canUpdate: boolean
|
|
64
|
+
canDelete: boolean
|
|
65
|
+
fieldPermissions: Record<string, { read: boolean; write: boolean }>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type RequestContext = CoreContext
|
|
69
|
+
|
|
70
|
+
// ─── ENGINE CLASS ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The single permission engine for all authorization in Spine.
|
|
74
|
+
*
|
|
75
|
+
* Instantiated once as a module-level singleton (`PermissionEngine`). Routes
|
|
76
|
+
* every check through one of three surfaces based on table classification.
|
|
77
|
+
* All public methods are async and never throw — on any internal error they
|
|
78
|
+
* fall back to a deny result to avoid accidental permission grants.
|
|
79
|
+
*
|
|
80
|
+
* Do not instantiate directly. Import `PermissionEngine` or use the named
|
|
81
|
+
* legacy exports (`sanitizeRecordData`, `validateUpdatePermissions`, etc.).
|
|
82
|
+
*
|
|
83
|
+
* @audience both
|
|
84
|
+
* @stability stable
|
|
85
|
+
* @calledBy All 19 API handlers via sanitizeRecordData / validateUpdatePermissions
|
|
86
|
+
* @calledBy admin-data.ts (primary consumer for runtime data)
|
|
87
|
+
* @testUnit tests/unit/permissions.test.ts
|
|
88
|
+
* @testIntegration tests/integration/isolation.test.ts, admin-data-accounts.test.ts
|
|
89
|
+
*/
|
|
90
|
+
class _PermissionEngineInternal {
|
|
91
|
+
private static instance: _PermissionEngineInternal
|
|
92
|
+
|
|
93
|
+
// ─── SURFACE CLASSIFICATION ───────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
// Surface classification tables
|
|
96
|
+
private readonly SECOND_SURFACE_TABLES = new Set([
|
|
97
|
+
'apps', 'app', 'pipelines', 'triggers', 'ai_agents', 'embeddings',
|
|
98
|
+
'timers', 'integrations', 'roles', 'types', 'prompt_configs'
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
private readonly THIRD_SURFACE_TABLES = new Set([
|
|
102
|
+
'logs', 'pipeline_executions', 'trigger_executions',
|
|
103
|
+
'link_types', 'links'
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
private constructor() {}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Classifies a table name into one of Spine's three permission surfaces.
|
|
110
|
+
*
|
|
111
|
+
* Surface membership is determined by static set membership — if a table is
|
|
112
|
+
* not in SECOND_SURFACE_TABLES or THIRD_SURFACE_TABLES, it defaults to first.
|
|
113
|
+
* This is intentionally conservative: unknown tables get the most restrictive
|
|
114
|
+
* surface (first), which requires a valid design_schema to grant any access.
|
|
115
|
+
*
|
|
116
|
+
* @param tableName - Table name string (e.g. 'items', 'pipelines', 'logs')
|
|
117
|
+
* @returns 'first' | 'second' | 'third' — surface classification
|
|
118
|
+
* @throws never
|
|
119
|
+
* @inputSpec tableName: string — any string; unknown names → 'first'
|
|
120
|
+
* @outputSpec 'first' | 'second' | 'third'
|
|
121
|
+
* @sideEffects none
|
|
122
|
+
* @calledBy canAccessRecord, sanitizeRecordData, validateUpdatePermissions
|
|
123
|
+
*/
|
|
124
|
+
private detectSurface(tableName: string): 'first' | 'second' | 'third' {
|
|
125
|
+
if (this.SECOND_SURFACE_TABLES.has(tableName)) {
|
|
126
|
+
return 'second'
|
|
127
|
+
}
|
|
128
|
+
if (this.THIRD_SURFACE_TABLES.has(tableName)) {
|
|
129
|
+
return 'third'
|
|
130
|
+
}
|
|
131
|
+
return 'first'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extracts a table/type name from a record to use for surface classification.
|
|
136
|
+
*
|
|
137
|
+
* Tries multiple fields in priority order: `record.table_name` (explicitly
|
|
138
|
+
* set by some handlers), `record.type`, `record.item_type`, then the
|
|
139
|
+
* `typeSlug` param. Falls back to `'unknown'` which routes to first surface.
|
|
140
|
+
*
|
|
141
|
+
* @param record - The record object being classified
|
|
142
|
+
* @param typeSlug - Optional caller-provided type slug (used as last resort)
|
|
143
|
+
* @returns string — table name used to classify the permission surface
|
|
144
|
+
* @throws never
|
|
145
|
+
* @inputSpec record: object — any record; missing fields are safely ignored
|
|
146
|
+
* @inputSpec typeSlug: string | undefined — optional fallback
|
|
147
|
+
* @outputSpec string — one of the known table names, or 'unknown'
|
|
148
|
+
* @sideEffects none
|
|
149
|
+
* @calledBy canAccessRecord, sanitizeRecordData, validateUpdatePermissions
|
|
150
|
+
*/
|
|
151
|
+
private extractTableName(record: any, typeSlug?: string): string {
|
|
152
|
+
// Try to get table name from record context
|
|
153
|
+
if (record?.table_name) {
|
|
154
|
+
return record.table_name
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try to get from type field
|
|
158
|
+
if (record?.type) {
|
|
159
|
+
return record.type
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Try to get from item_type field
|
|
163
|
+
if (record?.item_type) {
|
|
164
|
+
return record.item_type
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Use provided typeSlug
|
|
168
|
+
if (typeSlug) {
|
|
169
|
+
return typeSlug
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Default to unknown (will be treated as first surface)
|
|
173
|
+
return 'unknown'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns the singleton instance. Called once at module load time to
|
|
178
|
+
* initialise `PermissionEngine`. Not for direct use outside this file.
|
|
179
|
+
*
|
|
180
|
+
* @returns _PermissionEngineInternal — the single shared instance
|
|
181
|
+
* @throws never
|
|
182
|
+
* @sideEffects creates instance on first call (subsequent calls return cached)
|
|
183
|
+
* @calledBy module initialisation (bottom of this file)
|
|
184
|
+
*/
|
|
185
|
+
static getInstance(): _PermissionEngineInternal {
|
|
186
|
+
if (!_PermissionEngineInternal.instance) {
|
|
187
|
+
_PermissionEngineInternal.instance = new _PermissionEngineInternal()
|
|
188
|
+
}
|
|
189
|
+
return _PermissionEngineInternal.instance
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── FIRST SURFACE — RUNTIME DATA ──────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolves record-level and field-level permissions for a human principal
|
|
196
|
+
* acting on a first-surface (runtime data) record.
|
|
197
|
+
*
|
|
198
|
+
* Resolution steps:
|
|
199
|
+
* 1. Load `design_schema` from the type record if not pre-stamped on the record
|
|
200
|
+
* 2. Look up the person's role via `people.role_id` FK (single DB query)
|
|
201
|
+
* 3. Evaluate `design_schema.record_permissions[role]` array for CRUD flags
|
|
202
|
+
* 4. Evaluate `design_schema.fields[x].permissions[role]` for field flags
|
|
203
|
+
* 5. Apply `'all'` wildcard role key if present (grants to all authenticated)
|
|
204
|
+
* 6. For fields with no explicit permission, inherit from record-level flags
|
|
205
|
+
*
|
|
206
|
+
* Returns all-deny `PermissionResult` on any error — never throws.
|
|
207
|
+
*
|
|
208
|
+
* @param personId - UUID of the person making the request (from principal.id)
|
|
209
|
+
* @param accountId - UUID of the account context for the operation
|
|
210
|
+
* @param typeSlug - Slug of the type to look up design_schema if not pre-stamped
|
|
211
|
+
* @param _action - CRUD action (currently used for context; merge logic is role-based)
|
|
212
|
+
* @param designSchema - Pre-loaded design_schema object (skips DB lookup if provided)
|
|
213
|
+
*
|
|
214
|
+
* @inputSpec personId: string — valid UUID, must exist in people table with is_active=true
|
|
215
|
+
* @inputSpec accountId: string — valid UUID of accessible account
|
|
216
|
+
* @inputSpec typeSlug: string — slug of a type in the types table with is_active=true
|
|
217
|
+
* @inputSpec designSchema: object | undefined — if provided, must have record_permissions
|
|
218
|
+
* @outputSpec PermissionResult — all flags false on error/missing schema
|
|
219
|
+
* @throws never — catches all errors, returns defaultResult
|
|
220
|
+
* @sideEffects DB read: types table (if schema not pre-stamped), people table (role lookup)
|
|
221
|
+
* @calledBy canAccessFirstSurfaceRecord, sanitizeFirstSurfaceRecordData,
|
|
222
|
+
* validateFirstSurfaceUpdatePermissions
|
|
223
|
+
* @calls adminDb.from('types'), adminDb.from('people')
|
|
224
|
+
* @testUnit tests/unit/permissions.test.ts — 'resolveFirstSurfacePermissions' describe block
|
|
225
|
+
*
|
|
226
|
+
* @example Import usage (v2-custom/)
|
|
227
|
+
* ```ts
|
|
228
|
+
* import { PermissionEngine } from '../_shared/index'
|
|
229
|
+
* const perms = await PermissionEngine.resolveFirstSurfacePermissions(
|
|
230
|
+
* ctx.principal.id, ctx.accountId, 'ticket', 'read'
|
|
231
|
+
* )
|
|
232
|
+
* if (!perms.canRead) return { error: 'Forbidden' }
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
async resolveFirstSurfacePermissions(
|
|
236
|
+
personId: string,
|
|
237
|
+
accountId: string,
|
|
238
|
+
typeSlug: string,
|
|
239
|
+
_action: 'create' | 'read' | 'update' | 'delete',
|
|
240
|
+
designSchema?: any
|
|
241
|
+
): Promise<PermissionResult> {
|
|
242
|
+
// Default deny result
|
|
243
|
+
const defaultResult: PermissionResult = {
|
|
244
|
+
canCreate: false,
|
|
245
|
+
canRead: false,
|
|
246
|
+
canUpdate: false,
|
|
247
|
+
canDelete: false,
|
|
248
|
+
fieldPermissions: {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
// 1. Load type design schema if not provided (pre-stamped on record is preferred)
|
|
253
|
+
let schema = designSchema
|
|
254
|
+
if (!schema || !schema.record_permissions) {
|
|
255
|
+
// Attempt type lookup by slug as fallback
|
|
256
|
+
const { data: typeRecord } = await adminDb
|
|
257
|
+
.from('types')
|
|
258
|
+
.select('design_schema')
|
|
259
|
+
.eq('slug', typeSlug)
|
|
260
|
+
.eq('is_active', true)
|
|
261
|
+
.single()
|
|
262
|
+
|
|
263
|
+
// No schema = no permissions. RLS controls row access;
|
|
264
|
+
// design_schema controls what the principal can do with the record.
|
|
265
|
+
// A missing or empty schema is an explicit deny — not a free pass.
|
|
266
|
+
if (!typeRecord?.design_schema?.record_permissions) {
|
|
267
|
+
return defaultResult
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
schema = typeRecord.design_schema
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. Get user's role via people.role_id FK
|
|
274
|
+
const { data: person } = await adminDb
|
|
275
|
+
.from('people')
|
|
276
|
+
.select('role:role_id(slug)')
|
|
277
|
+
.eq('id', personId)
|
|
278
|
+
.eq('is_active', true)
|
|
279
|
+
.single()
|
|
280
|
+
|
|
281
|
+
const roleSlug = (person?.role as any)?.slug || Array.isArray(person?.role) && (person.role as any)[0]?.slug
|
|
282
|
+
if (!roleSlug) {
|
|
283
|
+
return defaultResult
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const userRoles = [roleSlug]
|
|
287
|
+
|
|
288
|
+
// 3. Evaluate record permissions for each role
|
|
289
|
+
const recordPermissions = schema.record_permissions || {}
|
|
290
|
+
const fieldDefinitions = schema.fields || {}
|
|
291
|
+
|
|
292
|
+
let mergedResult: PermissionResult = {
|
|
293
|
+
canCreate: false,
|
|
294
|
+
canRead: false,
|
|
295
|
+
canUpdate: false,
|
|
296
|
+
canDelete: false,
|
|
297
|
+
fieldPermissions: {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Merge permissions across all roles (union of actions)
|
|
301
|
+
// 'all' is a special wildcard role key: grants access to every authenticated principal
|
|
302
|
+
// that passed RLS, regardless of their named role. Always evaluated.
|
|
303
|
+
const rolesToEvaluate = recordPermissions['all'] ? [...userRoles, 'all'] : userRoles
|
|
304
|
+
for (const role of rolesToEvaluate) {
|
|
305
|
+
const rolePerms = recordPermissions[role]
|
|
306
|
+
if (!rolePerms || !Array.isArray(rolePerms)) continue
|
|
307
|
+
|
|
308
|
+
// Merge record permissions using array format: ["create", "read", "update", "delete"]
|
|
309
|
+
mergedResult.canCreate = mergedResult.canCreate || rolePerms.includes('create')
|
|
310
|
+
mergedResult.canRead = mergedResult.canRead || rolePerms.includes('read')
|
|
311
|
+
mergedResult.canUpdate = mergedResult.canUpdate || rolePerms.includes('update')
|
|
312
|
+
mergedResult.canDelete = mergedResult.canDelete || rolePerms.includes('delete')
|
|
313
|
+
|
|
314
|
+
// 5. Merge field permissions for this role
|
|
315
|
+
for (const [fieldName, fieldDef] of Object.entries(fieldDefinitions)) {
|
|
316
|
+
const fieldPerms = (fieldDef as any).permissions?.[role]
|
|
317
|
+
if (!fieldPerms || !Array.isArray(fieldPerms)) continue
|
|
318
|
+
|
|
319
|
+
if (!mergedResult.fieldPermissions[fieldName]) {
|
|
320
|
+
mergedResult.fieldPermissions[fieldName] = { read: false, write: false }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Merge field permissions using array format: ["read", "write"]
|
|
324
|
+
mergedResult.fieldPermissions[fieldName].read =
|
|
325
|
+
mergedResult.fieldPermissions[fieldName].read || fieldPerms.includes('read')
|
|
326
|
+
mergedResult.fieldPermissions[fieldName].write =
|
|
327
|
+
mergedResult.fieldPermissions[fieldName].write || fieldPerms.includes('write')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 6. Apply record-level access to fields without explicit permissions
|
|
332
|
+
for (const [fieldName, _fieldDef] of Object.entries(fieldDefinitions)) {
|
|
333
|
+
if (!mergedResult.fieldPermissions[fieldName]) {
|
|
334
|
+
mergedResult.fieldPermissions[fieldName] = {
|
|
335
|
+
read: mergedResult.canRead,
|
|
336
|
+
write: mergedResult.canUpdate
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return mergedResult
|
|
342
|
+
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error('Error resolving permissions:', error)
|
|
345
|
+
return defaultResult
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── SECOND SURFACE — CONFIG OBJECTS ────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Checks whether the principal in `ctx` may perform `action` on a second-surface
|
|
353
|
+
* config object (apps, pipelines, triggers, roles, types, etc.).
|
|
354
|
+
*
|
|
355
|
+
* Rules:
|
|
356
|
+
* - system_admin: full access to all actions
|
|
357
|
+
* - machine principal: read-only
|
|
358
|
+
* - all others: denied
|
|
359
|
+
*
|
|
360
|
+
* @param ctx - Request context containing principal
|
|
361
|
+
* @param action - CRUD action being attempted
|
|
362
|
+
* @returns boolean — true if access is allowed
|
|
363
|
+
* @throws never
|
|
364
|
+
* @inputSpec ctx.principal: Principal — must be resolved (not anonymous)
|
|
365
|
+
* @inputSpec action: 'create' | 'read' | 'update' | 'delete'
|
|
366
|
+
* @outputSpec boolean — true = allowed, false = denied
|
|
367
|
+
* @sideEffects none
|
|
368
|
+
* @calledBy canAccessRecord (surface='second'), validateConfigObjectPermissions
|
|
369
|
+
*/
|
|
370
|
+
private canAccessConfigObject(ctx: RequestContext, action: 'create' | 'read' | 'update' | 'delete'): boolean {
|
|
371
|
+
// System admin has full access
|
|
372
|
+
if (this.isSystemAdmin(ctx)) {
|
|
373
|
+
return true
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// System role can only read
|
|
377
|
+
if (ctx.principal?.type === 'machine' && action === 'read') {
|
|
378
|
+
return true
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// All other access denied
|
|
382
|
+
return false
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Strips fields from a second-surface config record based on the principal's access.
|
|
387
|
+
*
|
|
388
|
+
* system_admin and machine principals receive the full record. All others
|
|
389
|
+
* receive only `{ id, created_at, updated_at }`. This is intentionally strict
|
|
390
|
+
* — config objects contain sensitive pipeline logic, schema definitions, and
|
|
391
|
+
* integration credentials that must not leak to end users.
|
|
392
|
+
*
|
|
393
|
+
* @param ctx - Request context
|
|
394
|
+
* @param record - The config record to sanitize
|
|
395
|
+
* @returns Sanitized record — full record or minimal stub
|
|
396
|
+
* @throws never
|
|
397
|
+
* @inputSpec ctx.principal: Principal — resolved principal
|
|
398
|
+
* @inputSpec record: object — must have id, created_at, updated_at at minimum
|
|
399
|
+
* @outputSpec object — full record for admin/machine, { id, created_at, updated_at } for others
|
|
400
|
+
* @sideEffects none
|
|
401
|
+
* @calledBy sanitizeRecordData (surface='second')
|
|
402
|
+
*/
|
|
403
|
+
private sanitizeConfigObject(ctx: RequestContext, record: any): any {
|
|
404
|
+
// Debug logging
|
|
405
|
+
console.log('sanitizeConfigObject called with record:', {
|
|
406
|
+
id: record.id,
|
|
407
|
+
slug: record.slug,
|
|
408
|
+
route_prefix: record.route_prefix,
|
|
409
|
+
renderer: record.renderer,
|
|
410
|
+
is_system: record.is_system,
|
|
411
|
+
min_role: record.min_role
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// System admin sees everything
|
|
415
|
+
if (this.isSystemAdmin(ctx)) {
|
|
416
|
+
return record
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// System role sees everything if they have read access
|
|
420
|
+
if (ctx.principal?.type === 'machine') {
|
|
421
|
+
return record
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// For apps table, include essential routing fields needed by React app
|
|
425
|
+
// Check multiple app-specific fields to properly identify apps records
|
|
426
|
+
if (record.route_prefix !== undefined || record.renderer !== undefined ||
|
|
427
|
+
(record.slug && (record.is_system !== undefined || record.min_role !== undefined))) {
|
|
428
|
+
console.log('Detected apps record, returning full fields')
|
|
429
|
+
return {
|
|
430
|
+
id: record.id,
|
|
431
|
+
slug: record.slug,
|
|
432
|
+
name: record.name,
|
|
433
|
+
description: record.description,
|
|
434
|
+
route_prefix: record.route_prefix,
|
|
435
|
+
min_role: record.min_role,
|
|
436
|
+
is_active: record.is_active,
|
|
437
|
+
is_system: record.is_system,
|
|
438
|
+
renderer: record.renderer,
|
|
439
|
+
created_at: record.created_at,
|
|
440
|
+
updated_at: record.updated_at
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
console.log('Not detected as apps record, returning minimal data')
|
|
445
|
+
// Others see minimal data for other config objects
|
|
446
|
+
return {
|
|
447
|
+
id: record.id,
|
|
448
|
+
created_at: record.created_at,
|
|
449
|
+
updated_at: record.updated_at
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validates whether the principal may perform `action` on a second-surface record.
|
|
455
|
+
* Thin wrapper around `canAccessConfigObject` that returns a typed result object
|
|
456
|
+
* suitable for returning directly from handler validation checks.
|
|
457
|
+
*
|
|
458
|
+
* @param ctx - Request context
|
|
459
|
+
* @param action - CRUD action being validated
|
|
460
|
+
* @returns { valid: boolean, error?: string }
|
|
461
|
+
* @throws never
|
|
462
|
+
* @inputSpec ctx.principal: Principal — resolved principal
|
|
463
|
+
* @inputSpec action: 'create' | 'read' | 'update' | 'delete'
|
|
464
|
+
* @outputSpec valid: boolean — true if action is permitted
|
|
465
|
+
* @outputSpec error: string | undefined — human-readable denial reason if !valid
|
|
466
|
+
* @sideEffects none
|
|
467
|
+
* @calledBy validateUpdatePermissions (surface='second')
|
|
468
|
+
*/
|
|
469
|
+
private validateConfigObjectPermissions(ctx: RequestContext, action: 'create' | 'read' | 'update' | 'delete'): { valid: boolean; error?: string } {
|
|
470
|
+
if (this.canAccessConfigObject(ctx, action)) {
|
|
471
|
+
return { valid: true }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return { valid: false, error: 'Insufficient permissions for this operation' }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── THIRD SURFACE — SYSTEM METADATA ────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Checks whether the principal may access a third-surface system metadata record
|
|
481
|
+
* (logs, pipeline_executions, trigger_executions, link_types, links).
|
|
482
|
+
*
|
|
483
|
+
* Rules:
|
|
484
|
+
* - system_admin: full access
|
|
485
|
+
* - machine principal: full access
|
|
486
|
+
* - human principal (read only):
|
|
487
|
+
* - owns the record (created_by === principal.id), OR
|
|
488
|
+
* - record is scoped to the principal's account (account_id === ctx.accountId), OR
|
|
489
|
+
* - record references the principal directly (person_id === principal.id)
|
|
490
|
+
* - human principal (create/update/delete): always denied
|
|
491
|
+
*
|
|
492
|
+
* @param ctx - Request context
|
|
493
|
+
* @param record - The system metadata record being accessed
|
|
494
|
+
* @param action - CRUD action being attempted
|
|
495
|
+
* @returns boolean — true if access is allowed
|
|
496
|
+
* @throws never
|
|
497
|
+
* @inputSpec ctx.principal: Principal — resolved principal
|
|
498
|
+
* @inputSpec record: object — must have at least one of: created_by, account_id, person_id
|
|
499
|
+
* @inputSpec action: 'create' | 'read' | 'update' | 'delete'
|
|
500
|
+
* @outputSpec boolean
|
|
501
|
+
* @sideEffects none
|
|
502
|
+
* @calledBy canAccessRecord (surface='third'), sanitizeSystemMetadata,
|
|
503
|
+
* validateSystemMetadataPermissions
|
|
504
|
+
*/
|
|
505
|
+
private canAccessSystemMetadata(ctx: RequestContext, record: any, action: 'create' | 'read' | 'update' | 'delete'): boolean {
|
|
506
|
+
// System admin has full access
|
|
507
|
+
if (this.isSystemAdmin(ctx)) {
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// System context has full access
|
|
512
|
+
if (ctx.principal?.type === 'machine') {
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Users can only read their own data
|
|
517
|
+
if (action === 'read') {
|
|
518
|
+
// Check if user owns this record or is related to it
|
|
519
|
+
if (record.created_by === ctx.principal?.id) {
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Check account ownership
|
|
524
|
+
if (record.account_id && record.account_id === ctx.accountId) {
|
|
525
|
+
return true
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check person-specific records
|
|
529
|
+
if (record.person_id && record.person_id === ctx.principal?.id) {
|
|
530
|
+
return true
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Users cannot create/update/delete system metadata
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Strips fields from a third-surface system metadata record based on ownership.
|
|
540
|
+
*
|
|
541
|
+
* system_admin and machine principals receive the full record. Human principals
|
|
542
|
+
* who pass `canAccessSystemMetadata` receive the full record. All others
|
|
543
|
+
* receive only `{ id, created_at, updated_at }`.
|
|
544
|
+
*
|
|
545
|
+
* @param ctx - Request context
|
|
546
|
+
* @param record - The system metadata record to sanitize
|
|
547
|
+
* @returns Sanitized record
|
|
548
|
+
* @throws never
|
|
549
|
+
* @inputSpec record: object — must have id, created_at, updated_at
|
|
550
|
+
* @outputSpec object — full record for system_admin/machine/owner, minimal stub for others
|
|
551
|
+
* @sideEffects none
|
|
552
|
+
* @calledBy sanitizeRecordData (surface='third')
|
|
553
|
+
*/
|
|
554
|
+
private sanitizeSystemMetadata(ctx: RequestContext, record: any): any {
|
|
555
|
+
// System admin and system role see everything
|
|
556
|
+
if (this.isSystemAdmin(ctx) || ctx.principal?.type === 'machine') {
|
|
557
|
+
return record
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Users see only their own data
|
|
561
|
+
if (this.canAccessSystemMetadata(ctx, record, 'read')) {
|
|
562
|
+
return record
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Others see minimal data
|
|
566
|
+
return {
|
|
567
|
+
id: record.id,
|
|
568
|
+
created_at: record.created_at,
|
|
569
|
+
updated_at: record.updated_at
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Validates whether the principal may perform `action` on a third-surface record.
|
|
575
|
+
* Delegates to `canAccessSystemMetadata` and wraps the result.
|
|
576
|
+
*
|
|
577
|
+
* @param ctx - Request context
|
|
578
|
+
* @param record - The system metadata record
|
|
579
|
+
* @param action - CRUD action being validated
|
|
580
|
+
* @returns { valid: boolean, error?: string }
|
|
581
|
+
* @throws never
|
|
582
|
+
* @inputSpec record: object — the record being written/read
|
|
583
|
+
* @outputSpec valid: boolean — true if action is permitted
|
|
584
|
+
* @outputSpec error: string | undefined — denial reason if !valid
|
|
585
|
+
* @sideEffects none
|
|
586
|
+
* @calledBy validateUpdatePermissions (surface='third')
|
|
587
|
+
*/
|
|
588
|
+
private validateSystemMetadataPermissions(ctx: RequestContext, record: any, action: 'create' | 'read' | 'update' | 'delete'): { valid: boolean; error?: string } {
|
|
589
|
+
if (this.canAccessSystemMetadata(ctx, record, action)) {
|
|
590
|
+
return { valid: true }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return { valid: false, error: 'Insufficient permissions for this operation' }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ─── SHARED HELPERS ──────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Returns true if the principal in `ctx` holds the `system_admin` role.
|
|
600
|
+
*
|
|
601
|
+
* This is the canonical system_admin check used by all three surfaces and
|
|
602
|
+
* the unified principal methods. It is the ONLY mechanism for bypassing
|
|
603
|
+
* surface-level permission checks — there is no other bypass in the engine.
|
|
604
|
+
*
|
|
605
|
+
* @param ctx - Request context with resolved principal
|
|
606
|
+
* @returns boolean — true if principal.roles includes 'system_admin'
|
|
607
|
+
* @throws never
|
|
608
|
+
* @inputSpec ctx.principal: Principal — principal.roles: string[]
|
|
609
|
+
* @outputSpec boolean — false if principal is null, anonymous, or has no roles
|
|
610
|
+
* @sideEffects none
|
|
611
|
+
* @calledBy canAccessRecord, sanitizeRecordData, validateUpdatePermissions,
|
|
612
|
+
* canAccessConfigObject, sanitizeConfigObject, sanitizeSystemMetadata,
|
|
613
|
+
* canPrincipalAccessRecord
|
|
614
|
+
* @testUnit tests/unit/permissions.test.ts — 'isSystemAdmin' describe block
|
|
615
|
+
*/
|
|
616
|
+
isSystemAdmin(ctx: RequestContext): boolean {
|
|
617
|
+
return ctx.principal?.roles?.includes('system_admin') || false
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── PUBLIC SURFACE ROUTER METHODS ──────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Checks whether the principal in `ctx` may perform `action` on `record`.
|
|
624
|
+
*
|
|
625
|
+
* Routes to the correct surface handler based on `record`'s table name:
|
|
626
|
+
* - second surface tables → `canAccessConfigObject`
|
|
627
|
+
* - third surface tables → `canAccessSystemMetadata`
|
|
628
|
+
* - everything else (first surface) → `canAccessFirstSurfaceRecord`
|
|
629
|
+
*
|
|
630
|
+
* system_admin always returns true before surface routing.
|
|
631
|
+
*
|
|
632
|
+
* @param ctx - Request context with resolved principal
|
|
633
|
+
* @param record - The record being accessed (used for surface detection only)
|
|
634
|
+
* @param action - CRUD action being attempted
|
|
635
|
+
* @returns Promise<boolean> — true if access is allowed
|
|
636
|
+
* @throws never — all surface handlers catch errors and return false
|
|
637
|
+
* @inputSpec ctx.principal: Principal — must be resolved
|
|
638
|
+
* @inputSpec record: object — used for table_name/type/item_type extraction
|
|
639
|
+
* @inputSpec action: 'create' | 'read' | 'update' | 'delete'
|
|
640
|
+
* @outputSpec boolean — false for anonymous principals, missing records, unknown types
|
|
641
|
+
* @sideEffects DB read (first surface only): types and people tables
|
|
642
|
+
* @calledBy API handlers where explicit access gate is needed (rare — most use sanitize)
|
|
643
|
+
* @testUnit tests/unit/permissions.test.ts — 'canAccessRecord' describe block
|
|
644
|
+
*/
|
|
645
|
+
async canAccessRecord(
|
|
646
|
+
ctx: RequestContext,
|
|
647
|
+
record: any,
|
|
648
|
+
action: 'create' | 'read' | 'update' | 'delete'
|
|
649
|
+
): Promise<boolean> {
|
|
650
|
+
// System admin bypasses all checks
|
|
651
|
+
if (this.isSystemAdmin(ctx)) {
|
|
652
|
+
return true
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Extract table name to determine surface
|
|
656
|
+
const tableName = this.extractTableName(record)
|
|
657
|
+
const surface = this.detectSurface(tableName)
|
|
658
|
+
|
|
659
|
+
// Route to appropriate surface logic
|
|
660
|
+
switch (surface) {
|
|
661
|
+
case 'second':
|
|
662
|
+
return this.canAccessConfigObject(ctx, action)
|
|
663
|
+
|
|
664
|
+
case 'third':
|
|
665
|
+
return this.canAccessSystemMetadata(ctx, record, action)
|
|
666
|
+
|
|
667
|
+
case 'first':
|
|
668
|
+
default:
|
|
669
|
+
return this.canAccessFirstSurfaceRecord(ctx, record, action)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Access check for first-surface (runtime data) records.
|
|
675
|
+
*
|
|
676
|
+
* Delegates to `resolveFirstSurfacePermissions` to evaluate the design_schema
|
|
677
|
+
* permission model. For 'own' access level, additionally checks record ownership
|
|
678
|
+
* via `created_by === principal.id`.
|
|
679
|
+
*
|
|
680
|
+
* Returns false for anonymous principals and any principal without a valid accountId.
|
|
681
|
+
*
|
|
682
|
+
* @param ctx - Request context
|
|
683
|
+
* @param record - The first-surface record being accessed
|
|
684
|
+
* @param action - CRUD action
|
|
685
|
+
* @returns Promise<boolean>
|
|
686
|
+
* @throws never
|
|
687
|
+
* @inputSpec ctx.principal: not anonymous, ctx.accountId: non-empty string
|
|
688
|
+
* @inputSpec record: must have account_id or item_type/type for schema resolution
|
|
689
|
+
* @outputSpec boolean — false for anonymous, missing schema, insufficient permissions
|
|
690
|
+
* @sideEffects DB read: types and people tables (via resolveFirstSurfacePermissions)
|
|
691
|
+
* @calledBy canAccessRecord (surface='first'), canPrincipalAccessRecord (human branch)
|
|
692
|
+
*/
|
|
693
|
+
private async canAccessFirstSurfaceRecord(
|
|
694
|
+
ctx: RequestContext,
|
|
695
|
+
record: any,
|
|
696
|
+
action: 'create' | 'read' | 'update' | 'delete'
|
|
697
|
+
): Promise<boolean> {
|
|
698
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous' || !ctx.accountId) {
|
|
699
|
+
return false
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// For create operations, check if user can create in this account
|
|
703
|
+
if (action === 'create') {
|
|
704
|
+
const perms = await this.resolveFirstSurfacePermissions(
|
|
705
|
+
ctx.principal.id,
|
|
706
|
+
ctx.accountId,
|
|
707
|
+
record.item_type || record.type || 'unknown',
|
|
708
|
+
'create'
|
|
709
|
+
)
|
|
710
|
+
return perms.canCreate
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// For read/update/delete, check record ownership and permissions
|
|
714
|
+
const perms = await this.resolveFirstSurfacePermissions(
|
|
715
|
+
ctx.principal.id,
|
|
716
|
+
record.account_id || ctx.accountId,
|
|
717
|
+
record.item_type || record.type || 'unknown',
|
|
718
|
+
action
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
// Check record-level permission
|
|
722
|
+
const canPerformAction =
|
|
723
|
+
(action === 'read' && perms.canRead) ||
|
|
724
|
+
(action === 'update' && perms.canUpdate) ||
|
|
725
|
+
(action === 'delete' && perms.canDelete)
|
|
726
|
+
|
|
727
|
+
if (!canPerformAction) {
|
|
728
|
+
return false
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// For 'own' access level, check if user owns the record
|
|
732
|
+
const userRoles = ctx.principal?.roles || []
|
|
733
|
+
const hasOwnAccess = userRoles.some(role => {
|
|
734
|
+
const rolePerms = (record.type_schema?.record_permissions || {})[role]
|
|
735
|
+
return rolePerms?.read === 'own' || rolePerms?.update === 'own'
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
if (hasOwnAccess && record.created_by !== ctx.principal?.id) {
|
|
739
|
+
return false
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return true
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Strips and formats a record's fields based on the principal's permissions.
|
|
747
|
+
*
|
|
748
|
+
* This is the primary output filter called by every API handler before returning
|
|
749
|
+
* data to the client. Routes to the correct surface handler, which applies
|
|
750
|
+
* field-level filtering from the record's stamped `design_schema`.
|
|
751
|
+
*
|
|
752
|
+
* system_admin receives the full record unchanged.
|
|
753
|
+
*
|
|
754
|
+
* For first-surface records with missing `design_schema` or no `record_permissions`,
|
|
755
|
+
* returns `{ id }` only — explicit deny, not a pass-through.
|
|
756
|
+
*
|
|
757
|
+
* @param ctx - Request context with resolved principal
|
|
758
|
+
* @param record - The record to sanitize (should be the raw DB row)
|
|
759
|
+
* @param typeSlug - Type slug used to classify the surface and look up schema
|
|
760
|
+
* if not already stamped on the record. Optional for second/third surfaces.
|
|
761
|
+
* @returns Promise<object> — sanitized record safe to return to the client
|
|
762
|
+
* @throws never
|
|
763
|
+
* @inputSpec ctx.principal: Principal — resolved, may be anonymous
|
|
764
|
+
* @inputSpec record: object — raw DB row, must have id at minimum
|
|
765
|
+
* @inputSpec typeSlug: string | undefined — slug of the type (e.g. 'item', 'account')
|
|
766
|
+
* @outputSpec object — filtered record; field set depends on principal's role permissions
|
|
767
|
+
* @outputSpec system_admin: full record unchanged
|
|
768
|
+
* @outputSpec unauthenticated: { id, created_at, updated_at } only
|
|
769
|
+
* @outputSpec first surface, no schema: { id } only
|
|
770
|
+
* @sideEffects DB read (first surface): types and people tables via resolveFirstSurface
|
|
771
|
+
* @calledBy All 19 API handlers — this is the most-called method in the engine
|
|
772
|
+
* @calls sanitizeFirstSurfaceRecordData | sanitizeConfigObject | sanitizeSystemMetadata
|
|
773
|
+
* @testUnit tests/unit/permissions.test.ts — 'sanitizeRecordData' describe block
|
|
774
|
+
* @testIntegration tests/integration/admin-data-accounts.test.ts
|
|
775
|
+
*
|
|
776
|
+
* @example API handler usage
|
|
777
|
+
* ```ts
|
|
778
|
+
* const sanitized = await sanitizeRecordData(ctx, record, 'item')
|
|
779
|
+
* return { data: sanitized }
|
|
780
|
+
* ```
|
|
781
|
+
*
|
|
782
|
+
* @example Import usage (v2-custom/)
|
|
783
|
+
* ```ts
|
|
784
|
+
* import { sanitizeRecordData } from '../_shared/index'
|
|
785
|
+
* const safe = await sanitizeRecordData(ctx, rawRecord, 'ticket')
|
|
786
|
+
* ```
|
|
787
|
+
*/
|
|
788
|
+
async sanitizeRecordData(
|
|
789
|
+
ctx: RequestContext,
|
|
790
|
+
record: any,
|
|
791
|
+
typeSlug?: string
|
|
792
|
+
): Promise<any> {
|
|
793
|
+
// System admin sees everything
|
|
794
|
+
if (this.isSystemAdmin(ctx)) {
|
|
795
|
+
return record
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Extract table name to determine surface
|
|
799
|
+
const tableName = this.extractTableName(record, typeSlug)
|
|
800
|
+
const surface = this.detectSurface(tableName)
|
|
801
|
+
|
|
802
|
+
// Route to appropriate surface logic
|
|
803
|
+
switch (surface) {
|
|
804
|
+
case 'second':
|
|
805
|
+
return this.sanitizeConfigObject(ctx, record)
|
|
806
|
+
|
|
807
|
+
case 'third':
|
|
808
|
+
return this.sanitizeSystemMetadata(ctx, record)
|
|
809
|
+
|
|
810
|
+
case 'first':
|
|
811
|
+
default:
|
|
812
|
+
return this.sanitizeFirstSurfaceRecordData(ctx, record, typeSlug || '')
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Field-level filter and formatter for first-surface (runtime data) records.
|
|
818
|
+
*
|
|
819
|
+
* Steps:
|
|
820
|
+
* 1. Return minimal stub for anonymous principals
|
|
821
|
+
* 2. Check `record.design_schema.record_permissions` — deny if missing
|
|
822
|
+
* 3. Resolve permissions via `resolveFirstSurfacePermissions`
|
|
823
|
+
* 4. Return minimal stub if `!perms.canRead`
|
|
824
|
+
* 5. For each field in `record.data`, include only if `fieldPerms.read === true`
|
|
825
|
+
* 6. Apply `formatFieldData` from schema-utils if validation_schema specifies a data_type
|
|
826
|
+
* 7. Strip `record.metadata` (legacy field, migrated to `data`)
|
|
827
|
+
*
|
|
828
|
+
* @param ctx - Request context
|
|
829
|
+
* @param record - First-surface DB row with design_schema and data fields
|
|
830
|
+
* @param typeSlug - Type slug for schema lookup
|
|
831
|
+
* @returns Promise<object> — filtered and formatted record
|
|
832
|
+
* @throws never
|
|
833
|
+
* @inputSpec record.design_schema: object with record_permissions — deny if missing
|
|
834
|
+
* @inputSpec record.data: object — JSONB data fields; only permitted fields returned
|
|
835
|
+
* @outputSpec object — sanitized record matching the principal's field permissions
|
|
836
|
+
* @sideEffects DB read: types and people via resolveFirstSurfacePermissions
|
|
837
|
+
* @calledBy sanitizeRecordData (surface='first')
|
|
838
|
+
* @calls resolveFirstSurfacePermissions, schema-utils.formatFieldData
|
|
839
|
+
*/
|
|
840
|
+
private async sanitizeFirstSurfaceRecordData(
|
|
841
|
+
ctx: RequestContext,
|
|
842
|
+
record: any,
|
|
843
|
+
typeSlug: string
|
|
844
|
+
): Promise<any> {
|
|
845
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous' || !ctx.accountId) {
|
|
846
|
+
// Return minimal data for unauthenticated users
|
|
847
|
+
return {
|
|
848
|
+
id: record.id,
|
|
849
|
+
created_at: record.created_at,
|
|
850
|
+
updated_at: record.updated_at
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Special case for apps table - return essential routing fields
|
|
855
|
+
if (typeSlug === 'app') {
|
|
856
|
+
console.log('Processing app record, returning routing fields')
|
|
857
|
+
return {
|
|
858
|
+
id: record.id,
|
|
859
|
+
slug: record.slug,
|
|
860
|
+
name: record.name,
|
|
861
|
+
description: record.description,
|
|
862
|
+
route_prefix: record.route_prefix,
|
|
863
|
+
min_role: record.min_role,
|
|
864
|
+
is_active: record.is_active,
|
|
865
|
+
is_system: record.is_system,
|
|
866
|
+
renderer: record.renderer,
|
|
867
|
+
created_at: record.created_at,
|
|
868
|
+
updated_at: record.updated_at
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Use record's design_schema stamped at creation time.
|
|
873
|
+
// No schema or missing record_permissions = deny. RLS controls row access;
|
|
874
|
+
// design_schema controls what the principal can do. No permissions granted = none given.
|
|
875
|
+
const designSchema = record.design_schema
|
|
876
|
+
if (!designSchema || !designSchema.record_permissions) {
|
|
877
|
+
return { id: record.id }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const perms = await this.resolveFirstSurfacePermissions(
|
|
881
|
+
ctx.principal.id,
|
|
882
|
+
record.account_id || ctx.accountId,
|
|
883
|
+
typeSlug,
|
|
884
|
+
'read',
|
|
885
|
+
designSchema
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
if (!perms.canRead) {
|
|
889
|
+
// Return minimal data if no read access
|
|
890
|
+
return {
|
|
891
|
+
id: record.id,
|
|
892
|
+
created_at: record.created_at,
|
|
893
|
+
updated_at: record.updated_at
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Clone record to avoid mutation
|
|
898
|
+
const sanitized = { ...record }
|
|
899
|
+
|
|
900
|
+
// Filter and format data fields based on permissions
|
|
901
|
+
if (sanitized.data && typeof sanitized.data === 'object') {
|
|
902
|
+
const filteredData: any = {}
|
|
903
|
+
|
|
904
|
+
for (const [fieldName, fieldValue] of Object.entries(sanitized.data)) {
|
|
905
|
+
const fieldPerms = perms.fieldPermissions[fieldName]
|
|
906
|
+
if (fieldPerms && fieldPerms.read) {
|
|
907
|
+
// Apply data formatting using validation schema
|
|
908
|
+
const validationSchema = record.validation_schema || {}
|
|
909
|
+
const fieldValidation = validationSchema.fields?.[fieldName]
|
|
910
|
+
|
|
911
|
+
if (fieldValidation) {
|
|
912
|
+
// Import formatFieldData function
|
|
913
|
+
const { formatFieldData } = await import('./schema-utils')
|
|
914
|
+
filteredData[fieldName] = formatFieldData(fieldValue, fieldValidation.data_type, {
|
|
915
|
+
currency_code: fieldValidation.currency_code
|
|
916
|
+
})
|
|
917
|
+
} else {
|
|
918
|
+
filteredData[fieldName] = fieldValue
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
sanitized.data = filteredData
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Remove metadata field if it exists (should be migrated to data)
|
|
927
|
+
if (sanitized.metadata) {
|
|
928
|
+
delete sanitized.metadata
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return sanitized
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Validates that the principal has write permission for every field in `updateData`,
|
|
936
|
+
* and sanitizes the data using the validation schema before returning it.
|
|
937
|
+
*
|
|
938
|
+
* Routes to the correct surface handler. system_admin bypasses all checks and
|
|
939
|
+
* receives `updateData` unchanged (with `sanitizedData` set to `updateData`).
|
|
940
|
+
*
|
|
941
|
+
* For first-surface records:
|
|
942
|
+
* - Each field in `updateData.data` must have `fieldPerms.write === true`
|
|
943
|
+
* - Fields are sanitized via `sanitizeFieldData` from schema-utils
|
|
944
|
+
* - Returns `{ valid: false, error }` on the first denied or invalid field
|
|
945
|
+
*
|
|
946
|
+
* @param ctx - Request context with resolved principal
|
|
947
|
+
* @param updateData - The payload being written (may contain `data` and/or `metadata`)
|
|
948
|
+
* @param existingRecord - The current DB row (used for schema + account_id resolution)
|
|
949
|
+
* @param typeSlug - Type slug for surface classification and schema lookup
|
|
950
|
+
* @returns Promise<{ valid: boolean, error?: string, sanitizedData?: any }>
|
|
951
|
+
* @throws never
|
|
952
|
+
* @inputSpec ctx.principal: Principal — resolved, non-anonymous required for first surface
|
|
953
|
+
* @inputSpec updateData: object — payload with data: {} and/or metadata: {}
|
|
954
|
+
* @inputSpec existingRecord: object — must have design_schema, account_id
|
|
955
|
+
* @outputSpec valid: boolean — false on first permission or validation failure
|
|
956
|
+
* @outputSpec error: string | undefined — field name + reason when !valid
|
|
957
|
+
* @outputSpec sanitizedData: object | undefined — cleaned payload when valid
|
|
958
|
+
* @sideEffects DB read (first surface): types and people via resolveFirstSurfacePermissions
|
|
959
|
+
* @calledBy admin-data.ts (update handler), and any handler that accepts user writes
|
|
960
|
+
* @calls validateFirstSurfaceUpdatePermissions | validateConfigObjectPermissions |
|
|
961
|
+
* validateSystemMetadataPermissions
|
|
962
|
+
* @testUnit tests/unit/permissions.test.ts — 'validateUpdatePermissions' describe block
|
|
963
|
+
*
|
|
964
|
+
* @example API handler usage
|
|
965
|
+
* ```ts
|
|
966
|
+
* const { valid, error, sanitizedData } = await validateUpdatePermissions(
|
|
967
|
+
* ctx, body, existingRecord, 'item'
|
|
968
|
+
* )
|
|
969
|
+
* if (!valid) return { error }
|
|
970
|
+
* await ctx.db.from('items').update(sanitizedData).eq('id', id)
|
|
971
|
+
* ```
|
|
972
|
+
*/
|
|
973
|
+
async validateUpdatePermissions(
|
|
974
|
+
ctx: RequestContext,
|
|
975
|
+
updateData: any,
|
|
976
|
+
existingRecord: any,
|
|
977
|
+
typeSlug?: string
|
|
978
|
+
): Promise<{ valid: boolean; error?: string }> {
|
|
979
|
+
// System admin can update anything — pass data through unsanitized
|
|
980
|
+
if (this.isSystemAdmin(ctx)) {
|
|
981
|
+
return { valid: true, sanitizedData: updateData } as any
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Extract table name to determine surface
|
|
985
|
+
const tableName = this.extractTableName(existingRecord, typeSlug)
|
|
986
|
+
const surface = this.detectSurface(tableName)
|
|
987
|
+
|
|
988
|
+
// Route to appropriate surface logic
|
|
989
|
+
switch (surface) {
|
|
990
|
+
case 'second':
|
|
991
|
+
return this.validateConfigObjectPermissions(ctx, 'update')
|
|
992
|
+
|
|
993
|
+
case 'third':
|
|
994
|
+
return this.validateSystemMetadataPermissions(ctx, existingRecord, 'update')
|
|
995
|
+
|
|
996
|
+
case 'first':
|
|
997
|
+
default:
|
|
998
|
+
return this.validateFirstSurfaceUpdatePermissions(ctx, updateData, existingRecord, typeSlug || '')
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Field-level write validation and sanitization for first-surface update payloads.
|
|
1004
|
+
*
|
|
1005
|
+
* Checks every field in `updateData.data` (and legacy `updateData.metadata`) against
|
|
1006
|
+
* the principal's write permissions. Sanitizes each permitted field through
|
|
1007
|
+
* `sanitizeFieldData` for type coercion and constraint validation. Returns on
|
|
1008
|
+
* the first denied or invalid field — does not accumulate errors.
|
|
1009
|
+
*
|
|
1010
|
+
* @param ctx - Request context
|
|
1011
|
+
* @param updateData - Incoming update payload
|
|
1012
|
+
* @param existingRecord - Existing DB row with design_schema stamped at creation
|
|
1013
|
+
* @param typeSlug - Type slug for schema lookup
|
|
1014
|
+
* @returns Promise<{ valid: boolean, error?: string, sanitizedData?: any }>
|
|
1015
|
+
* @throws never
|
|
1016
|
+
* @inputSpec existingRecord.design_schema.record_permissions — deny if missing
|
|
1017
|
+
* @inputSpec updateData.data: object — all fields must have fieldPerms.write=true
|
|
1018
|
+
* @outputSpec sanitizedData: object — only present when valid=true
|
|
1019
|
+
* @sideEffects DB read: types and people via resolveFirstSurfacePermissions
|
|
1020
|
+
* @calledBy validateUpdatePermissions (surface='first')
|
|
1021
|
+
* @calls resolveFirstSurfacePermissions, schema-utils.sanitizeFieldData
|
|
1022
|
+
*/
|
|
1023
|
+
private async validateFirstSurfaceUpdatePermissions(
|
|
1024
|
+
ctx: RequestContext,
|
|
1025
|
+
updateData: any,
|
|
1026
|
+
existingRecord: any,
|
|
1027
|
+
typeSlug: string
|
|
1028
|
+
): Promise<{ valid: boolean; error?: string; sanitizedData?: any }> {
|
|
1029
|
+
if (!ctx.principal || ctx.principal.id === 'anonymous' || !ctx.accountId) {
|
|
1030
|
+
return { valid: false, error: 'Authentication required' }
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Use record's design_schema stamped at creation time.
|
|
1034
|
+
// No schema or missing record_permissions = deny. RLS controls row access;
|
|
1035
|
+
// design_schema controls what the principal can do. No permissions granted = none given.
|
|
1036
|
+
const designSchema = existingRecord.design_schema
|
|
1037
|
+
if (!designSchema || !designSchema.record_permissions) {
|
|
1038
|
+
return { valid: false, error: 'No permissions defined on this record type' }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const perms = await this.resolveFirstSurfacePermissions(
|
|
1042
|
+
ctx.principal.id,
|
|
1043
|
+
existingRecord.account_id || ctx.accountId,
|
|
1044
|
+
typeSlug,
|
|
1045
|
+
'update',
|
|
1046
|
+
designSchema
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
if (!perms.canUpdate) {
|
|
1050
|
+
return { valid: false, error: 'Insufficient permissions to update this record' }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Check field-level permissions and sanitize data
|
|
1054
|
+
const sanitizedData: any = {}
|
|
1055
|
+
const validationSchema = existingRecord.validation_schema || {}
|
|
1056
|
+
|
|
1057
|
+
// Process data fields
|
|
1058
|
+
if (updateData.data && typeof updateData.data === 'object') {
|
|
1059
|
+
sanitizedData.data = {}
|
|
1060
|
+
|
|
1061
|
+
for (const [fieldName, fieldValue] of Object.entries(updateData.data)) {
|
|
1062
|
+
const fieldPerms = perms.fieldPermissions[fieldName]
|
|
1063
|
+
if (!fieldPerms || !fieldPerms.write) {
|
|
1064
|
+
return { valid: false, error: `Insufficient permissions to update field '${fieldName}'` }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Apply data sanitization using validation schema
|
|
1068
|
+
const fieldValidation = validationSchema.fields?.[fieldName]
|
|
1069
|
+
|
|
1070
|
+
if (fieldValidation) {
|
|
1071
|
+
// Import sanitizeFieldData function
|
|
1072
|
+
const { sanitizeFieldData } = await import('./schema-utils')
|
|
1073
|
+
try {
|
|
1074
|
+
sanitizedData.data[fieldName] = sanitizeFieldData(
|
|
1075
|
+
fieldValue,
|
|
1076
|
+
fieldValidation.data_type,
|
|
1077
|
+
fieldValidation
|
|
1078
|
+
)
|
|
1079
|
+
} catch (sanitizeError: any) {
|
|
1080
|
+
return { valid: false, error: `Field '${fieldName}' validation error: ${sanitizeError.message}` }
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
sanitizedData.data[fieldName] = fieldValue
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Process metadata fields (if still present during migration)
|
|
1089
|
+
if (updateData.metadata && typeof updateData.metadata === 'object') {
|
|
1090
|
+
sanitizedData.metadata = {}
|
|
1091
|
+
|
|
1092
|
+
for (const [fieldName, fieldValue] of Object.entries(updateData.metadata)) {
|
|
1093
|
+
const fieldPerms = perms.fieldPermissions[fieldName]
|
|
1094
|
+
if (!fieldPerms || !fieldPerms.write) {
|
|
1095
|
+
return { valid: false, error: `Insufficient permissions to update field '${fieldName}'` }
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Apply basic sanitization for legacy metadata
|
|
1099
|
+
sanitizedData.metadata[fieldName] = fieldValue
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Copy non-data/metadata fields through
|
|
1104
|
+
for (const [key, value] of Object.entries(updateData)) {
|
|
1105
|
+
if (key !== 'data' && key !== 'metadata') {
|
|
1106
|
+
sanitizedData[key] = value
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return { valid: true, sanitizedData }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ─── UNIFIED PRINCIPAL METHODS ───────────────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Unified permission check for all principal types (human, machine, cron, trigger).
|
|
1117
|
+
*
|
|
1118
|
+
* This is the preferred method when you have a `Principal` directly rather than
|
|
1119
|
+
* a full `RequestContext`. Used by the Unified Principal Architecture to check
|
|
1120
|
+
* access without constructing a fake context.
|
|
1121
|
+
*
|
|
1122
|
+
* Resolution:
|
|
1123
|
+
* 1. system_admin (human with 'system_admin' role) → always true
|
|
1124
|
+
* 2. machine principal → scope check via `checkMachineScope`
|
|
1125
|
+
* 3. human principal with accountId → `canAccessFirstSurfaceRecord` (constructs minimal ctx)
|
|
1126
|
+
* 4. all others → false
|
|
1127
|
+
*
|
|
1128
|
+
* @param principal - The fully resolved Principal from `resolvePrincipal()`
|
|
1129
|
+
* @param record - The record being accessed; must include account_id and type for scope matching
|
|
1130
|
+
* @param action - CRUD action being attempted
|
|
1131
|
+
* @returns Promise<boolean> — true if the principal may perform the action
|
|
1132
|
+
* @throws never
|
|
1133
|
+
* @inputSpec principal: Principal — must be resolved (not ANONYMOUS_PRINCIPAL for useful results)
|
|
1134
|
+
* @inputSpec record.account_id: string — required for human principals
|
|
1135
|
+
* @inputSpec record.type: string | undefined — used for machine scope matching
|
|
1136
|
+
* @outputSpec boolean
|
|
1137
|
+
* @sideEffects DB read (human principal): types and people tables
|
|
1138
|
+
* @calledBy Handlers that receive a Principal directly (e.g. CLI, import callers)
|
|
1139
|
+
* @calls checkMachineScope, canAccessFirstSurfaceRecord
|
|
1140
|
+
* @testUnit tests/unit/permissions.test.ts — 'canPrincipalAccessRecord' describe block
|
|
1141
|
+
*
|
|
1142
|
+
* @example Import usage (v2-custom/)
|
|
1143
|
+
* ```ts
|
|
1144
|
+
* import { PermissionEngine } from '../_shared/index'
|
|
1145
|
+
* const allowed = await PermissionEngine.canPrincipalAccessRecord(
|
|
1146
|
+
* principal, { account_id: accountId, type: 'item' }, 'create'
|
|
1147
|
+
* )
|
|
1148
|
+
* ```
|
|
1149
|
+
*
|
|
1150
|
+
* @example CLI usage
|
|
1151
|
+
* ```bash
|
|
1152
|
+
* # Access checks happen automatically when CLI constructs CoreContext
|
|
1153
|
+
* spine items create --data '{"title":"Test"}'
|
|
1154
|
+
* ```
|
|
1155
|
+
*/
|
|
1156
|
+
async canPrincipalAccessRecord(
|
|
1157
|
+
principal: Principal,
|
|
1158
|
+
record: { account_id: string; type?: string; [key: string]: any },
|
|
1159
|
+
action: 'create' | 'read' | 'update' | 'delete'
|
|
1160
|
+
): Promise<boolean> {
|
|
1161
|
+
// System admin bypass
|
|
1162
|
+
if (principal.type === 'human' && principal.roles?.includes('system_admin')) {
|
|
1163
|
+
return true
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Machine scope check
|
|
1167
|
+
if (principal.type === 'machine') {
|
|
1168
|
+
return this.checkMachineScope(principal, record, action)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Human: Use existing schema-driven permissions
|
|
1172
|
+
if (principal.type === 'human' && principal.accountId) {
|
|
1173
|
+
return this.canAccessFirstSurfaceRecord(
|
|
1174
|
+
{
|
|
1175
|
+
requestId: '',
|
|
1176
|
+
principal,
|
|
1177
|
+
db: null as any,
|
|
1178
|
+
accountId: principal.accountId,
|
|
1179
|
+
appId: null,
|
|
1180
|
+
query: {}
|
|
1181
|
+
} as any,
|
|
1182
|
+
record,
|
|
1183
|
+
action
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return false
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Evaluates whether a machine principal's scopes permit the requested action.
|
|
1192
|
+
*
|
|
1193
|
+
* Scope matching supports three patterns (evaluated in order):
|
|
1194
|
+
* 1. Exact match: `'items:read'` matches `'items:read'`
|
|
1195
|
+
* 2. Wildcard action: `'items:*'` matches any action on `items`
|
|
1196
|
+
* 3. Global wildcard: `'*:*'` matches any resource and any action
|
|
1197
|
+
*
|
|
1198
|
+
* The required scope is constructed as `<record.type>:<action>`. If `record.type`
|
|
1199
|
+
* is absent, `'resource'` is used as the resource name.
|
|
1200
|
+
*
|
|
1201
|
+
* @param principal - Machine principal (principal.type must be 'machine')
|
|
1202
|
+
* @param record - The record being accessed (record.type used as resource name)
|
|
1203
|
+
* @param action - The CRUD action string
|
|
1204
|
+
* @returns boolean — true if any scope in principal.scopes grants the action
|
|
1205
|
+
* @throws never
|
|
1206
|
+
* @inputSpec principal.type: 'machine' — returns false for non-machine principals
|
|
1207
|
+
* @inputSpec principal.scopes: string[] — list of granted scope strings
|
|
1208
|
+
* @inputSpec record.type: string | undefined — resource name portion of scope check
|
|
1209
|
+
* @outputSpec boolean
|
|
1210
|
+
* @sideEffects none
|
|
1211
|
+
* @calledBy canPrincipalAccessRecord (machine branch)
|
|
1212
|
+
* @testUnit tests/unit/permissions.test.ts — 'checkMachineScope' describe block
|
|
1213
|
+
*/
|
|
1214
|
+
private checkMachineScope(
|
|
1215
|
+
principal: Principal,
|
|
1216
|
+
record: any,
|
|
1217
|
+
action: string
|
|
1218
|
+
): boolean {
|
|
1219
|
+
if (principal.type !== 'machine') return false
|
|
1220
|
+
|
|
1221
|
+
const scopes = principal.scopes || []
|
|
1222
|
+
const requiredScope = `${record.type || 'resource'}:${action}`
|
|
1223
|
+
const [resource] = requiredScope.split(':')
|
|
1224
|
+
|
|
1225
|
+
// Exact match
|
|
1226
|
+
if (scopes.includes(requiredScope)) return true
|
|
1227
|
+
|
|
1228
|
+
// Wildcard resource match (e.g., "items:*" matches "items:read")
|
|
1229
|
+
if (scopes.includes(`${resource}:*`)) return true
|
|
1230
|
+
|
|
1231
|
+
// Global wildcard
|
|
1232
|
+
if (scopes.includes('*:*')) return true
|
|
1233
|
+
|
|
1234
|
+
return false
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Returns a structured summary of a principal's permission posture for use
|
|
1239
|
+
* in audit log entries.
|
|
1240
|
+
*
|
|
1241
|
+
* Does not perform any access check — purely descriptive. The returned object
|
|
1242
|
+
* is safe to serialize into the `metadata` column of the `logs` table.
|
|
1243
|
+
*
|
|
1244
|
+
* @param principal - The resolved principal to summarize
|
|
1245
|
+
* @returns object — summary safe for audit log serialization
|
|
1246
|
+
* @throws never
|
|
1247
|
+
* @inputSpec principal: Principal — any resolved principal including ANONYMOUS
|
|
1248
|
+
* @outputSpec { type, roles, is_system_admin } for human principals
|
|
1249
|
+
* @outputSpec { type, machine_type, scopes, is_internal } for machine principals
|
|
1250
|
+
* @outputSpec { type: 'unknown' } for all other types
|
|
1251
|
+
* @sideEffects none
|
|
1252
|
+
* @calledBy audit.ts (emitAudit), any handler that logs permission context
|
|
1253
|
+
* @testUnit tests/unit/permissions.test.ts — 'getPrincipalPermissionSummary' describe block
|
|
1254
|
+
*
|
|
1255
|
+
* @example
|
|
1256
|
+
* ```ts
|
|
1257
|
+
* await emitAudit(ctx, 'record.read', record.id, {
|
|
1258
|
+
* permissions: PermissionEngine.getPrincipalPermissionSummary(ctx.principal)
|
|
1259
|
+
* })
|
|
1260
|
+
* ```
|
|
1261
|
+
*/
|
|
1262
|
+
getPrincipalPermissionSummary(principal: Principal): object {
|
|
1263
|
+
if (principal.type === 'human') {
|
|
1264
|
+
return {
|
|
1265
|
+
type: 'human',
|
|
1266
|
+
roles: principal.roles || [],
|
|
1267
|
+
is_system_admin: principal.roles?.includes('system_admin') || false
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (principal.type === 'machine') {
|
|
1272
|
+
return {
|
|
1273
|
+
type: 'machine',
|
|
1274
|
+
machine_type: principal.machineType,
|
|
1275
|
+
scopes: principal.scopes || [],
|
|
1276
|
+
is_internal: principal.isInternal
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return { type: 'unknown' }
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ─── SINGLETON EXPORT ────────────────────────────────────────────────────────
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* The single shared PermissionEngine instance.
|
|
1288
|
+
*
|
|
1289
|
+
* This is the ONLY export that should be used for permission checks. Import this
|
|
1290
|
+
* directly or use the named legacy aliases below. Do not instantiate
|
|
1291
|
+
* `_PermissionEngineInternal` yourself.
|
|
1292
|
+
*
|
|
1293
|
+
* @stability stable
|
|
1294
|
+
* @audience both
|
|
1295
|
+
* @calledBy All 19 API handlers, tests, and custom code in v2-custom/
|
|
1296
|
+
*
|
|
1297
|
+
* @example API handler
|
|
1298
|
+
* ```ts
|
|
1299
|
+
* import { PermissionEngine } from './_shared/permissions'
|
|
1300
|
+
* const sanitized = await PermissionEngine.sanitizeRecordData(ctx, record, 'item')
|
|
1301
|
+
* ```
|
|
1302
|
+
*
|
|
1303
|
+
* @example Import usage (v2-custom/)
|
|
1304
|
+
* ```ts
|
|
1305
|
+
* import { PermissionEngine } from '../_shared/index'
|
|
1306
|
+
* const allowed = await PermissionEngine.canPrincipalAccessRecord(principal, record, 'read')
|
|
1307
|
+
* ```
|
|
1308
|
+
*/
|
|
1309
|
+
export const PermissionEngine: _PermissionEngineInternal = _PermissionEngineInternal.getInstance()
|
|
1310
|
+
|
|
1311
|
+
// ─── LEGACY EXPORTS ───────────────────────────────────────────────────────────
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Legacy named exports — bound methods on the singleton for backward compatibility.
|
|
1315
|
+
* Prefer importing `PermissionEngine` and calling methods on it directly.
|
|
1316
|
+
* These will be removed in a future version.
|
|
1317
|
+
*
|
|
1318
|
+
* @deprecated Use `PermissionEngine.<methodName>()` instead.
|
|
1319
|
+
* @stability internal
|
|
1320
|
+
*/
|
|
1321
|
+
export const resolveFirstSurfacePermissions = PermissionEngine.resolveFirstSurfacePermissions.bind(PermissionEngine)
|
|
1322
|
+
export const isSystemAdmin = PermissionEngine.isSystemAdmin.bind(PermissionEngine)
|
|
1323
|
+
export const canAccessRecord = PermissionEngine.canAccessRecord.bind(PermissionEngine)
|
|
1324
|
+
export const sanitizeRecordData = PermissionEngine.sanitizeRecordData.bind(PermissionEngine)
|
|
1325
|
+
export const validateUpdatePermissions = PermissionEngine.validateUpdatePermissions.bind(PermissionEngine)
|