@voltro/cli 0.33.0 → 0.35.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 (343) hide show
  1. package/CHANGELOG.md +1968 -0
  2. package/bin/nodeEnvironment.d.mts +30 -0
  3. package/bin/nodeEnvironment.mjs +158 -0
  4. package/bin/voltro.mjs +69 -5
  5. package/dist/addCommand-BNeoeSxe.js +124 -0
  6. package/dist/addCommand-aXSQveak.js +2 -0
  7. package/dist/agentsMd-BTchIZku.js +2 -0
  8. package/dist/agentsMd-mhQMF1bx.js +254 -0
  9. package/dist/apiBuild-B8aoJvuw.js +2 -0
  10. package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
  11. package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
  12. package/dist/appGraph-zuMGKVYX.js +2 -0
  13. package/dist/appPort-B_HpJ_ck.js +48 -0
  14. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  15. package/dist/baselineCommand-DIttzO8A.js +227 -0
  16. package/dist/bin.js +71 -28
  17. package/dist/build-CD8K4XOr.js +711 -0
  18. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  19. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  20. package/dist/checkCommand-Ct9xkTrS.js +232 -0
  21. package/dist/checkCommand-DKpDLlqu.js +2 -0
  22. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  23. package/dist/cliError-BmdYnghb.js +10 -0
  24. package/dist/cliOutput-D1tSBoRM.js +15 -0
  25. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  26. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  27. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  28. package/dist/clusterCmd-D5wsCmA_.js +54 -0
  29. package/dist/codegen-CYM3Zqrf.js +605 -0
  30. package/dist/codegen-ChBi_hVa.js +2 -0
  31. package/dist/codegenCommand-C4YoQIc2.js +30 -0
  32. package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
  33. package/dist/commandRunner-BLAEFLjp.js +47 -0
  34. package/dist/commands-BE8E7zF3.js +816 -0
  35. package/dist/connectionConfig-UFlIEiys.js +66 -0
  36. package/dist/dashboardCommand-D7SgZGaN.js +25 -0
  37. package/dist/dataCommand-BhYwDgg-.js +537 -0
  38. package/dist/dataProfile-dW-PsfLB.js +15 -0
  39. package/dist/dbCommand-DS4b97Is.js +2 -0
  40. package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
  41. package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
  42. package/dist/dev-D2BikO7a.js +3 -0
  43. package/dist/devActivity-Dx_3nnGv.js +100 -0
  44. package/dist/devActivity.js +1 -1
  45. package/dist/dialectDriver-CgXnDfec.js +39 -0
  46. package/dist/discover-C9XKJDco.js +25 -0
  47. package/dist/doctorCommand-CM4Ch9C7.js +2 -0
  48. package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
  49. package/dist/dormancyCommand-QewYug_s.js +69 -0
  50. package/dist/e2eCmd-BRabZww-.js +147 -0
  51. package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
  52. package/dist/envCommand-CCGPRQY1.js +60 -0
  53. package/dist/evalCommand-6RUfPen4.js +118 -0
  54. package/dist/evolveCommand-CHsLCtDf.js +281 -0
  55. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  56. package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
  57. package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
  58. package/dist/generateCommand-DbgcUpGw.js +147 -0
  59. package/dist/index.d.ts +45 -0
  60. package/dist/index.js +4 -3
  61. package/dist/infoCommand-DwOgK1t6.js +60 -0
  62. package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
  63. package/dist/inspect-P4pxoMaV.js +2 -0
  64. package/dist/inspectCmd-EHFZ9yYu.js +224 -0
  65. package/dist/inspectFetch-EMuhTG_9.js +151 -0
  66. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  67. package/dist/loadEnv-D9nEOClM.js +44 -0
  68. package/dist/logFileSink-C_D2wRN1.js +105 -0
  69. package/dist/logsCmd-D36xK7Zu.js +260 -0
  70. package/dist/manifestBuild-COkJoyAr.js +2 -0
  71. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
  72. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  73. package/dist/migrate-BV7I-ZHZ.js +83 -0
  74. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  75. package/dist/newCommand-COWOJ1_E.js +156 -0
  76. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  77. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  78. package/dist/pageConvention-cEiRxdab.js +5 -0
  79. package/dist/privacyCommand-C-Df56U_.js +146 -0
  80. package/dist/probeCommand-CZfaaUOZ.js +122 -0
  81. package/dist/projectScaffold-DmzEKHib.js +2 -0
  82. package/dist/projectScaffold-LMMtaavR.js +814 -0
  83. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  84. package/dist/renderProfile-1OWWAAtx.js +81 -0
  85. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  86. package/dist/runtimeTrace-CH3eUiMw.js +91 -0
  87. package/dist/scheduleCmd-DQRu6BZC.js +149 -0
  88. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  89. package/dist/schemaIr-UJybUUZW.js +103 -0
  90. package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
  91. package/dist/seedRunner-ZmLSqNe2.js +333 -0
  92. package/dist/serveCommand-CbDHU6l-.js +2 -0
  93. package/dist/serveCommand-iwlUBNS1.js +1766 -0
  94. package/dist/serveEntry.js +5 -5
  95. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  96. package/dist/start-BgN62boB.js +3 -0
  97. package/dist/start-T4VesWiM.js +1087 -0
  98. package/dist/startEntry.js +2 -2
  99. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  100. package/dist/storageCommand-Co6NfLqN.js +42 -0
  101. package/dist/templates-De8IR5-c.js +102 -0
  102. package/dist/test-CI6iDsYc.js +115 -0
  103. package/dist/tracesCmd-DStmCJPi.js +232 -0
  104. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  105. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  106. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  107. package/dist/updateCommand-BlMZhWgO.js +2 -0
  108. package/dist/updateCommand-x0pI_x-B.js +585 -0
  109. package/dist/webDev-BcykISYQ2.js +2 -0
  110. package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
  111. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  112. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  113. package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
  114. package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
  115. package/package.json +209 -17
  116. package/templates/AGENTS.core.md +58 -3
  117. package/templates/AGENTS.md +64 -7
  118. package/templates/agent-docs/_index.md +6 -4
  119. package/templates/agent-docs/_manifest.json +23 -6
  120. package/templates/agent-docs/ai.md +370 -0
  121. package/templates/agent-docs/authentication.md +313 -31
  122. package/templates/agent-docs/caching.md +6 -0
  123. package/templates/agent-docs/cli.md +853 -50
  124. package/templates/agent-docs/data.md +608 -12
  125. package/templates/agent-docs/database/migrations.md +223 -25
  126. package/templates/agent-docs/database/misc.md +156 -40
  127. package/templates/agent-docs/database/querying.md +19 -1
  128. package/templates/agent-docs/database/scaling.md +60 -0
  129. package/templates/agent-docs/database/schema.md +1 -0
  130. package/templates/agent-docs/database/seedsdialects.md +208 -19
  131. package/templates/agent-docs/database/transactions.md +68 -0
  132. package/templates/agent-docs/deployment.md +284 -25
  133. package/templates/agent-docs/introduction.md +88 -17
  134. package/templates/agent-docs/local-first-mobile.md +79 -4
  135. package/templates/agent-docs/multi-tenancy.md +188 -42
  136. package/templates/agent-docs/observability.md +58 -3
  137. package/templates/agent-docs/plugins/ai-flows.md +247 -2
  138. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  139. package/templates/agent-docs/plugins/audit.md +37 -1
  140. package/templates/agent-docs/plugins/auth-social.md +143 -0
  141. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  142. package/templates/agent-docs/plugins/auth.md +131 -6
  143. package/templates/agent-docs/plugins/billing.md +132 -15
  144. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  145. package/templates/agent-docs/plugins/clickhouse.md +32 -1
  146. package/templates/agent-docs/plugins/duckdb.md +1 -1
  147. package/templates/agent-docs/plugins/flags.md +132 -0
  148. package/templates/agent-docs/plugins/governance.md +105 -7
  149. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  150. package/templates/agent-docs/plugins/presence.md +13 -2
  151. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  152. package/templates/agent-docs/plugins/search.md +162 -8
  153. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  154. package/templates/agent-docs/plugins/storage.md +11 -0
  155. package/templates/agent-docs/plugins/webhooks.md +105 -0
  156. package/templates/agent-docs/plugins.md +152 -18
  157. package/templates/agent-docs/reference.md +60 -3
  158. package/templates/agent-docs/releases.md +1117 -0
  159. package/templates/agent-docs/routing.md +43 -25
  160. package/templates/agent-docs/scheduling.md +27 -0
  161. package/templates/agent-docs/schema-driven-ui.md +92 -12
  162. package/templates/agent-docs/security.md +426 -0
  163. package/templates/agent-docs/templates/apibackends.md +87 -18
  164. package/templates/agent-docs/templates/appshells.md +32 -14
  165. package/templates/agent-docs/templates/overview.md +13 -8
  166. package/templates/agent-docs/testing.md +211 -14
  167. package/templates/agent-docs/whats-new.md +98 -136
  168. package/templates/agent-docs/workflows.md +231 -14
  169. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  170. package/templates/apps/api-ai/package.json +8 -7
  171. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  172. package/templates/apps/api-auth/package.json +9 -8
  173. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  174. package/templates/apps/api-backend/package.json +12 -8
  175. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  176. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  177. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  178. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  179. package/templates/apps/api-backend-deactivation/package.json +8 -7
  180. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  181. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  182. package/templates/apps/api-backend-mail/package.json +9 -8
  183. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  184. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  185. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  186. package/templates/apps/api-backend-mariadb/package.json +10 -9
  187. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  188. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  189. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  190. package/templates/apps/api-backend-sqlite/package.json +9 -8
  191. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  192. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  193. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  194. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  195. package/templates/apps/api-backend-storage/package.json +9 -8
  196. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  197. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  198. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  199. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  200. package/templates/apps/api-cms/app.config.ts +19 -0
  201. package/templates/apps/api-cms/authz.ts +63 -0
  202. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  203. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  204. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  205. package/templates/apps/api-cms/package.json +11 -10
  206. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  207. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  208. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  209. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  210. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  211. package/templates/apps/api-collab/package.json +9 -8
  212. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  213. package/templates/apps/api-data-advanced/package.json +9 -8
  214. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  215. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  216. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  217. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  218. package/templates/apps/api-durable/package.json +9 -8
  219. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  220. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  221. package/templates/apps/api-feature-flags/package.json +10 -9
  222. package/templates/apps/api-governance/README.md +30 -8
  223. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  224. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  225. package/templates/apps/api-governance/database/schema.ts +16 -4
  226. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  227. package/templates/apps/api-governance/package.json +9 -8
  228. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  229. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  230. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  231. package/templates/apps/api-kv/package.json +9 -8
  232. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  233. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  234. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  235. package/templates/apps/api-moderation/package.json +9 -8
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  237. package/templates/apps/api-observability/package.json +9 -8
  238. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  239. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  240. package/templates/apps/api-ratelimit/package.json +9 -8
  241. package/templates/apps/api-rbac/package.json +9 -8
  242. package/templates/apps/api-rest/package.json +8 -7
  243. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  244. package/templates/apps/api-saas/package.json +12 -11
  245. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  246. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  247. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  248. package/templates/apps/api-saas-starter/authz.ts +75 -0
  249. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  250. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  251. package/templates/apps/api-saas-starter/package.json +15 -11
  252. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  253. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  254. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  255. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  256. package/templates/apps/api-search/package.json +9 -8
  257. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  258. package/templates/apps/api-status/README.md +10 -3
  259. package/templates/apps/api-status/app.config.ts +8 -3
  260. package/templates/apps/api-status/authz.ts +5 -3
  261. package/templates/apps/api-status/package.json +9 -8
  262. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  263. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  264. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  265. package/templates/apps/api-status/tests/status.test.ts +9 -1
  266. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  267. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  268. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  269. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  270. package/templates/apps/api-versioning/package.json +9 -8
  271. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  272. package/templates/apps/api-webhooks/package.json +10 -9
  273. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  274. package/templates/apps/changelog/package.json +8 -6
  275. package/templates/apps/edge-functions/package.json +2 -2
  276. package/templates/apps/frontend-admin/package.json +10 -8
  277. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  278. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  279. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  280. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  281. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  282. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  283. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  284. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  285. package/templates/apps/frontend-app/app.config.ts +4 -3
  286. package/templates/apps/frontend-app/package.json +11 -8
  287. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  288. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  289. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  290. package/templates/apps/frontend-auth/package.json +10 -8
  291. package/templates/apps/frontend-blank/package.json +9 -7
  292. package/templates/apps/frontend-cms/package.json +11 -9
  293. package/templates/apps/frontend-collab/package.json +12 -9
  294. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  295. package/templates/apps/frontend-contact/package.json +9 -7
  296. package/templates/apps/frontend-dashboard/package.json +9 -7
  297. package/templates/apps/frontend-docs/package.json +9 -7
  298. package/templates/apps/frontend-i18n/package.json +8 -6
  299. package/templates/apps/frontend-landing/package.json +9 -7
  300. package/templates/apps/frontend-portal/package.json +10 -8
  301. package/templates/apps/frontend-saas/app.config.ts +10 -6
  302. package/templates/apps/frontend-saas/package.json +10 -8
  303. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  304. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  305. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  306. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  307. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  308. package/templates/apps/frontend-spa/package.json +9 -7
  309. package/templates/apps/frontend-ssr/package.json +9 -7
  310. package/templates/apps/frontend-ssr-api/package.json +10 -8
  311. package/templates/apps/frontend-static-blog/package.json +8 -6
  312. package/templates/apps/frontend-status/package.json +10 -8
  313. package/templates/apps/mobile-app/README.md +1 -0
  314. package/templates/apps/mobile-app/package.json +4 -2
  315. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  316. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  317. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  318. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  319. package/templates/baselines/bare/.env.example +14 -0
  320. package/templates/baselines/bare/baseline.json +4 -4
  321. package/templates/baselines/compose/.env.example +14 -0
  322. package/templates/baselines/compose/README.md +1 -1
  323. package/templates/baselines/compose/baseline.json +5 -5
  324. package/templates/baselines/compose-mariadb/.env.example +14 -0
  325. package/templates/baselines/compose-mariadb/README.md +1 -1
  326. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  327. package/templates/baselines/helm/.env.example +14 -0
  328. package/templates/baselines/helm/baseline.json +4 -4
  329. package/dist/apiBuild-C-x9YacA.js +0 -2
  330. package/dist/appGraph-CvQCte0z.js +0 -2
  331. package/dist/checkCommand-DRovTKza.js +0 -2
  332. package/dist/commands-CJfepbm4.js +0 -11541
  333. package/dist/dbCommand-b1gum4td.js +0 -2
  334. package/dist/dev-iiMtlkfs.js +0 -3
  335. package/dist/devActivity-BhIu6ncs.js +0 -159
  336. package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
  337. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  338. package/dist/inspect-mmBuRXmy.js +0 -2
  339. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  340. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  341. package/dist/serveCommand-DdaM4Hup.js +0 -1608
  342. package/dist/start-C0koT0UO.js +0 -1084
  343. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -95,7 +95,7 @@ import { hashPassword } from '@voltro/plugin-auth'
