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.
Files changed (385) hide show
  1. package/.framework/README.md +129 -0
  2. package/.framework/cli/bin.cjs +14 -0
  3. package/.framework/cli/commands/agents.ts +153 -0
  4. package/.framework/cli/commands/auth.ts +94 -0
  5. package/.framework/cli/commands/create-app.ts +185 -0
  6. package/.framework/cli/commands/dev.ts +295 -0
  7. package/.framework/cli/commands/doctor.ts +442 -0
  8. package/.framework/cli/commands/generate.ts +332 -0
  9. package/.framework/cli/commands/init.ts +272 -0
  10. package/.framework/cli/commands/install-app.ts +391 -0
  11. package/.framework/cli/commands/items.ts +253 -0
  12. package/.framework/cli/commands/migrations.ts +141 -0
  13. package/.framework/cli/commands/pipelines.ts +166 -0
  14. package/.framework/cli/commands/status.ts +197 -0
  15. package/.framework/cli/commands/system.ts +184 -0
  16. package/.framework/cli/commands/test.ts +227 -0
  17. package/.framework/cli/commands/uninstall-app.ts +166 -0
  18. package/.framework/cli/context.ts +268 -0
  19. package/.framework/cli/env-loader.ts +36 -0
  20. package/.framework/cli/index.ts +106 -0
  21. package/.framework/cli/welcome.cjs +45 -0
  22. package/.framework/docs/API.md +384 -0
  23. package/.framework/docs/STABILITY.md +52 -0
  24. package/.framework/docs/admin-routes.md +76 -0
  25. package/.framework/docs/api-docs-progress.md +38 -0
  26. package/.framework/docs/api-governance.md +146 -0
  27. package/.framework/docs/api-testing-results.md +212 -0
  28. package/.framework/docs/apis/admin-configs.md +567 -0
  29. package/.framework/docs/apis/admin-data.md +272 -0
  30. package/.framework/docs/apis/index.md +231 -0
  31. package/.framework/docs/apis/internal.md +295 -0
  32. package/.framework/docs/apis/runtime.md +537 -0
  33. package/.framework/docs/assembly-launch-guide.md +138 -0
  34. package/.framework/docs/audit-results.md +590 -0
  35. package/.framework/docs/authorization-model.md +170 -0
  36. package/.framework/docs/db-api-inventory.md +95 -0
  37. package/.framework/docs/examples/custom-app/README.md +77 -0
  38. package/.framework/docs/examples/custom-function/README.md +27 -0
  39. package/.framework/docs/examples/custom-function/handler.ts +48 -0
  40. package/.framework/docs/examples/custom-webhook/README.md +68 -0
  41. package/.framework/docs/gap-remediation-backlog.md +103 -0
  42. package/.framework/docs/guides/cli-guide.md +224 -0
  43. package/.framework/docs/guides/getting-started.md +103 -0
  44. package/.framework/docs/guides/import-guide.md +193 -0
  45. package/.framework/docs/guides/testing-guide.md +229 -0
  46. package/.framework/docs/permission-examples.md +326 -0
  47. package/.framework/docs/ui-adoption-verification.md +111 -0
  48. package/.framework/docs/ui-api-coverage.md +84 -0
  49. package/.framework/docs/v2-compatibility-audit.md +228 -0
  50. package/.framework/functions/.gitkeep +1 -0
  51. package/.framework/functions/_shared/agent-runner.ts +1097 -0
  52. package/.framework/functions/_shared/app-manifest.ts +184 -0
  53. package/.framework/functions/_shared/audit.ts +150 -0
  54. package/.framework/functions/_shared/db.ts +174 -0
  55. package/.framework/functions/_shared/index.ts +382 -0
  56. package/.framework/functions/_shared/middleware.ts +490 -0
  57. package/.framework/functions/_shared/permissions.ts +1325 -0
  58. package/.framework/functions/_shared/pipeline-runner.ts +731 -0
  59. package/.framework/functions/_shared/principal.ts +760 -0
  60. package/.framework/functions/_shared/schema-utils.ts +967 -0
  61. package/.framework/functions/_shared/testing.ts +258 -0
  62. package/.framework/functions/_shared/trigger-engine.ts +425 -0
  63. package/.framework/functions/_shared/webhook-registration.ts +168 -0
  64. package/.framework/functions/_shared/webhook-registry.ts +129 -0
  65. package/.framework/functions/account-nodes.ts +111 -0
  66. package/.framework/functions/admin-data.ts +606 -0
  67. package/.framework/functions/ai-agents.ts +323 -0
  68. package/.framework/functions/api-keys.ts +376 -0
  69. package/.framework/functions/apps.ts +483 -0
  70. package/.framework/functions/auth.ts +196 -0
  71. package/.framework/functions/debug-auth.ts +107 -0
  72. package/.framework/functions/embeddings.ts +556 -0
  73. package/.framework/functions/integration-routes.ts +523 -0
  74. package/.framework/functions/integrations.ts +319 -0
  75. package/.framework/functions/item-progress.ts +272 -0
  76. package/.framework/functions/logs.ts +438 -0
  77. package/.framework/functions/observability.ts +275 -0
  78. package/.framework/functions/pipeline-executions.ts +494 -0
  79. package/.framework/functions/pipelines.ts +485 -0
  80. package/.framework/functions/prompt-configs.ts +339 -0
  81. package/.framework/functions/roles.ts +387 -0
  82. package/.framework/functions/system-cron.ts +742 -0
  83. package/.framework/functions/system.ts +323 -0
  84. package/.framework/functions/tests.ts +119 -0
  85. package/.framework/functions/timers.ts +357 -0
  86. package/.framework/functions/triggers.ts +563 -0
  87. package/.framework/functions/types.ts +604 -0
  88. package/.framework/migrations/000_foundation.sql +1256 -0
  89. package/.framework/migrations/001_seed.sql +92 -0
  90. package/.framework/migrations/002_seed_constraints.sql +13 -0
  91. package/.framework/migrations/003_auth_user_trigger.sql +59 -0
  92. package/.framework/src/App.tsx +126 -0
  93. package/.framework/src/apps/admin/index.tsx +173 -0
  94. package/.framework/src/components/AppWrapper.tsx +56 -0
  95. package/.framework/src/components/CustomAppLoader.tsx +116 -0
  96. package/.framework/src/components/admin/AdminListPage.tsx +151 -0
  97. package/.framework/src/components/admin/AdminSidebar.tsx +166 -0
  98. package/.framework/src/components/admin/AdminStatsCard.tsx +62 -0
  99. package/.framework/src/components/admin/SortableTableHeader.tsx +42 -0
  100. package/.framework/src/components/app-shell/GenericAppShell.tsx +181 -0
  101. package/.framework/src/components/app-shell/GenericDetailPage.tsx +200 -0
  102. package/.framework/src/components/app-shell/GenericListPage.tsx +116 -0
  103. package/.framework/src/components/app-sidebar.tsx +228 -0
  104. package/.framework/src/components/auth/ProtectedRoute.tsx +88 -0
  105. package/.framework/src/components/layout/AppShell.tsx +91 -0
  106. package/.framework/src/components/layout/Header.tsx +88 -0
  107. package/.framework/src/components/layout/Layout.tsx +95 -0
  108. package/.framework/src/components/layout/Sidebar.tsx +329 -0
  109. package/.framework/src/components/runtime/DataDetailHeader.tsx +77 -0
  110. package/.framework/src/components/runtime/DataDetailPage.tsx +171 -0
  111. package/.framework/src/components/runtime/DataFilters.tsx +91 -0
  112. package/.framework/src/components/runtime/DataHeader.tsx +68 -0
  113. package/.framework/src/components/runtime/DataListPage.tsx +124 -0
  114. package/.framework/src/components/runtime/DataStats.tsx +70 -0
  115. package/.framework/src/components/runtime/DataTable.tsx +174 -0
  116. package/.framework/src/components/runtime/SchemaDetailForm.tsx +134 -0
  117. package/.framework/src/components/runtime/index.ts +18 -0
  118. package/.framework/src/components/search-form.tsx +29 -0
  119. package/.framework/src/components/shared/AgentView.tsx +213 -0
  120. package/.framework/src/components/shared/FieldRenderer.tsx +478 -0
  121. package/.framework/src/components/shared/SchemaFields.tsx +226 -0
  122. package/.framework/src/components/ui/DataTable.tsx +343 -0
  123. package/.framework/src/components/ui/Form.tsx +281 -0
  124. package/.framework/src/components/ui/ItemCard.tsx +296 -0
  125. package/.framework/src/components/ui/ItemListView.tsx +308 -0
  126. package/.framework/src/components/ui/LoadingSpinner.tsx +52 -0
  127. package/.framework/src/components/ui/Modal.tsx +61 -0
  128. package/.framework/src/components/ui/RichTextEditor.tsx +210 -0
  129. package/.framework/src/components/ui/accordion.tsx +82 -0
  130. package/.framework/src/components/ui/alert-dialog.tsx +197 -0
  131. package/.framework/src/components/ui/alert.tsx +76 -0
  132. package/.framework/src/components/ui/aspect-ratio.tsx +11 -0
  133. package/.framework/src/components/ui/avatar.tsx +110 -0
  134. package/.framework/src/components/ui/badge.tsx +49 -0
  135. package/.framework/src/components/ui/breadcrumb.tsx +122 -0
  136. package/.framework/src/components/ui/button-group.tsx +83 -0
  137. package/.framework/src/components/ui/button.tsx +65 -0
  138. package/.framework/src/components/ui/calendar.tsx +222 -0
  139. package/.framework/src/components/ui/card.tsx +100 -0
  140. package/.framework/src/components/ui/carousel.tsx +240 -0
  141. package/.framework/src/components/ui/chart.tsx +373 -0
  142. package/.framework/src/components/ui/checkbox.tsx +31 -0
  143. package/.framework/src/components/ui/collapsible.tsx +33 -0
  144. package/.framework/src/components/ui/combobox.tsx +299 -0
  145. package/.framework/src/components/ui/command.tsx +193 -0
  146. package/.framework/src/components/ui/context-menu.tsx +261 -0
  147. package/.framework/src/components/ui/dialog.tsx +165 -0
  148. package/.framework/src/components/ui/direction.tsx +22 -0
  149. package/.framework/src/components/ui/drawer.tsx +132 -0
  150. package/.framework/src/components/ui/dropdown-menu.tsx +269 -0
  151. package/.framework/src/components/ui/empty.tsx +104 -0
  152. package/.framework/src/components/ui/field.tsx +238 -0
  153. package/.framework/src/components/ui/hover-card.tsx +42 -0
  154. package/.framework/src/components/ui/input-group.tsx +153 -0
  155. package/.framework/src/components/ui/input-otp.tsx +87 -0
  156. package/.framework/src/components/ui/input.tsx +19 -0
  157. package/.framework/src/components/ui/item.tsx +196 -0
  158. package/.framework/src/components/ui/kbd.tsx +26 -0
  159. package/.framework/src/components/ui/label.tsx +22 -0
  160. package/.framework/src/components/ui/menubar.tsx +277 -0
  161. package/.framework/src/components/ui/native-select.tsx +61 -0
  162. package/.framework/src/components/ui/navigation-menu.tsx +164 -0
  163. package/.framework/src/components/ui/pagination.tsx +129 -0
  164. package/.framework/src/components/ui/popover.tsx +87 -0
  165. package/.framework/src/components/ui/progress.tsx +31 -0
  166. package/.framework/src/components/ui/radio-group.tsx +42 -0
  167. package/.framework/src/components/ui/resizable.tsx +50 -0
  168. package/.framework/src/components/ui/scroll-area.tsx +53 -0
  169. package/.framework/src/components/ui/select.tsx +195 -0
  170. package/.framework/src/components/ui/separator.tsx +26 -0
  171. package/.framework/src/components/ui/sheet.tsx +145 -0
  172. package/.framework/src/components/ui/sidebar.tsx +706 -0
  173. package/.framework/src/components/ui/skeleton.tsx +13 -0
  174. package/.framework/src/components/ui/slider.tsx +59 -0
  175. package/.framework/src/components/ui/sonner.tsx +47 -0
  176. package/.framework/src/components/ui/spinner.tsx +10 -0
  177. package/.framework/src/components/ui/switch.tsx +33 -0
  178. package/.framework/src/components/ui/table-primitives.tsx +141 -0
  179. package/.framework/src/components/ui/table.tsx +114 -0
  180. package/.framework/src/components/ui/tabs.tsx +90 -0
  181. package/.framework/src/components/ui/textarea.tsx +18 -0
  182. package/.framework/src/components/ui/toggle-group.tsx +89 -0
  183. package/.framework/src/components/ui/toggle.tsx +45 -0
  184. package/.framework/src/components/ui/tooltip.tsx +57 -0
  185. package/.framework/src/contexts/AppContext.tsx +133 -0
  186. package/.framework/src/contexts/AuthContext.tsx +371 -0
  187. package/.framework/src/hooks/use-mobile.ts +19 -0
  188. package/.framework/src/hooks/useApi.ts +526 -0
  189. package/.framework/src/hooks/useApps.ts +114 -0
  190. package/.framework/src/hooks/useEntityList.ts +190 -0
  191. package/.framework/src/hooks/useEntityRecord.ts +308 -0
  192. package/.framework/src/hooks/useForm.ts +307 -0
  193. package/.framework/src/hooks/useListSchema.ts +264 -0
  194. package/.framework/src/hooks/useSchemaRecord.ts +223 -0
  195. package/.framework/src/index.css +128 -0
  196. package/.framework/src/lib/api.ts +156 -0
  197. package/.framework/src/lib/supabase.ts +94 -0
  198. package/.framework/src/lib/utils.ts +317 -0
  199. package/.framework/src/main.tsx +27 -0
  200. package/.framework/src/pages/DashboardPage.tsx +181 -0
  201. package/.framework/src/pages/NotFoundPage.tsx +39 -0
  202. package/.framework/src/pages/admin/AIAgentDetailPage.tsx +161 -0
  203. package/.framework/src/pages/admin/AIAgentsPage.tsx +318 -0
  204. package/.framework/src/pages/admin/APIKeyDetailPage.tsx +199 -0
  205. package/.framework/src/pages/admin/APIKeysPage.tsx +303 -0
  206. package/.framework/src/pages/admin/AlertsConfigPage.tsx +523 -0
  207. package/.framework/src/pages/admin/AppDetailPage.tsx +493 -0
  208. package/.framework/src/pages/admin/AppsPage.tsx +355 -0
  209. package/.framework/src/pages/admin/DesignedPage.tsx +491 -0
  210. package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +534 -0
  211. package/.framework/src/pages/admin/EmbeddingsPage.tsx +424 -0
  212. package/.framework/src/pages/admin/ExtendedShadcnTestPage.tsx +176 -0
  213. package/.framework/src/pages/admin/IncrementalShadcnTestPage.tsx +109 -0
  214. package/.framework/src/pages/admin/IntegratedDashboard.tsx +402 -0
  215. package/.framework/src/pages/admin/IntegrationDetailPage.tsx +187 -0
  216. package/.framework/src/pages/admin/IntegrationsPage.tsx +301 -0
  217. package/.framework/src/pages/admin/LogsPage.tsx +283 -0
  218. package/.framework/src/pages/admin/MinimalShadcnTestPage.tsx +85 -0
  219. package/.framework/src/pages/admin/ObservabilityDashboard.tsx +470 -0
  220. package/.framework/src/pages/admin/PipelineDetailPage.tsx +183 -0
  221. package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +279 -0
  222. package/.framework/src/pages/admin/PipelinesPage.tsx +390 -0
  223. package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +299 -0
  224. package/.framework/src/pages/admin/PromptConfigsPage.tsx +292 -0
  225. package/.framework/src/pages/admin/ProperlyDesignedPage.tsx +434 -0
  226. package/.framework/src/pages/admin/RoleDetailPage.tsx +273 -0
  227. package/.framework/src/pages/admin/RolesPage.tsx +292 -0
  228. package/.framework/src/pages/admin/SelectTestPage.tsx +61 -0
  229. package/.framework/src/pages/admin/ShadcnTestPage.tsx +588 -0
  230. package/.framework/src/pages/admin/SimpleDashboard.tsx +387 -0
  231. package/.framework/src/pages/admin/TestRunDetailPage.tsx +172 -0
  232. package/.framework/src/pages/admin/TestingDashboard.tsx +257 -0
  233. package/.framework/src/pages/admin/TimerDetailPage.tsx +151 -0
  234. package/.framework/src/pages/admin/TimersPage.tsx +376 -0
  235. package/.framework/src/pages/admin/TriggerDetailPage.tsx +149 -0
  236. package/.framework/src/pages/admin/TriggersPage.tsx +381 -0
  237. package/.framework/src/pages/admin/TypeDetailPage.tsx +694 -0
  238. package/.framework/src/pages/admin/TypesPage.tsx +295 -0
  239. package/.framework/src/pages/auth/LoginPage.tsx +188 -0
  240. package/.framework/src/pages/auth/RegisterPage.tsx +163 -0
  241. package/.framework/src/pages/spine-framework/APIPage.tsx +17 -0
  242. package/.framework/src/pages/spine-framework/CLIPage.tsx +25 -0
  243. package/.framework/src/types/auth.ts +125 -0
  244. package/.framework/src/types/types.ts +407 -0
  245. package/STRUCTURE.md +150 -0
  246. package/config/components.json +25 -0
  247. package/config/deno.lock +108 -0
  248. package/config/package-lock.json +17183 -0
  249. package/config/postcss.config.cjs +10 -0
  250. package/config/tailwind.config.cjs +78 -0
  251. package/config/tsconfig.build.json +32 -0
  252. package/config/tsconfig.cli.json +18 -0
  253. package/config/tsconfig.json +41 -0
  254. package/config/tsconfig.node.json +17 -0
  255. package/config/tsconfig.node.tsbuildinfo +1 -0
  256. package/config/tsconfig.tsbuildinfo +1 -0
  257. package/config/typedoc.json +16 -0
  258. package/config/vite.config.d.ts +2 -0
  259. package/config/vite.config.ts +72 -0
  260. package/dist/cli/commands/agents.d.ts +39 -0
  261. package/dist/cli/commands/agents.d.ts.map +1 -0
  262. package/dist/cli/commands/auth.d.ts +36 -0
  263. package/dist/cli/commands/auth.d.ts.map +1 -0
  264. package/dist/cli/commands/create-app.d.ts +23 -0
  265. package/dist/cli/commands/create-app.d.ts.map +1 -0
  266. package/dist/cli/commands/dev.d.ts +39 -0
  267. package/dist/cli/commands/dev.d.ts.map +1 -0
  268. package/dist/cli/commands/doctor.d.ts +42 -0
  269. package/dist/cli/commands/doctor.d.ts.map +1 -0
  270. package/dist/cli/commands/generate.d.ts +36 -0
  271. package/dist/cli/commands/generate.d.ts.map +1 -0
  272. package/dist/cli/commands/init.d.ts +30 -0
  273. package/dist/cli/commands/init.d.ts.map +1 -0
  274. package/dist/cli/commands/install-app.d.ts +30 -0
  275. package/dist/cli/commands/install-app.d.ts.map +1 -0
  276. package/dist/cli/commands/items.d.ts +45 -0
  277. package/dist/cli/commands/items.d.ts.map +1 -0
  278. package/dist/cli/commands/migrations.d.ts +41 -0
  279. package/dist/cli/commands/migrations.d.ts.map +1 -0
  280. package/dist/cli/commands/pipelines.d.ts +40 -0
  281. package/dist/cli/commands/pipelines.d.ts.map +1 -0
  282. package/dist/cli/commands/status.d.ts +23 -0
  283. package/dist/cli/commands/status.d.ts.map +1 -0
  284. package/dist/cli/commands/system.d.ts +29 -0
  285. package/dist/cli/commands/system.d.ts.map +1 -0
  286. package/dist/cli/commands/test.d.ts +46 -0
  287. package/dist/cli/commands/test.d.ts.map +1 -0
  288. package/dist/cli/commands/uninstall-app.d.ts +23 -0
  289. package/dist/cli/commands/uninstall-app.d.ts.map +1 -0
  290. package/dist/cli/context.d.ts +88 -0
  291. package/dist/cli/context.d.ts.map +1 -0
  292. package/dist/cli/env-loader.d.ts +14 -0
  293. package/dist/cli/env-loader.d.ts.map +1 -0
  294. package/dist/cli/index.d.ts +41 -0
  295. package/dist/cli/index.d.ts.map +1 -0
  296. package/dist/functions/_shared/agent-runner.d.ts +156 -0
  297. package/dist/functions/_shared/agent-runner.d.ts.map +1 -0
  298. package/dist/functions/_shared/app-manifest.d.ts +68 -0
  299. package/dist/functions/_shared/app-manifest.d.ts.map +1 -0
  300. package/dist/functions/_shared/audit.d.ts +91 -0
  301. package/dist/functions/_shared/audit.d.ts.map +1 -0
  302. package/dist/functions/_shared/db.d.ts +125 -0
  303. package/dist/functions/_shared/db.d.ts.map +1 -0
  304. package/dist/functions/_shared/index.d.ts +298 -0
  305. package/dist/functions/_shared/index.d.ts.map +1 -0
  306. package/dist/functions/_shared/middleware.d.ts +315 -0
  307. package/dist/functions/_shared/middleware.d.ts.map +1 -0
  308. package/dist/functions/_shared/permissions.d.ts +626 -0
  309. package/dist/functions/_shared/permissions.d.ts.map +1 -0
  310. package/dist/functions/_shared/pipeline-runner.d.ts +124 -0
  311. package/dist/functions/_shared/pipeline-runner.d.ts.map +1 -0
  312. package/dist/functions/_shared/principal.d.ts +284 -0
  313. package/dist/functions/_shared/principal.d.ts.map +1 -0
  314. package/dist/functions/_shared/schema-utils.d.ts +181 -0
  315. package/dist/functions/_shared/schema-utils.d.ts.map +1 -0
  316. package/dist/functions/_shared/testing.d.ts +172 -0
  317. package/dist/functions/_shared/testing.d.ts.map +1 -0
  318. package/dist/functions/_shared/trigger-engine.d.ts +140 -0
  319. package/dist/functions/_shared/trigger-engine.d.ts.map +1 -0
  320. package/dist/functions/_shared/webhook-registration.d.ts +81 -0
  321. package/dist/functions/_shared/webhook-registration.d.ts.map +1 -0
  322. package/dist/functions/_shared/webhook-registry.d.ts +57 -0
  323. package/dist/functions/_shared/webhook-registry.d.ts.map +1 -0
  324. package/dist/functions/account-nodes.d.ts +48 -0
  325. package/dist/functions/account-nodes.d.ts.map +1 -0
  326. package/dist/functions/admin-data.d.ts +178 -0
  327. package/dist/functions/admin-data.d.ts.map +1 -0
  328. package/dist/functions/ai-agents.d.ts +125 -0
  329. package/dist/functions/ai-agents.d.ts.map +1 -0
  330. package/dist/functions/api-keys.d.ts +140 -0
  331. package/dist/functions/api-keys.d.ts.map +1 -0
  332. package/dist/functions/apps.d.ts +163 -0
  333. package/dist/functions/apps.d.ts.map +1 -0
  334. package/dist/functions/auth.d.ts +74 -0
  335. package/dist/functions/auth.d.ts.map +1 -0
  336. package/dist/functions/debug-auth.d.ts +33 -0
  337. package/dist/functions/debug-auth.d.ts.map +1 -0
  338. package/dist/functions/embeddings.d.ts +205 -0
  339. package/dist/functions/embeddings.d.ts.map +1 -0
  340. package/dist/functions/integration-routes.d.ts +45 -0
  341. package/dist/functions/integration-routes.d.ts.map +1 -0
  342. package/dist/functions/integrations.d.ts +124 -0
  343. package/dist/functions/integrations.d.ts.map +1 -0
  344. package/dist/functions/item-progress.d.ts +41 -0
  345. package/dist/functions/item-progress.d.ts.map +1 -0
  346. package/dist/functions/logs.d.ts +162 -0
  347. package/dist/functions/logs.d.ts.map +1 -0
  348. package/dist/functions/observability.d.ts +123 -0
  349. package/dist/functions/observability.d.ts.map +1 -0
  350. package/dist/functions/pipeline-executions.d.ts +190 -0
  351. package/dist/functions/pipeline-executions.d.ts.map +1 -0
  352. package/dist/functions/pipelines.d.ts +171 -0
  353. package/dist/functions/pipelines.d.ts.map +1 -0
  354. package/dist/functions/prompt-configs.d.ts +125 -0
  355. package/dist/functions/prompt-configs.d.ts.map +1 -0
  356. package/dist/functions/roles.d.ts +118 -0
  357. package/dist/functions/roles.d.ts.map +1 -0
  358. package/dist/functions/system-cron.d.ts +65 -0
  359. package/dist/functions/system-cron.d.ts.map +1 -0
  360. package/dist/functions/system.d.ts +29 -0
  361. package/dist/functions/system.d.ts.map +1 -0
  362. package/dist/functions/tests.d.ts +28 -0
  363. package/dist/functions/tests.d.ts.map +1 -0
  364. package/dist/functions/timers.d.ts +139 -0
  365. package/dist/functions/timers.d.ts.map +1 -0
  366. package/dist/functions/triggers.d.ts +203 -0
  367. package/dist/functions/triggers.d.ts.map +1 -0
  368. package/dist/functions/types.d.ts +151 -0
  369. package/dist/functions/types.d.ts.map +1 -0
  370. package/dist/src/types/types.d.ts +364 -0
  371. package/dist/src/types/types.d.ts.map +1 -0
  372. package/package.json +192 -0
  373. package/scripts/app-install-cli.ts +286 -0
  374. package/scripts/assemble-frontend.sh +79 -0
  375. package/scripts/assemble-functions.sh +62 -0
  376. package/scripts/assemble.sh +35 -0
  377. package/scripts/boundary-check.sh +106 -0
  378. package/scripts/build-manifest.sh +80 -0
  379. package/scripts/check-core-integrity.sh +82 -0
  380. package/scripts/ingest-chunks.cjs +202 -0
  381. package/scripts/kb-chunk-parser.cjs +312 -0
  382. package/scripts/kb-chunk-parser.ts +330 -0
  383. package/scripts/load-test-app-install.ts +484 -0
  384. package/scripts/netlify-dev-wrapper.sh +22 -0
  385. 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)