95
95
  import { Effect } from 'effect'
96
96
 
97
97
  const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
98
- // → 'scrypt$16384$8$1$<saltB64>$<derivedB64>'
98
+ // → 'scrypt$32768$8$1$<saltB64>$<derivedB64>'
99
99
  ```
100
100
 
101
101
  Store `hash` in the `passwordHash` column of your users table. **Never** store the plaintext.
@@ -133,11 +133,29 @@ The framework uses scrypt with these defaults:
133
133
 
134
134
  | Param | Value | Effect |
135
135
  |---|---|---|
136
- | `N` | `2^14` = 16384 | CPU + memory cost |
136
+ | `N` | `2^15` = 32768 | CPU + memory cost |
137
137
  | `r` | 8 | block size |
138
138
  | `p` | 1 | parallelism |
139
139
 
140
- At those parameters a single hash takes roughly ~50ms on a modern laptop slow enough to make offline brute force expensive, fast enough that sign-in feels instant. The cost is roughly equivalent to bcrypt's `$2y$10$`. The parameters are encoded inline in the hash string (`scrypt$16384$8$1$…`), so a future cost bump can decode older hashes and re-encode on the next sign-in.
140
+ Measurednode v26.3.0, Apple M2 Pro, median of 5 runs at `r=8 p=1 keyLen=32`. Memory is `128 · N · r`, held for the whole derivation:
141
+
142
+ | `N` | time | memory |
143
+ |---|---|---|
144
+ | `2^14` | 39 ms | 16 MiB |
145
+ | **`2^15`** (this) | **73 ms** | **32 MiB** |
146
+ | `2^16` | 156 ms | 64 MiB |
147
+ | `2^17` (OWASP's floor) | 271 ms | 128 MiB |
148
+
149
+ ### Why not OWASP's `2^17`
150
+
151
+ Every row of that table is a cost **your server** pays, per attempt, on an endpoint an anonymous caller controls. Two facts decide it:
152
+
153
+ - The brute-force lockout that is on by default is keyed by **email** — deliberately, so an unknown address locks exactly like a real one and the lock is not an existence oracle. An attacker who rotates the email field is therefore not rate-limited, and every attempt buys a full derivation. General per-IP rate limiting is opt-in ([`@voltro/plugin-ratelimit`](/docs/plugins/ratelimit)).
154
+ - The common deployment target is a small container. At 0.25 vCPU, `2^17` is over a second of CPU and 128 MiB **per anonymous attempt** — one laptop can hold that box down, and ten concurrent logins is an OOM.
155
+
156
+ Availability is part of security, so the ceiling here is set by what an anonymous caller can make the server spend, not by the offline-cracking table alone. Put a per-IP limiter in front of `/auth` and raising `N` becomes cheap.
157
+
158
+ The parameters are encoded inline in the hash string (`scrypt$32768$8$1$…`), so a cost bump decodes older hashes and re-encodes them on the next sign-in — see [Rehash-on-verify](/docs/plugins/auth). Hashes minted at `2^14` keep verifying; nothing to migrate.
141
159
 
142
160
  For high-throughput service-to-service flows that need many auths per second, use API keys instead — `apiKeyStrategy` from `@voltro/protocol/apikey`. Passwords are for humans.
143
161
 
@@ -227,13 +245,40 @@ voltro:session=<base64url(payload)>.<base64url(signature)>
227
245
 
228
246
  Where:
229
247
 
230
- - `payload` is JSON `{ subject, exp, iat, kid? }` — the **whole** typed `Subject` round-trips inside the cookie, e.g. `{ "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }`. The subject's own `id` is the identity; `iat` drives the sliding-window renewal below; `kid` is the (non-secret) label of the signing key, stamped when signing with a keyed secret set.
248
+ - `payload` is JSON `{ v, subject, exp, iat, kid? }`, e.g. `{ "v": 2, "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }`. `v` is the payload format version; `iat` drives the sliding-window renewal below; `kid` is the (non-secret) label of the signing key, stamped when signing with a keyed secret set.
231
249
  - `signature` is HMAC-SHA256(payload, AUTH_SECRET)
232
250
 
233
251
  **Why HMAC, not JWT?**
234
252
 
235
253
  JWTs come with the `alg: 'none'` attack and a long history of header confusion. The framework's format is intentionally simpler: HMAC-SHA256, fixed algorithm, no header.
236
254
 
255
+ ## The cookie carries identity, never authority
256
+
257
+ `subject` in the payload is a **`SubjectIdentity`** — the `Subject` union with `scopes` omitted at the schema level. A session cookie says *who* the caller is. What they may *do* is resolved on every request by [`auth.resolveScopes`](/docs/authentication/strategies).
258
+
259
+ This is not a style choice, it is the whole lifetime argument. Identity is settled once at sign-in and never changes. Authority changes the moment an admin removes a role — and a value signed into a 7-day cookie is frozen for 7 days, longer if the sliding renewal keeps re-signing it. Embedding scopes meant that narrowing someone's role had no effect on the session they already held: you could sign them out entirely, but you could not take one permission away.
260
+
261
+ Two consequences to know:
262
+
263
+ - **`issueSession` / `signSession` throw** when handed a subject that carries `scopes`. They do not strip them. A silently dropped scope is an authorization change with no error, no log line and no diff — you find it later, as "permissions randomly stopped working".
264
+ - **`readSession` / `verifySession` return a `SubjectIdentity`.** The absent field is the guarantee: nothing downstream can read authority out of a cookie, because the value it gets back has nowhere to keep it.
265
+
266
+ If you build a Subject yourself at login — a custom sign-in route, an SSO `onLogin` — mint the session from the identity and move the permissions into the resolver:
267
+
268
+ ```ts
269
+ // before: authority frozen into the cookie for a week
270
+ issueSession({ ...identity, scopes: permissionsFor(user) }, secret)
271
+
272
+ // after: identity in the cookie, authority resolved per request
273
+ issueSession(identity, secret)
274
+ ```
275
+
276
+ ### Payload versioning — what happens to live sessions
277
+
278
+ `v` is required and pinned to the version this build mints. A payload without it, or with an older one, **fails to decode** — it does not fall back to a lenient read. A cookie from the previous format asserts authority, and honouring that assertion is exactly the defect being removed; authenticating someone under a contract the server no longer holds is worse than asking them to sign in.
279
+
280
+ So a framework upgrade that bumps the payload version ends every live session. Plan it like a secret rotation: users see the sign-in screen once. Their `sessions` rows are untouched.
281
+
237
282
  ## Issuing
238
283
 
239
284
  ```ts
@@ -241,7 +286,7 @@ import { issueSession } from '@voltro/plugin-auth'
241
286
 
242
287
  // Positional args: (subject, secret, options?). Returns { value, setCookie }.
243
288
  const { value, setCookie } = issueSession(
244
- subject, // a full Subject, e.g. subjectFromUser(user)
289
+ subject, // an identity, e.g. subjectFromUser(user) — no scopes
245
290
  AUTH_CONFIG.secret,
246
291
  {
247
292
  ttlSeconds: 60 * 60 * 24 * 7, // 7 days (default if omitted)
@@ -262,7 +307,7 @@ res.setHeader('set-cookie', setCookie)
262
307
  import { readSession } from '@voltro/plugin-auth'
263
308
 
264
309
  const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
265
- // → { type: 'user', id, tenantId } | null
310
+ // → { type: 'user', id, tenantId } | null — a SubjectIdentity: no `scopes`
266
311
  ```
267
312
 
268
313
  Returns `null` for:
@@ -270,6 +315,7 @@ Returns `null` for:
270
315
  - Missing cookie
271
316
  - Tampered payload (signature mismatch)
272
317
  - Expired session (`exp < now`)
318
+ - A payload from a superseded format version (see above)
273
319
 
274
320
  ## Clearing
275
321
 
@@ -304,6 +350,8 @@ For centralised revocation, the plugin writes a `sessions` row on every sign-in
304
350
 
305
351
  Revocation is enforced **at request time**: on every verify, the cookie's server-side session id (`metadata.sessionId`) is checked against the `sessions` table through a small in-process TTL cache (default **30 seconds**, tune via the plugin's `sessionRevocation.ttlMs`). Be honest about the window: a revocation is instant on the process that performed it (its cache entry is invalidated inline — sign-out kills the cookie immediately there) and takes effect within the cache window on every other replica. `POST /auth/sign-out` deletes the caller's session row, and a password-reset confirm revokes **all** of the user's sessions — in both cases a retained copy of the cookie stops authenticating within that window, days before its `exp`. The framework's rpc auth chain enforces the same check: `authRoutesPlugin` carries a pre-wired session strategy (sharing the same cache) that `voltro dev` / `voltro serve` slot into the chain automatically. One deliberate gap: a cookie minted by hand via `issueSession` without a `sessions` row carries no `sessionId` and stays purely stateless.
306
352
 
353
+ **Revoking a session and revoking a permission are two different operations, and both now take effect on a live session.** This section is the first: kill the row and the holder is signed out. The second is [`auth.resolveScopes`](/docs/authentication/strategies) — narrow the role and the caller keeps their session but loses the permission. The two windows are deliberately the same 30 seconds and both invalidate to zero the same way, so there is one number to reason about rather than a second one you discover later.
354
+
307
355
  ## When to use a longer / shorter lifetime
308
356
 
309
357
  | Use case | Lifetime |
@@ -322,9 +370,9 @@ Sliding-window auto-renewal ships: the session payload carries an `iat`, and `ve
322
370
  // 1. Parse cookie → { payloadB64, sigB64 }
323
371
  // 2. Compute expected = hmacSha256(payloadB64, secret)
324
372
  // 3. Constant-time compare expected vs. sigB64
325
- // 4. Decode payload
373
+ // 4. Decode payload (rejects a superseded payload version)
326
374
  // 5. Check exp (reject if exp < now)
327
- // 6. Return the decoded Subject
375
+ // 6. Return the decoded SubjectIdentity
328
376
  ```
329
377
 
330
378
  All steps use constant-time comparisons to defeat timing attacks. Implementation lives in `@voltro/plugin-auth/session.ts` — read it for the full story.
@@ -674,21 +722,26 @@ Order matters: put the cheapest / most-common strategy first. When no strategy m
674
722
 
675
723
  If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. `voltro check`'s `rbac/unguarded-mutation` reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
676
724
 
677
- `resolveScopes` closes that. It runs after a strategy matches and adds scopes to the resolved Subject from whatever source you like:
725
+ `resolveScopes` closes that. It runs after a strategy matches, on every request, and resolves the caller's authority from whatever source you like:
678
726
 
679
727
  ```ts
680
728
  // app.config.ts
681
729
  export default defineApiConfig({
682
730
  auth: {
683
731
  resolveScopes: async (subject, { store }) => {
684
- if (store === undefined) return [] // still booting claim nothing
685
- const role = await roleCache.get(subject.id, store) // cache it — see below
686
- return role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read']
732
+ if (store === undefined) return { kind: 'unavailable', reason: 'store not ready' }
733
+ const role = await readRole(store, subject.id)
734
+ return {
735
+ kind: 'authoritative',
736
+ scopes: role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read'],
737
+ }
687
738
  },
688
739
  },
689
740
  })
690
741
  ```
691
742
 
743
+ **For a cookie-authenticated caller this is not a supplement — it is the only place authority comes from.** The session cookie carries a [`SubjectIdentity`](/docs/authentication/sessions) with no `scopes` field, so the strategy establishes nothing to add to. An app that gates on scopes and wires no resolver has callers with no scopes, which is the fail-closed direction.
744
+
692
745
  The same authorization is now declarable on the descriptor:
693
746
 
694
747
  ```ts
@@ -699,16 +752,86 @@ export const payrollList = defineQuery({
699
752
  })
700
753
  ```
701
754
 
702
- **You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `[]` then rather than guessing.
755
+ ### Three answers, and picking the right one is the point
756
+
757
+ | Return | Meaning | Effect |
758
+ |---|---|---|
759
+ | `['a', 'b']` — a bare array | **grant** (the shorthand; identical to `{ kind: 'grant', scopes }`) | unioned onto whatever the strategy established |
760
+ | `{ kind: 'authoritative', scopes }` | this resolver is the **complete** answer | replaces — anything not listed is removed |
761
+ | `{ kind: 'unavailable', reason }` | the authority source could not be reached | the request fails closed with `Unauthenticated`, and `reason` reaches `onStrategyFailed` |
762
+
763
+ An already-written resolver returning an array keeps its exact meaning, with no compiler error suggesting otherwise.
703
764
 
704
- **Scopes only never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface. The returned scopes are UNIONED with whatever the strategy already set, so a resolver can grant authority but never revoke it.
765
+ **Why this is three tags and not a boolean.** The hook used to be union-only, on the reasoning that a resolver which can silently subtract is a resolver whose bad day is indistinguishable from a policy decision — a DB blip that returns no rows would read as "this user has no permissions" and be applied as such. That hazard is real. But union-only also made narrowing impossible: removing a permission from a role had no effect on anyone already signed in, because the only hook that could have observed it was structurally forbidden from removing anything.
766
+
767
+ The empty array is what forced the split. Under a grant shape `[]` has to mean "no extra scopes"; under a replace shape it has to mean "no scopes at all"; and a failed lookup produces it under both. Three meanings, one value — so each got its own tag, and none of them is what you get by accident. **Return `unavailable`, not `[]`, when a lookup fails.**
768
+
769
+ **You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `{ kind: 'unavailable', … }` then rather than guessing — under the old contract `[]` was the safe answer there, and it no longer is.
770
+
771
+ **Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface.
705
772
 
706
773
  **It does not run for anonymous callers** — there is no identity to look a role up for.
707
774
 
708
- **Cache it yourself.** It is on the request path. A `Map` keyed by subject id with a short TTL is usually enough. The framework deliberately does not cache for you, because only you know how quickly a role change has to take effect.
775
+ ### It also answers for durable workflows read `ctx.origin` first
776
+
777
+ A workflow's start context persists the caller's **identity**, never their scopes: a `json()` column read back by another cluster runner days later is authority frozen and made durable, which is the session cookie's old defect one layer down. So a resumed run asks this resolver what its caller may do, on every execution attempt:
778
+
779
+ ```ts
780
+ resolveScopes: async (subject, ctx) => {
781
+ if (ctx.origin === 'workflow') return rolesFromDb(subject, ctx.store)
782
+ return rolesFromHeader(ctx.headers) // the request path, unchanged
783
+ }
784
+ ```
785
+
786
+ `ctx.origin` is `'request' | 'workflow'`. For `'workflow'` there **is** no request: `ctx.headers` is `{}` and `ctx.clientId` is `undefined` (its type is `number | undefined`, which is where a resolver reading it sees the compile error). Empty rather than fabricated — a resolver that needs headers has to be able to branch instead of silently receiving a bag that is always empty.
787
+
788
+ Three consequences worth stating plainly:
789
+
790
+ - **An app that wires no resolver gets workflow runs with no scopes.** Fail-closed, and the same default a cookie-authenticated request already has.
791
+ - **`{ kind: 'unavailable' }` fails the execution attempt**, rather than downgrading it. A run that quietly skips the branch it was not allowed to take is indistinguishable from one whose business logic said no. Fix the source and `voltro workflows redrive`.
792
+ - **A run with no recorded caller at all** — a bootstrap, or one whose start-context row aged out — runs as the framework's `SYSTEM_SUBJECT` and is *not* put through your resolver. It already states its own authority, and it carries `tenantId: null`, which the tenant scope reads as "every tenant". That is why the framework does not promote a *caller-owned* workflow to it: that would trade frozen authority for cross-tenant visibility.
793
+
794
+ `scopeCache` applies to the request path only. One resolution per run attempt is not a hot path, and a run that lasts days must not inherit a window sized for a burst of requests.
795
+
796
+ **Narrowing is audited.** `auth.onScopesNarrowed` is called whenever an authoritative resolution removed scopes the strategy had established, with `{ strategyId, subjectType, subjectId, removed, granted }`. It fires on a fresh resolution rather than on a cache replay, so a narrowed caller logs once per window instead of once per request.
709
797
 
710
798
  Wired identically under `voltro dev` and `voltro serve`.
711
799
 
800
+ ## The staleness window, and how to make it zero
801
+
802
+ The framework caches the resolution for you. The window is `auth.scopeCache`, and its default is **30 seconds** — deliberately the same window the [session-revocation check](/docs/authentication/sessions) already used, so the two per-request store reads miss together and there is *one* number to reason about rather than a second one you discover later.
803
+
804
+ That number is the lag between "an admin removes a role" and "every replica enforces it". Three ways to shorten it:
805
+
806
+ ```ts
807
+ // app.config.ts
808
+ import { makeScopeCache, scopeCacheKey } from '@voltro/protocol'
809
+
810
+ // 1. Keep the default: a role change lands within 30s, everywhere. Nothing to write.
811
+
812
+ // 2. Resolve on every request. Staleness zero, one store read per request.
813
+ export default defineApiConfig({
814
+ auth: { resolveScopes, scopeCache: { ttlMs: 0 } },
815
+ })
816
+
817
+ // 3. Keep the cache AND get zero where it matters: hold the handle and drop the
818
+ // entry from whatever changes a role.
819
+ export const scopeCache = makeScopeCache({ ttlMs: 30_000 })
820
+ export default defineApiConfig({
821
+ auth: { resolveScopes, scopeCache },
822
+ })
823
+
824
+ // …in the mutation that grants or removes a role:
825
+ scopeCache.invalidate(scopeCacheKey(subject)) // instant on this process, ttlMs elsewhere
826
+ scopeCache.invalidateAll() // when a ROLE's definition changed, not one membership
827
+ ```
828
+
829
+ `VOLTRO_AUTH_SCOPE_CACHE_TTL_MS` overrides the default per deployment; an explicit `ttlMs` in code wins over the variable. Set `scopeCache: false` when your resolver reads anything beyond the subject's identity (a header, a request path) — the cache key is `type + tenantId + id` and nothing else, so a resolver that varies on something outside that key must not be cached.
830
+
831
+ `tenantId` is in the key on purpose: a user keeps their `id` across a tenant switch and their authority does not.
832
+
833
+ **The honest cost.** This is one extra store read per subject per window, on a path that was already doing one of exactly this shape for session revocation. An `unavailable` verdict is never cached — caching it would stretch one blip into a window of denials and hide the recovery.
834
+
712
835
  ## Wiring it into the app
713
836
 
714
837
  The composed resolver becomes the runtime's `AuthMiddleware` — the per-request middleware that populates `SubjectService` so every handler can `yield* SubjectService` (or read `ctx.subject`). On a single-strategy password app you never touch this; the plugin wires `voltroPasswordStrategy` for you. You only assemble the chain explicitly when you add a second strategy:
@@ -723,7 +846,11 @@ export const AuthLayer = Layer.succeed(
723
846
  )
724
847
  ```
725
848
 
726
- The runtime injects a connection-override fast-path *ahead* of the chain so a soft re-auth (`auth.signin` rebinding the WebSocket's subject after a credential check) is honoured without re-running strategies. That's why `AuthStrategyInput` carries `clientId`.
849
+ Nothing runs *ahead* of the chain. A soft re-auth `auth.signin` over the live WebSocket, a tenant switch — calls `bindConnectionCredential(clientId, { cookies })` from `@voltro/runtime`, which patches the connection's headers; the chain then runs on those headers exactly as it would for a fresh request. That's why `AuthStrategyInput` carries `clientId`.
850
+
851
+ This used to be a fast path that returned a stored `Subject` and skipped the chain, and the cost was everything downstream of the strategy: session revocation (it lives inside the strategy), `resolveScopes`, the scope cache, and the credential-expiry bound — for the whole life of the connection, with a 24-hour idle sweep as the only backstop. Patching the credential means a rebound connection has no property a reconnecting one lacks, because it is the same code path.
852
+
853
+ If you have no credential to present, that is the finding rather than a limitation: a caller that cannot authenticate a fresh request was holding authority no request could obtain.
727
854
 
728
855
  ## The built-in: `voltroPasswordStrategy`
729
856
 
@@ -826,6 +953,8 @@ Voltro ships first-party [strategy](/docs/authentication/strategies) plugins for
826
953
  | `@voltro/plugin-auth-supabase` | Supabase Auth (GoTrue) | `supabase` |
827
954
  | `@voltro/plugin-auth-oidc` | Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) | configurable via `id:` |
828
955
 
956
+ > **Looking for "Sign in with Google"?** That is a different plugin. Everything on this page is a *verifier* — it checks a JWT an enterprise IdP already issued. For consumer social login (Google / GitHub / Apple) where Voltro runs the whole redirect flow and mints your own session, use [`@voltro/plugin-auth-social`](/docs/plugins/auth-social). No identity vendor required.
957
+
829
958
  Every plugin is a thin wrapper — ~70–110 lines each — over one shared engine: **`jwtBearerStrategy`**. They add nothing but provider-specific defaults (JWKS URL, cookie name, tenant-claim mapping). If your IdP isn't in the list AND doesn't expose a standard OIDC discovery document, point `jwtBearerStrategy` at its JWKS endpoint directly.
830
959
 
831
960
  ## The model: verify, don't redirect
@@ -911,21 +1040,25 @@ Tenant maps from `claims.org_id`. Verified claims arrive as `subject.metadata.cl
911
1040
 
912
1041
  The strategy above is the *verify* model — WorkOS' JWT is the session. `@voltro/plugin-auth-workos` also exports two primitives for the **other** model, where WorkOS runs the login and your app mints its **own** session — an alternate front door *alongside* password sign-in, not a replacement session authority. This is how **Voltro Cloud** offers a "Sign in with WorkOS SSO" button next to email/password.
913
1042
 
914
- - **`workosAuthorizationUrl({ clientId, redirectUri, state? })`** — builds the WorkOS hosted-login (AuthKit) URL to redirect the browser to. Pass a random `state` for CSRF defence (and to carry a post-login return path).
915
- - **`workosAuthenticateWithCode({ clientId, apiKey, code })`** — exchanges the `?code=` from the callback for the authenticated `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). **Server-only** — it carries the WorkOS **API key** (the OAuth client secret).
1043
+ - **`workosBeginLogin({ clientId, redirectUri })`** — returns `{ url, state, codeVerifier }`. `url` is the WorkOS hosted-login (AuthKit) URL to redirect the browser to; it always carries a freshly-minted CSRF `state` and a PKCE `code_challenge` (S256). Stash `state` and `codeVerifier` the callback needs both.
1044
+ - **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** — verifies the state in constant time, then exchanges the `?code=` from the callback for the authenticated `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). **Server-only** — it carries the WorkOS **API key** (the OAuth client secret).
916
1045
 
917
1046
  Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency), so they drop into any app's own routing + provisioning.
918
1047
 
1048
+ > **`state` and PKCE are not optional, and the check is not yours to remember.** `state` used to be a parameter you could pass — which meant the default flow had no CSRF token at all. Without it, an attacker who gets a victim's browser to hit your callback with an authorization code *they* obtained logs the victim into the **attacker's** account. It is now minted for you on every call, and the state comparison lives *inside* `workosAuthenticateWithCode`: a generated `state` that nothing verifies is worse than none, because a code review and a screenshot of the authorize URL then both read as "CSRF is handled". PKCE (S256) rides along for the same reason — the authorization code travels through the address bar and the referrer chain, and without a verifier whoever captures it can redeem it first.
1049
+
919
1050
  ### The flow
920
1051
 
921
1052
  ```text
922
1053
  GET /auth/workos/login
923
- ─► set a random `state` cookie (CSRF defence)
924
- ─► 302 workosAuthorizationUrl({ clientId, redirectUri, state })
1054
+ ─► const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
1055
+ ─► stash BOTH in short-lived HttpOnly cookies (CSRF nonce + PKCE verifier)
1056
+ ─► 302 → url
925
1057
 
926
1058
  GET /auth/workos/callback?code=…&state=…
927
- ─► verify `state` matches the cookie (else 400 possible CSRF)
928
- ─► workosAuthenticateWithCode({ clientId, apiKey, code }) → WorkosProfile
1059
+ ─► workosAuthenticateWithCode({ clientId, apiKey, code,
1060
+ state, expectedState: <cookie>, codeVerifier: <cookie> })
1061
+ → WorkosProfile (throws WorkosStateMismatchError → 400 on a mismatch)
929
1062
  ─► find-or-provision the local user + org (SHARED with password signup)
930
1063
  ─► issueSession(subject) → Set-Cookie: voltro:session=…
931
1064
  ─► 302 → dashboard
@@ -938,9 +1071,8 @@ The callback mints the **same** `voltro:session` as password sign-in, so everyth
938
1071
  Wrap the two routes in a small **server-only** plugin and add it to `plugins` in your api's `app.config`:
939
1072
 
940
1073
  ```ts
941
- import { randomUUID } from 'node:crypto'
942
1074
  import { definePlugin } from '@voltro/protocol'
943
- import { workosAuthorizationUrl, workosAuthenticateWithCode } from '@voltro/plugin-auth-workos'
1075
+ import { workosBeginLogin, workosAuthenticateWithCode, WorkosStateMismatchError } from '@voltro/plugin-auth-workos'
944
1076
  import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'
945
1077
 
946
1078
  export const workosAuthPlugin = () =>
@@ -956,11 +1088,20 @@ export const workosAuthPlugin = () =>
956
1088
  if (!clientId || !redirectUri) {
957
1089
  return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
958
1090
  }
959
- const state = randomUUID()
960
- const url = workosAuthorizationUrl({ clientId, redirectUri, state })
1091
+ const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
1092
+ // SameSite=Lax, not Strict: the return from WorkOS is a top-level GET,
1093
+ // which Lax allows and Strict would drop — and a dropped cookie now
1094
+ // FAILS the login instead of silently skipping the check.
1095
+ const attrs = 'Path=/; Max-Age=600; HttpOnly; SameSite=Lax'
961
1096
  return {
962
1097
  status: 302,
963
- headers: { location: url, 'set-cookie': `workos-oauth-state=${state}; Path=/; Max-Age=600; SameSite=Lax` },
1098
+ headers: {
1099
+ location: url,
1100
+ 'set-cookie': [
1101
+ `workos-oauth-state=${state}; ${attrs}`,
1102
+ `workos-oauth-verifier=${codeVerifier}; ${attrs}`,
1103
+ ].join('\n'),
1104
+ },
964
1105
  body: '',
965
1106
  }
966
1107
  },
@@ -974,10 +1115,24 @@ export const workosAuthPlugin = () =>
974
1115
  if (!clientId || !apiKey) {
975
1116
  return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
976
1117
  }
977
- const code = new URLSearchParams(req.query).get('code')
1118
+ const params = new URLSearchParams(req.query)
1119
+ const code = params.get('code')
978
1120
  if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
979
- // …also validate `state` against the state cookie (CSRF) before exchanging.
980
- const profile = await workosAuthenticateWithCode({ clientId, apiKey, code })
1121
+ let profile
1122
+ try {
1123
+ profile = await workosAuthenticateWithCode({
1124
+ clientId, apiKey, code,
1125
+ state: params.get('state') ?? '',
1126
+ expectedState: readCookie(req.headers['cookie'], 'workos-oauth-state') ?? '',
1127
+ codeVerifier: readCookie(req.headers['cookie'], 'workos-oauth-verifier') ?? '',
1128
+ })
1129
+ } catch (err) {
1130
+ // Distinct from "WorkOS said no" — worth alerting on separately.
1131
+ if (err instanceof WorkosStateMismatchError) {
1132
+ return { status: 400, contentType: 'text/plain', body: 'Invalid or missing OAuth state (possible CSRF).' }
1133
+ }
1134
+ return { status: 502, contentType: 'text/plain', body: 'WorkOS code exchange failed.' }
1135
+ }
981
1136
  // find-or-provision runs the SAME path as password signup:
982
1137
  const { userId, orgId } = await findOrProvisionUser(profile)
983
1138
  const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
@@ -995,6 +1150,8 @@ export const workosAuthPlugin = () =>
995
1150
  })
996
1151
  ```
997
1152
 
1153
+ Do **not** keep a hand-rolled `state !== cookieState` check beside this — two expressions of one rule, and the copy is the one that goes stale.
1154
+
998
1155
  The **first** SSO login provisions the local user + org (the same routine password signup uses); later logins find the existing user by email. That's why the routes live in *your app*, not in the plugin — the provisioning and session model are yours; the plugin only supplies the reusable OAuth primitives. Match the `voltro:session` cookie's attributes (e.g. `Secure`, `HttpOnly`) to your app's existing session cookie.
999
1156
 
1000
1157
  ### Configuration
@@ -1738,7 +1895,9 @@ const TenantSwitcher = () => {
1738
1895
  }
1739
1896
  ```
1740
1897
 
1741
- `POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket — rebinds the connection's Subject so active subscriptions re-scope to the new tenant without a reconnect. No re-auth.
1898
+ `POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket — presents that cookie on the connection, so calls made afterwards resolve against the new tenant without a reconnect. No re-auth.
1899
+
1900
+ Calls, not subscriptions: a subscription already open on that connection was authorized under the previous cookie and keeps running until the client re-subscribes. Re-mount the subscribing components (or reload) if the switch has to change what they show.
1742
1901
 
1743
1902
  ## Anti-patterns
1744
1903
 
@@ -2001,6 +2160,99 @@ A subscription is a long-lived grant. Its guards — scope and relationship alik
2001
2160
  — are re-evaluated before each delivery, so revoking a relation mid-session ends
2002
2161
  the stream with the typed error instead of continuing to push rows.
2003
2162
 
2163
+ ## Every procedure decides — `guards:` or `openAccess:`
2164
+
2165
+ A procedure that declares neither is **refused at boot**. `guards:` used to
2166
+ default to "allowed", so a discovered `*.query.ts` / `*.mutation.ts` /
2167
+ `*.action.ts` / `*.stream.ts` with no guard was callable by **any authenticated
2168
+ session** — the door defaulted open, and nothing said so.
2169
+
2170
+ The same rule covers **events**: a `*.event.ts` declaration is a wire surface
2171
+ too, and one that declares neither `guards:` nor `openAccess:` was silently
2172
+ subscribable by anyone who could open the socket. `defineEvent` takes the same
2173
+ two answers — see [Events](/docs/data/events).
2174
+
2175
+ There are exactly two answers, and they are not the same claim:
2176
+
2177
+ ```ts
2178
+ export const invoiceList = defineQuery({
2179
+ name: 'invoices.list',
2180
+ guards: [{ scope: 'invoices:read' }], // the caller must hold a scope
2181
+
2182
+ })
2183
+
2184
+ export const pricing = defineQuery({
2185
+ name: 'pricing.current',
2186
+ openAccess: 'public pricing page — reads no caller data', // anyone may call it, and why
2187
+
2188
+ })
2189
+ ```
2190
+
2191
+ `openAccess` takes a **reason, not a boolean**. That is the point of it: the
2192
+ reason is what a reviewer reads later, and it is what makes *"we decided this is
2193
+ open"* distinguishable from *"nobody looked"*. Without such a marker, the only
2194
+ way to satisfy a default-deny gate is to add a guard — so every genuinely open
2195
+ endpoint grows a scope every caller already holds. That rubber stamp reads as
2196
+ protection and enforces nothing, which is a worse state than the hole it
2197
+ replaces.
2198
+
2199
+ A procedure that only other **server** code calls wants neither: mark it
2200
+ `internal: true` and it leaves the wire entirely (no client-group entry, no
2201
+ route). `openAccess` on an internal procedure is refused — there is no wire
2202
+ surface to make a decision about.
2203
+
2204
+ ### The gate
2205
+
2206
+ `voltro dev` and `voltro serve` run the same check at boot, and `voltro doctor`
2207
+ runs it as a preflight (non-zero exit; `accessDecisions` in `--json`). The
2208
+ refusal names **every** offending procedure with its file, because the fix is one
2209
+ pass over the whole list:
2210
+
2211
+ ```
2212
+ [access] 3 wire-exposed procedures or events declare no access decision, and
2213
+ this app runs with `security.defaultDeny`:
2214
+
2215
+ invoices.list (query)
2216
+ src/api/invoices.query.ts
2217
+
2218
+ ```
2219
+
2220
+ `voltro doctor` is the fastest way to get the list without a failed boot.
2221
+
2222
+ ### Turning it off
2223
+
2224
+ One field, in `app.config.ts`, for the whole app:
2225
+
2226
+ ```ts
2227
+ export default defineApiConfig({
2228
+ security: { defaultDeny: false },
2229
+ })
2230
+ ```
2231
+
2232
+ There is deliberately **no environment variable** for this. The only direction
2233
+ anyone reaches for is off, and an env var is how a security default becomes
2234
+ permanently off in one CI job with no diff to review. `voltro doctor` keeps
2235
+ listing the undecided procedures while it is off, marked advisory.
2236
+
2237
+ ### What it does NOT cover — and what covers the rest
2238
+
2239
+ The **boot** gate reads **your app's own** discovered procedures and events. The
2240
+ procedures a plugin declares are the plugin author's decision and are not judged
2241
+ at boot — adopting this does not turn into a bug report against a plugin you
2242
+ installed.
2243
+
2244
+ They are not unpoliced, though: the same `security.defaultDeny` is also enforced
2245
+ **per request in the dispatch spine**, as defense in depth. A descriptor that
2246
+ reaches the wire with no access decision — a plugin route, a hand-bound
2247
+ descriptor — is refused with the same typed `ScopeError` before the transaction
2248
+ opens or any external I/O runs. Every first-party plugin route declares its own
2249
+ decision (a scope where a real authority exists — e.g. `billing:manage`,
2250
+ `storage:browse` — or `openAccess` with the reason on the routes that are
2251
+ self-scoped or anonymous-capable by design; each plugin's page lists them). A
2252
+ third-party plugin that declares neither on a route will see that route refused
2253
+ per-request under default-deny — the fix is one field on the route, exactly as
2254
+ for your own procedures.
2255
+
2004
2256
  ## Decide — `can` / `assertCan`
2005
2257
 
2006
2258
  `can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
@@ -2252,6 +2504,36 @@ error frame, rather than delivering an empty snapshot — an empty snapshot on a
2252
2504
  live subscription reads to a client as "every row you could see was just
2253
2505
  deleted". Make sure your subscription error handling surfaces it.
2254
2506
 
2507
+ ## A path that cannot apply the filter refuses, rather than serving rows
2508
+
2509
+ If a filter is registered and a scoped store is built without a resolved scope,
2510
+ the store **throws**. It does not fall back to unfiltered reads.
2511
+
2512
+ That fallback used to exist, and it is the reason this section does. A team
2513
+ measured four read paths returning every row of the tenant to every employee,
2514
+ on both transports, with `row filter registered` in the boot log and a green
2515
+ test suite. The registration lived in a module-local variable, so an app's
2516
+ `*.startup.tsx` and the framework's request pipeline could hold two different
2517
+ copies of it — the serve bundle inlines the framework while app modules stay
2518
+ external, and a strict pnpm tree can resolve one version into two directories.
2519
+ The pipeline read "no filter registered", which was indistinguishable from an
2520
+ app that has none, and served everything.
2521
+
2522
+ The registration is process-global for real now (`globalThis`, so every copy
2523
+ shares one cell), and the ambiguity that made the failure silent is gone: those
2524
+ two readings are different claims and only one of them is a decision.
2525
+
2526
+ If a code path is deliberately unfiltered — a system sweep, a migration, a
2527
+ seeding helper in a test — say so:
2528
+
2529
+ ```ts
2530
+ wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_FILTER })
2531
+ ```
2532
+
2533
+ `runAsSystem`, change-stream subscribers and the webhook trigger context already
2534
+ do this; a system subject bypasses row filters by design, and it is now written
2535
+ down rather than inferred from an absence.
2536
+
2255
2537
  ## What does *not* bypass it
2256
2538
 
2257
2539
  | | Bypasses the row filter? |
@@ -276,6 +276,7 @@ Add a `cache` field to a `defineQuery` and the framework caches the query's **se
276
276
  export const listTodos = defineQuery({
277
277
  name: 'todos.listByTenant',
278
278
  source: 'todos',
279
+ guards: [{ scope: 'todos:read' }], // who may call it
279
280
  input: Schema.Struct({ done: Schema.optional(Schema.Boolean) }),
280
281
  output: Todo,
281
282
  cache: { ttl: '30s', swr: '5m', scope: 'subject' }, // tenant-filtered → subject
@@ -299,6 +300,8 @@ The matching `.<primitive>.server.ts` is **unchanged** — caching is a descript
299
300
  export const listCountries = defineQuery({
300
301
  name: 'reference.countries',
301
302
  source: 'countries',
303
+ openAccess: 'a static country reference list — the same rows for every caller, '
304
+ + 'no tenant rows and nothing caller-derived',
302
305
  input: Schema.Void,
303
306
  output: Country,
304
307
  cache: { ttl: '1h', scope: 'global' },
@@ -308,12 +311,15 @@ export const listCountries = defineQuery({
308
311
  export const last12Months = defineQuery({
309
312
  name: 'globalStatistics.last12Months',
310
313
  source: ['invoices', 'employees'],
314
+ guards: [{ scope: 'analytics:read' }],
311
315
  input: Schema.Struct({}),
312
316
  output: Stats,
313
317
  cache: { ttl: '5m', scope: 'tenant' },
314
318
  })
315
319
  ```
316
320
 
321
+ **`cache.scope` and the access decision are two questions, and they line up here by coincidence rather than by rule.** Every wire-exposed query must also declare `guards:`, `openAccess: '<reason>'` or `internal: true` or the boot refuses it — and the reasoning that made `scope: 'global'` correct for `reference.countries` (the same rows for everyone, nothing caller-derived) is the same reasoning that makes `openAccess` honest there. It does not generalise: a query can be perfectly cacheable per subject *and* need a scope to call, which is `todos.listByTenant` above. Decide them separately; see [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
322
+
317
323
  `'tenant'` exists because the other two were the only options and neither fit an org-wide figure: `'subject'` recomputes it per person — eighteen identical computations of the same nine-table statistic for an eighteen-person org — and `'global'` shares one entry across tenant boundaries, which for data derived from `subject.tenantId` is not a cache but a leak.
318
324
 
319
325
  A caller with no `tenantId` (an anonymous or system subject) **bypasses** a `'tenant'` cache rather than sharing a null-keyed entry.