@voltro/cli 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 (626) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +20236 -0
  6. package/bin/voltro.mjs +43 -0
  7. package/dist/bin.d.ts +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/commands-CfPH2Wf4.js +18061 -0
  10. package/dist/frameworkInspectState-CX2250XB.js +86 -0
  11. package/dist/index.d.ts +48 -0
  12. package/dist/index.js +5 -0
  13. package/dist/inspectState.d.ts +116 -0
  14. package/dist/inspectState.js +2 -0
  15. package/dist/startup.d.ts +26 -0
  16. package/dist/startup.js +2 -0
  17. package/dist/startupRunner-CRhuUl91.js +71 -0
  18. package/package.json +88 -0
  19. package/templates/AGENTS.core.md +258 -0
  20. package/templates/AGENTS.md +351 -0
  21. package/templates/agent-docs/_index.md +93 -0
  22. package/templates/agent-docs/_manifest.json +655 -0
  23. package/templates/agent-docs/ai.md +1845 -0
  24. package/templates/agent-docs/authentication.md +1788 -0
  25. package/templates/agent-docs/caching.md +624 -0
  26. package/templates/agent-docs/cli.md +1650 -0
  27. package/templates/agent-docs/configuration.md +295 -0
  28. package/templates/agent-docs/data.md +2172 -0
  29. package/templates/agent-docs/database/advancedqueries.md +1583 -0
  30. package/templates/agent-docs/database/columntypes.md +1200 -0
  31. package/templates/agent-docs/database/hosting.md +881 -0
  32. package/templates/agent-docs/database/migrations.md +2938 -0
  33. package/templates/agent-docs/database/misc.md +270 -0
  34. package/templates/agent-docs/database/overview.md +108 -0
  35. package/templates/agent-docs/database/querying.md +1622 -0
  36. package/templates/agent-docs/database/scaling.md +331 -0
  37. package/templates/agent-docs/database/schema.md +1458 -0
  38. package/templates/agent-docs/database/seedsdialects.md +1235 -0
  39. package/templates/agent-docs/database/transactions.md +285 -0
  40. package/templates/agent-docs/deployment.md +999 -0
  41. package/templates/agent-docs/internationalization.md +359 -0
  42. package/templates/agent-docs/introduction.md +438 -0
  43. package/templates/agent-docs/multi-tenancy.md +610 -0
  44. package/templates/agent-docs/observability.md +350 -0
  45. package/templates/agent-docs/plugins.md +1273 -0
  46. package/templates/agent-docs/reference.md +978 -0
  47. package/templates/agent-docs/routing.md +1553 -0
  48. package/templates/agent-docs/scheduling.md +666 -0
  49. package/templates/agent-docs/schema-driven-ui.md +607 -0
  50. package/templates/agent-docs/security.md +42 -0
  51. package/templates/agent-docs/templates/apibackends.md +3214 -0
  52. package/templates/agent-docs/templates/appshells.md +2062 -0
  53. package/templates/agent-docs/templates/custom.md +128 -0
  54. package/templates/agent-docs/templates/overview.md +122 -0
  55. package/templates/agent-docs/templates/serverless.md +315 -0
  56. package/templates/agent-docs/testing.md +376 -0
  57. package/templates/agent-docs/workflows.md +1351 -0
  58. package/templates/apps/api-ai/README.md +105 -0
  59. package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
  60. package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
  61. package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
  62. package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
  63. package/templates/apps/api-ai/app.config.ts +57 -0
  64. package/templates/apps/api-ai/database/schema.ts +67 -0
  65. package/templates/apps/api-ai/package.json +27 -0
  66. package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
  67. package/templates/apps/api-ai/template.json +6 -0
  68. package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
  69. package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
  70. package/templates/apps/api-ai/tsconfig.json +5 -0
  71. package/templates/apps/api-auth/.env +17 -0
  72. package/templates/apps/api-auth/README.md +109 -0
  73. package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
  74. package/templates/apps/api-auth/actions/me.action.ts +19 -0
  75. package/templates/apps/api-auth/app.config.ts +59 -0
  76. package/templates/apps/api-auth/database/schema.ts +26 -0
  77. package/templates/apps/api-auth/package.json +28 -0
  78. package/templates/apps/api-auth/template.json +6 -0
  79. package/templates/apps/api-auth/tests/me.test.ts +37 -0
  80. package/templates/apps/api-auth/tsconfig.json +5 -0
  81. package/templates/apps/api-backend/README.md +25 -0
  82. package/templates/apps/api-backend/app.config.ts +39 -0
  83. package/templates/apps/api-backend/database/schema.ts +57 -0
  84. package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
  85. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
  86. package/templates/apps/api-backend/package.json +28 -0
  87. package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
  88. package/templates/apps/api-backend/queries/notes.query.ts +20 -0
  89. package/templates/apps/api-backend/template.json +6 -0
  90. package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
  91. package/templates/apps/api-backend/tsconfig.json +5 -0
  92. package/templates/apps/api-backend-deactivation/README.md +49 -0
  93. package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
  94. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
  95. package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
  96. package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
  97. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
  98. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
  99. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
  100. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
  101. package/templates/apps/api-backend-deactivation/package.json +28 -0
  102. package/templates/apps/api-backend-deactivation/template.json +6 -0
  103. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
  104. package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
  105. package/templates/apps/api-backend-mail/README.md +39 -0
  106. package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
  107. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
  108. package/templates/apps/api-backend-mail/app.config.ts +34 -0
  109. package/templates/apps/api-backend-mail/database/schema.ts +57 -0
  110. package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
  111. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
  112. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
  113. package/templates/apps/api-backend-mail/package.json +30 -0
  114. package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
  115. package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
  116. package/templates/apps/api-backend-mail/template.json +6 -0
  117. package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
  118. package/templates/apps/api-backend-mail/tsconfig.json +5 -0
  119. package/templates/apps/api-backend-mariadb/.env.example +35 -0
  120. package/templates/apps/api-backend-mariadb/README.md +31 -0
  121. package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
  122. package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
  123. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
  124. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
  125. package/templates/apps/api-backend-mariadb/package.json +30 -0
  126. package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
  127. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
  128. package/templates/apps/api-backend-mariadb/template.json +6 -0
  129. package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
  130. package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
  131. package/templates/apps/api-backend-storage/README.md +86 -0
  132. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
  133. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
  134. package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
  135. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
  136. package/templates/apps/api-backend-storage/app.config.ts +38 -0
  137. package/templates/apps/api-backend-storage/database/schema.ts +57 -0
  138. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
  139. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
  140. package/templates/apps/api-backend-storage/package.json +27 -0
  141. package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
  142. package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
  143. package/templates/apps/api-backend-storage/template.json +6 -0
  144. package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
  145. package/templates/apps/api-backend-storage/tsconfig.json +5 -0
  146. package/templates/apps/api-data-advanced/.env +17 -0
  147. package/templates/apps/api-data-advanced/README.md +88 -0
  148. package/templates/apps/api-data-advanced/app.config.ts +56 -0
  149. package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
  150. package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
  151. package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
  152. package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
  153. package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
  154. package/templates/apps/api-data-advanced/database/index.ts +23 -0
  155. package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
  156. package/templates/apps/api-data-advanced/package.json +28 -0
  157. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
  158. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
  159. package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
  160. package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
  161. package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
  162. package/templates/apps/api-data-advanced/template.json +6 -0
  163. package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
  164. package/templates/apps/api-data-advanced/tsconfig.json +5 -0
  165. package/templates/apps/api-durable/README.md +81 -0
  166. package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
  167. package/templates/apps/api-durable/app.config.ts +41 -0
  168. package/templates/apps/api-durable/database/schema.ts +65 -0
  169. package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
  170. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
  171. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
  172. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
  173. package/templates/apps/api-durable/package.json +28 -0
  174. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
  175. package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
  176. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
  177. package/templates/apps/api-durable/template.json +6 -0
  178. package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
  179. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
  180. package/templates/apps/api-durable/tsconfig.json +5 -0
  181. package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
  182. package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
  183. package/templates/apps/api-feature-flags/README.md +63 -0
  184. package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
  185. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
  186. package/templates/apps/api-feature-flags/app.config.ts +41 -0
  187. package/templates/apps/api-feature-flags/database/schema.ts +37 -0
  188. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
  189. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
  190. package/templates/apps/api-feature-flags/package.json +29 -0
  191. package/templates/apps/api-feature-flags/template.json +6 -0
  192. package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
  193. package/templates/apps/api-feature-flags/tsconfig.json +5 -0
  194. package/templates/apps/api-governance/.env +4 -0
  195. package/templates/apps/api-governance/README.md +61 -0
  196. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
  197. package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
  198. package/templates/apps/api-governance/app.config.ts +35 -0
  199. package/templates/apps/api-governance/database/schema.ts +41 -0
  200. package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
  201. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
  202. package/templates/apps/api-governance/package.json +29 -0
  203. package/templates/apps/api-governance/template.json +6 -0
  204. package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
  205. package/templates/apps/api-governance/tsconfig.json +5 -0
  206. package/templates/apps/api-kv/README.md +100 -0
  207. package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
  208. package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
  209. package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
  210. package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
  211. package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
  212. package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
  213. package/templates/apps/api-kv/app.config.ts +48 -0
  214. package/templates/apps/api-kv/database/schema.ts +62 -0
  215. package/templates/apps/api-kv/package.json +28 -0
  216. package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
  217. package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
  218. package/templates/apps/api-kv/template.json +6 -0
  219. package/templates/apps/api-kv/tests/sync.test.ts +103 -0
  220. package/templates/apps/api-kv/tsconfig.json +5 -0
  221. package/templates/apps/api-moderation/README.md +46 -0
  222. package/templates/apps/api-moderation/app.config.ts +33 -0
  223. package/templates/apps/api-moderation/database/schema.ts +45 -0
  224. package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
  225. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
  226. package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
  227. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
  228. package/templates/apps/api-moderation/package.json +29 -0
  229. package/templates/apps/api-moderation/template.json +6 -0
  230. package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
  231. package/templates/apps/api-moderation/tsconfig.json +5 -0
  232. package/templates/apps/api-observability/README.md +106 -0
  233. package/templates/apps/api-observability/app.config.ts +32 -0
  234. package/templates/apps/api-observability/database/schema.ts +38 -0
  235. package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
  237. package/templates/apps/api-observability/package.json +29 -0
  238. package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
  239. package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
  240. package/templates/apps/api-observability/template.json +6 -0
  241. package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
  242. package/templates/apps/api-observability/tsconfig.json +5 -0
  243. package/templates/apps/api-ratelimit/README.md +49 -0
  244. package/templates/apps/api-ratelimit/app.config.ts +42 -0
  245. package/templates/apps/api-ratelimit/database/schema.ts +37 -0
  246. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
  247. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
  248. package/templates/apps/api-ratelimit/package.json +29 -0
  249. package/templates/apps/api-ratelimit/template.json +6 -0
  250. package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
  251. package/templates/apps/api-ratelimit/tsconfig.json +5 -0
  252. package/templates/apps/api-rbac/README.md +59 -0
  253. package/templates/apps/api-rbac/app.config.ts +48 -0
  254. package/templates/apps/api-rbac/database/schema.ts +37 -0
  255. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
  256. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
  257. package/templates/apps/api-rbac/package.json +29 -0
  258. package/templates/apps/api-rbac/template.json +6 -0
  259. package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
  260. package/templates/apps/api-rbac/tsconfig.json +5 -0
  261. package/templates/apps/api-rest/README.md +85 -0
  262. package/templates/apps/api-rest/app.config.ts +70 -0
  263. package/templates/apps/api-rest/database/schema.ts +52 -0
  264. package/templates/apps/api-rest/lib/product.ts +30 -0
  265. package/templates/apps/api-rest/package.json +27 -0
  266. package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
  267. package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
  268. package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
  269. package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
  270. package/templates/apps/api-rest/template.json +6 -0
  271. package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
  272. package/templates/apps/api-rest/tsconfig.json +5 -0
  273. package/templates/apps/api-saas/README.md +106 -0
  274. package/templates/apps/api-saas/app.config.ts +51 -0
  275. package/templates/apps/api-saas/database/schema.ts +50 -0
  276. package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
  277. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
  278. package/templates/apps/api-saas/package.json +31 -0
  279. package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
  280. package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
  281. package/templates/apps/api-saas/template.json +6 -0
  282. package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
  283. package/templates/apps/api-saas/tsconfig.json +5 -0
  284. package/templates/apps/api-search/README.md +68 -0
  285. package/templates/apps/api-search/app.config.ts +31 -0
  286. package/templates/apps/api-search/database/schema.ts +47 -0
  287. package/templates/apps/api-search/lib/search.ts +27 -0
  288. package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
  289. package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
  290. package/templates/apps/api-search/package.json +29 -0
  291. package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
  292. package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
  293. package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
  294. package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
  295. package/templates/apps/api-search/template.json +6 -0
  296. package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
  297. package/templates/apps/api-search/tsconfig.json +5 -0
  298. package/templates/apps/api-versioning/README.md +51 -0
  299. package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
  300. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
  301. package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
  302. package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
  303. package/templates/apps/api-versioning/app.config.ts +24 -0
  304. package/templates/apps/api-versioning/database/schema.ts +38 -0
  305. package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
  306. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
  307. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
  308. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
  309. package/templates/apps/api-versioning/package.json +29 -0
  310. package/templates/apps/api-versioning/template.json +6 -0
  311. package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
  312. package/templates/apps/api-versioning/tsconfig.json +5 -0
  313. package/templates/apps/api-webhooks/.env +6 -0
  314. package/templates/apps/api-webhooks/README.md +106 -0
  315. package/templates/apps/api-webhooks/app.config.ts +16 -0
  316. package/templates/apps/api-webhooks/database/schema.ts +49 -0
  317. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
  318. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
  319. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
  320. package/templates/apps/api-webhooks/package.json +28 -0
  321. package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
  322. package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
  323. package/templates/apps/api-webhooks/template.json +6 -0
  324. package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
  325. package/templates/apps/api-webhooks/tsconfig.json +5 -0
  326. package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
  327. package/templates/apps/changelog/README.md +77 -0
  328. package/templates/apps/changelog/app.config.ts +27 -0
  329. package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
  330. package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
  331. package/templates/apps/changelog/package.json +30 -0
  332. package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
  333. package/templates/apps/changelog/src/globals.css +66 -0
  334. package/templates/apps/changelog/src/globals.d.ts +16 -0
  335. package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
  336. package/templates/apps/changelog/src/lib/locale.ts +55 -0
  337. package/templates/apps/changelog/src/lib/releases.ts +21 -0
  338. package/templates/apps/changelog/src/locales/de.ts +22 -0
  339. package/templates/apps/changelog/src/locales/en.ts +30 -0
  340. package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
  341. package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
  342. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
  343. package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
  344. package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
  345. package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
  346. package/templates/apps/changelog/src/pages/index.tsx +55 -0
  347. package/templates/apps/changelog/src/pages/layout.tsx +84 -0
  348. package/templates/apps/changelog/template.json +6 -0
  349. package/templates/apps/changelog/tsconfig.json +11 -0
  350. package/templates/apps/edge-functions/README.md +55 -0
  351. package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
  352. package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
  353. package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
  354. package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
  355. package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
  356. package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
  357. package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
  358. package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
  359. package/templates/apps/edge-functions/package.json +21 -0
  360. package/templates/apps/edge-functions/template.json +6 -0
  361. package/templates/apps/edge-functions/tsconfig.json +10 -0
  362. package/templates/apps/frontend-admin/README.md +40 -0
  363. package/templates/apps/frontend-admin/app.config.ts +37 -0
  364. package/templates/apps/frontend-admin/package.json +30 -0
  365. package/templates/apps/frontend-admin/src/config.ts +8 -0
  366. package/templates/apps/frontend-admin/src/globals.css +76 -0
  367. package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
  368. package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
  369. package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
  370. package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
  371. package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
  372. package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
  373. package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
  374. package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
  375. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
  376. package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
  377. package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
  378. package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
  379. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
  380. package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
  381. package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
  382. package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
  383. package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
  384. package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
  385. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
  386. package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
  387. package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
  388. package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
  389. package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
  390. package/templates/apps/frontend-admin/template.json +6 -0
  391. package/templates/apps/frontend-admin/tsconfig.json +11 -0
  392. package/templates/apps/frontend-app/README.md +98 -0
  393. package/templates/apps/frontend-app/app.config.ts +43 -0
  394. package/templates/apps/frontend-app/package.json +30 -0
  395. package/templates/apps/frontend-app/src/locales/de.ts +36 -0
  396. package/templates/apps/frontend-app/src/locales/en.ts +43 -0
  397. package/templates/apps/frontend-app/src/locales/index.ts +15 -0
  398. package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
  399. package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
  400. package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
  401. package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
  402. package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
  403. package/templates/apps/frontend-app/template.json +6 -0
  404. package/templates/apps/frontend-app/tsconfig.json +11 -0
  405. package/templates/apps/frontend-blank/README.md +18 -0
  406. package/templates/apps/frontend-blank/app.config.ts +29 -0
  407. package/templates/apps/frontend-blank/package.json +29 -0
  408. package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
  409. package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
  410. package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
  411. package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
  412. package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
  413. package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
  414. package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
  415. package/templates/apps/frontend-blank/template.json +6 -0
  416. package/templates/apps/frontend-blank/tsconfig.json +11 -0
  417. package/templates/apps/frontend-contact/README.md +65 -0
  418. package/templates/apps/frontend-contact/app.config.ts +25 -0
  419. package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
  420. package/templates/apps/frontend-contact/package.json +33 -0
  421. package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
  422. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
  423. package/templates/apps/frontend-contact/src/config.ts +12 -0
  424. package/templates/apps/frontend-contact/src/globals.css +84 -0
  425. package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
  426. package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
  427. package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
  428. package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
  429. package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
  430. package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
  431. package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
  432. package/templates/apps/frontend-contact/template.json +6 -0
  433. package/templates/apps/frontend-contact/tsconfig.json +11 -0
  434. package/templates/apps/frontend-dashboard/README.md +54 -0
  435. package/templates/apps/frontend-dashboard/app.config.ts +39 -0
  436. package/templates/apps/frontend-dashboard/package.json +29 -0
  437. package/templates/apps/frontend-dashboard/src/config.ts +8 -0
  438. package/templates/apps/frontend-dashboard/src/globals.css +72 -0
  439. package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
  440. package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
  441. package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
  442. package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
  443. package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
  444. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
  445. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
  446. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
  447. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
  448. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
  449. package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
  450. package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
  451. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
  452. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
  453. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
  454. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
  455. package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
  456. package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
  457. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
  458. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
  459. package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
  460. package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
  461. package/templates/apps/frontend-dashboard/template.json +6 -0
  462. package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
  463. package/templates/apps/frontend-docs/README.md +19 -0
  464. package/templates/apps/frontend-docs/app.config.ts +25 -0
  465. package/templates/apps/frontend-docs/package.json +29 -0
  466. package/templates/apps/frontend-docs/src/globals.css +32 -0
  467. package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
  468. package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
  469. package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
  470. package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
  471. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
  472. package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
  473. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
  474. package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
  475. package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
  476. package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
  477. package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
  478. package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
  479. package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
  480. package/templates/apps/frontend-docs/template.json +6 -0
  481. package/templates/apps/frontend-docs/tsconfig.json +11 -0
  482. package/templates/apps/frontend-i18n/README.md +61 -0
  483. package/templates/apps/frontend-i18n/app.config.ts +33 -0
  484. package/templates/apps/frontend-i18n/package.json +28 -0
  485. package/templates/apps/frontend-i18n/src/globals.css +46 -0
  486. package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
  487. package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
  488. package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
  489. package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
  490. package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
  491. package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
  492. package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
  493. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
  494. package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
  495. package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
  496. package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
  497. package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
  498. package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
  499. package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
  500. package/templates/apps/frontend-i18n/template.json +6 -0
  501. package/templates/apps/frontend-i18n/tsconfig.json +11 -0
  502. package/templates/apps/frontend-landing/README.md +17 -0
  503. package/templates/apps/frontend-landing/app.config.ts +25 -0
  504. package/templates/apps/frontend-landing/package.json +29 -0
  505. package/templates/apps/frontend-landing/src/globals.css +23 -0
  506. package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
  507. package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
  508. package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
  509. package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
  510. package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
  511. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
  512. package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
  513. package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
  514. package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
  515. package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
  516. package/templates/apps/frontend-landing/template.json +6 -0
  517. package/templates/apps/frontend-landing/tsconfig.json +11 -0
  518. package/templates/apps/frontend-spa/README.md +45 -0
  519. package/templates/apps/frontend-spa/app.config.ts +27 -0
  520. package/templates/apps/frontend-spa/package.json +29 -0
  521. package/templates/apps/frontend-spa/src/globals.css +84 -0
  522. package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
  523. package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
  524. package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
  525. package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
  526. package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
  527. package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
  528. package/templates/apps/frontend-spa/template.json +6 -0
  529. package/templates/apps/frontend-spa/tsconfig.json +11 -0
  530. package/templates/apps/frontend-ssr/README.md +68 -0
  531. package/templates/apps/frontend-ssr/app.config.ts +32 -0
  532. package/templates/apps/frontend-ssr/package.json +29 -0
  533. package/templates/apps/frontend-ssr/src/globals.css +67 -0
  534. package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
  535. package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
  536. package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
  537. package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
  538. package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
  539. package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
  540. package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
  541. package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
  542. package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
  543. package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
  544. package/templates/apps/frontend-ssr/template.json +6 -0
  545. package/templates/apps/frontend-ssr/tsconfig.json +11 -0
  546. package/templates/apps/frontend-ssr-api/README.md +50 -0
  547. package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
  548. package/templates/apps/frontend-ssr-api/package.json +30 -0
  549. package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
  550. package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
  551. package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
  552. package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
  553. package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
  554. package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
  555. package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
  556. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
  557. package/templates/apps/frontend-ssr-api/template.json +6 -0
  558. package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
  559. package/templates/apps/frontend-static-blog/README.md +49 -0
  560. package/templates/apps/frontend-static-blog/app.config.ts +34 -0
  561. package/templates/apps/frontend-static-blog/package.json +28 -0
  562. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
  563. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
  564. package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
  565. package/templates/apps/frontend-static-blog/src/globals.css +75 -0
  566. package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
  567. package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
  568. package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
  569. package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
  570. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
  571. package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
  572. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
  573. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
  574. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
  575. package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
  576. package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
  577. package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
  578. package/templates/apps/frontend-static-blog/template.json +6 -0
  579. package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
  580. package/templates/baselines/bare/.env.example +37 -0
  581. package/templates/baselines/bare/README.md +47 -0
  582. package/templates/baselines/bare/baseline.json +31 -0
  583. package/templates/baselines/bare/deploy/README.md +43 -0
  584. package/templates/baselines/bare/deploy/voltro.service.example +36 -0
  585. package/templates/baselines/compose/.env.example +46 -0
  586. package/templates/baselines/compose/README.md +69 -0
  587. package/templates/baselines/compose/baseline.json +51 -0
  588. package/templates/baselines/compose/docker/.dockerignore +38 -0
  589. package/templates/baselines/compose/docker/api.Dockerfile +57 -0
  590. package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
  591. package/templates/baselines/compose/docker/web.Dockerfile +59 -0
  592. package/templates/baselines/compose/docker-compose.dev.yml +89 -0
  593. package/templates/baselines/compose/docker-compose.prod.yml +87 -0
  594. package/templates/baselines/compose/docker-compose.yml +41 -0
  595. package/templates/baselines/compose-mariadb/.env.example +57 -0
  596. package/templates/baselines/compose-mariadb/README.md +78 -0
  597. package/templates/baselines/compose-mariadb/baseline.json +51 -0
  598. package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
  599. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
  600. package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
  601. package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
  602. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
  603. package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
  604. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
  605. package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
  606. package/templates/baselines/helm/.env.example +39 -0
  607. package/templates/baselines/helm/README.md +98 -0
  608. package/templates/baselines/helm/baseline.json +53 -0
  609. package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
  610. package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
  611. package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
  612. package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
  613. package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
  614. package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
  615. package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
  616. package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
  617. package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
  618. package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
  619. package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
  620. package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
  621. package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
  622. package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
  623. package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
  624. package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
  625. package/templates/baselines/helm/deploy/README.md +94 -0
  626. package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
@@ -0,0 +1,1788 @@
1
+ # Authentication
2
+
3
+ > How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/authentication/overview.md -->
10
+ ## Overview
11
+
12
+ _How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol._
13
+
14
+ Voltro's auth story ships as a plugin: `@voltro/plugin-auth`. It's server-side primitives + client-side React glue, no third-party redirect dance, no managed-service dependency.
15
+
16
+ **Built-in (password):** password sign-up / sign-in, cookie-based sessions (multi-key rotation + sliding-window auto-renewal), rehash-on-verify, magic-link + password-reset flows, passkeys (WebAuthn), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, the typed `Subject`, TOTP/MFA enrolment, and a `SubjectProvider` React context + passkey ceremony helper (from the browser-safe `@voltro/plugin-auth/web` subpath). The single `authRoutesPlugin()` mounts every route under `/auth`.
17
+
18
+ **Pluggable identity:** auth is a [strategy](/docs/authentication/strategies) protocol, not just the password flow. First-party plugins ship for [WorkOS, Kinde, Clerk, Auth0, Supabase, and generic OIDC](/docs/authentication/external-idp); the shared `jwtBearerStrategy` covers any other OIDC provider; API-key callers use `apiKeyStrategy` from `@voltro/protocol/apikey`; you can write your own. Strategies stack, so you can run password + an external IdP side by side.
19
+
20
+ ## The pieces
21
+
22
+ ```text
23
+ ┌───────────────────────────────────────────────────────────────┐
24
+ │ Browser │
25
+ │ POST /auth/signin ────────┐ │
26
+ │ Cookie: voltro:session=... │ │
27
+ └───────────────────────────────┬─┘ │
28
+ │ Set-Cookie (HttpOnly, Secure)│
29
+ ▼ │
30
+ ┌───────────────────────────────────────────────────────────────┐
31
+ │ api app │
32
+ │ handleSignIn → verifyPassword → issueSession │
33
+ │ readSession → HMAC verify → Subject │
34
+ └───────────────────────────────────────────────────────────────┘
35
+
36
+ │ ctx.subject (typed)
37
+
38
+ ┌───────────────────────────────────────────────────────────────┐
39
+ │ Every query / mutation / workflow │
40
+ │ ctx.subject.id (string on a user; null on anonymous) │
41
+ │ ctx.subject.tenantId (string on a user; string|null anon) │
42
+ └───────────────────────────────────────────────────────────────┘
43
+ ```
44
+
45
+ ## What's in this section
46
+
47
+ - [Passwords](/docs/authentication/passwords) — hashing (scrypt), verification, why not bcrypt/argon2
48
+ - [Sessions](/docs/authentication/sessions) — issuing, verifying, the signed-cookie payload
49
+ - [The Subject](/docs/authentication/subject) — what's in `ctx.subject`, anonymous fallback
50
+ - [Auth strategies](/docs/authentication/strategies) — the `AuthStrategy` protocol, the resolver chain, writing your own
51
+ - [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`
52
+ - [HTTP handlers](/docs/authentication/handlers) — `handleSignIn`, `handleSignUp`, `handleSignOut`
53
+ - [User stores](/docs/authentication/user-stores) — `memoryUserStore`, `postgresUserStore`, custom backends
54
+ - [React on the web side](/docs/authentication/react) — `SubjectProvider`, `useSubject`, `RequireAuth`
55
+ - [Cookie security](/docs/authentication/cookies) — the production checklist
56
+
57
+ ## Schema tables
58
+
59
+ Two tables ship from `@voltro/plugin-auth/schema`:
60
+
61
+ ```ts
62
+ import { usersTable, sessionsTable } from '@voltro/plugin-auth/schema'
63
+
64
+ export const users = usersTable
65
+ export const sessions = sessionsTable
66
+ ```
67
+
68
+ `postgresUserStore` reads + writes the `users` table only — sessions are stateless signed cookies, so the default flow never touches `sessionsTable`. It ships for apps that opt into DB-backed session enumeration / "sign out other devices"; the stateless default leaves it empty. Don't redeclare these yourself — extend via additional columns in a sibling table linked by `userId`, not by modifying these.
69
+
70
+ ## Why password is the default (and IdPs are strategies, not the base)
71
+
72
+ You *can* run Clerk / WorkOS / Kinde — they're [first-party strategies](/docs/authentication/external-idp). But the built-in password flow is the default, and external IdPs plug in *underneath* the framework's own `Subject`, because:
73
+
74
+ - **Cookie sovereignty.** With the built-in flow your app owns the session — no redirect to a third-party SSO domain, no managed-service dependency for a feature every B2B SaaS needs.
75
+ - **Multi-tenant model is yours.** Even when an external IdP authenticates the user, `tenantId` and the typed `Subject` stay the framework's, not the vendor's. The IdP's claims ride along under `metadata.claims`; they don't replace the model. See [external IdPs](/docs/authentication/external-idp).
76
+ - **Self-host friendly.** Password auth needs nothing external. Reach for an IdP when you want SSO/SCIM/enterprise federation, not because the framework forces a service on you.
77
+
78
+ Strategies [stack](/docs/authentication/strategies), so adopting an IdP is additive — keep password sessions working while new sign-ups flow through the IdP, no big-bang cutover.
79
+
80
+
81
+
82
+ ---
83
+
84
+ <!-- source: en/authentication/passwords.md -->
85
+ ## Passwords
86
+
87
+ _scrypt-based password hashing, verification, timing-oracle defence, and why not bcrypt or argon2._
88
+
89
+ `@voltro/plugin-auth/password` exposes two functions: `hashPassword(plaintext)` and `verifyPassword(plaintext, hash)`. Both return **Effects**, not Promises — `hashPassword` fails with a typed `PasswordEmptyError | PasswordHashError`; `verifyPassword` returns `Effect<boolean>` (never fails — see below). Compose them in `Effect.gen`, or `Effect.runPromise` them at the edge.
90
+
91
+ ## Hashing on sign-up
92
+
93
+ ```ts
94
+ import { hashPassword } from '@voltro/plugin-auth'
95
+ import { Effect } from 'effect'
96
+
97
+ const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
98
+ // → 'scrypt$16384$8$1$<saltB64>$<derivedB64>'
99
+ ```
100
+
101
+ Store `hash` in the `passwordHash` column of your users table. **Never** store the plaintext.
102
+
103
+ ## Verifying on sign-in
104
+
105
+ ```ts
106
+ import { verifyPassword } from '@voltro/plugin-auth'
107
+ import { Effect } from 'effect'
108
+
109
+ const ok = await Effect.runPromise(verifyPassword(input.password, user.passwordHash))
110
+ if (!ok) throw new Unauthorised({})
111
+ ```
112
+
113
+ `verifyPassword` compares the derived key with `timingSafeEqual`, so timing-based guessing of a correct prefix is mitigated. It also returns `Effect<boolean>` with **no failure channel**: a malformed hash, a parse error, or a scrypt error all collapse to `false` (via `Effect.catchAll`). That's deliberate — surfacing "malformed" vs "mismatched" would let an attacker fingerprint stored-hash structure. You only ever branch on the boolean.
114
+
115
+ ## Why scrypt, not bcrypt / argon2
116
+
117
+ We picked scrypt deliberately. It's:
118
+
119
+ | Function | Native dep? | Memory-hard? | OWASP-recommended? |
120
+ |---|---|---|---|
121
+ | **scrypt** | ✗ (in `node:crypto`) | ✓ | ✓ |
122
+ | bcrypt | ✓ (`bcrypt` npm pkg) | ✗ | partially |
123
+ | argon2 | ✓ (`argon2` npm pkg) | ✓ | ✓ (preferred) |
124
+ | pbkdf2 | ✗ | ✗ | only with high iteration count |
125
+
126
+ - **No native dep** — `node:crypto.scrypt` is built into Node. argon2 needs a C addon that breaks on Alpine / Bun / serverless runtimes regularly.
127
+ - **Memory-hard** — defeats GPU brute-forcing the way bcrypt + pbkdf2 don't.
128
+ - **OWASP-acceptable** — not their top pick (argon2id is) but explicitly listed as safe.
129
+
130
+ ## Cost parameters
131
+
132
+ The framework uses scrypt with these defaults:
133
+
134
+ | Param | Value | Effect |
135
+ |---|---|---|
136
+ | `N` | `2^14` = 16384 | CPU + memory cost |
137
+ | `r` | 8 | block size |
138
+ | `p` | 1 | parallelism |
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.
141
+
142
+ 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
+
144
+ ## Timing-oracle defence
145
+
146
+ A naive sign-in implementation leaks "is this email registered?" via response time:
147
+
148
+ ```ts
149
+ // BAD — fast 401 for unknown email, slow 401 for wrong password
150
+ const user = await store.findByEmail(input.email)
151
+ if (!user) return error(401)
152
+ if (!await verifyPassword(input.password, user.passwordHash)) return error(401)
153
+ ```
154
+
155
+ The framework's `handleSignIn` always runs `verifyPassword` (with a dummy hash for the unknown-email case) so response times are uniform.
156
+
157
+ ```ts
158
+ import { handleSignIn } from '@voltro/plugin-auth'
159
+ // handleSignIn internally (Effect-gen):
160
+ // const user = yield* store.findByEmail(email)
161
+ // const ok = user
162
+ // ? yield* verifyPassword(password, user.passwordHash)
163
+ // : (yield* verifyPassword(password, freshDecoyHash), false)
164
+ // if (!user || !ok) return json(401, { error: 'invalid credentials' })
165
+ ```
166
+
167
+ Use `handleSignIn` instead of rolling your own — the timing-oracle gap is the kind of subtle bug that hides for years. Note it returns a `401 HandlerResult`, it does not throw a domain error.
168
+
169
+ ## Rehashing on parameter bump
170
+
171
+ When the framework updates the default cost parameters, existing hashes stay valid — `verifyPassword` reads `N`/`r`/`p` from the stored hash string itself (they're encoded inline as `scrypt$<N>$<r>$<p>$…`). Rehash-on-verify ships: `needsRehash(stored)` reports whether a hash is below the current cost, and `verifyPasswordWithRehash(plaintext, stored)` returns `{ valid, rehash? }` — when the password matches an under-cost hash, `rehash` is a freshly-minted replacement. `handleSignIn` wires this through `UserStore.updatePassword`, so a user's stored hash silently strengthens on their next login, no forced reset and no backfill.
172
+
173
+ ## Password policy
174
+
175
+ The framework doesn't enforce a policy at the hash layer — that's a UX decision. Enforce at the sign-up handler:
176
+
177
+ ```ts
178
+ import { Schema } from 'effect'
179
+
180
+ const PasswordSchema = Schema.String.pipe(
181
+ Schema.minLength(12), // OWASP minimum for non-2FA
182
+ Schema.pattern(/[a-z]/),
183
+ Schema.pattern(/[A-Z]/),
184
+ Schema.pattern(/[0-9]/),
185
+ )
186
+ ```
187
+
188
+ OWASP's current guidance: minimum length 8 (12 preferred), no upper-cap below 64, no required character classes if length ≥ 12. `handleSignUp` itself only enforces the 8-character floor; richer policy is yours to add at the route. Checking passwords against the [HIBP breach corpus](https://haveibeenpwned.com/API/v3) is a good idea — wire the k-anonymity range API into your sign-up route yourself; the plugin ships no HIBP helper.
189
+
190
+
191
+
192
+ ---
193
+
194
+ <!-- source: en/authentication/sessions.md -->
195
+ ## Sessions
196
+
197
+ _How sessions get issued, signed, and verified — the HMAC-SHA256 cookie payload format. For cookie attributes see Cookie security._
198
+
199
+ A Voltro session is a signed cookie. No server-side session store, no Redis dependency. The cookie *is* the session.
200
+
201
+ ## Format
202
+
203
+ ```
204
+ voltro:session=<base64url(payload)>.<base64url(signature)>
205
+ ```
206
+
207
+ Where:
208
+
209
+ - `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.
210
+ - `signature` is HMAC-SHA256(payload, AUTH_SECRET)
211
+
212
+ **Why HMAC, not JWT?**
213
+
214
+ 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.
215
+
216
+ ## Issuing
217
+
218
+ ```ts
219
+ import { issueSession } from '@voltro/plugin-auth'
220
+
221
+ // Positional args: (subject, secret, options?). Returns { value, setCookie }.
222
+ const { value, setCookie } = issueSession(
223
+ subject, // a full Subject, e.g. subjectFromUser(user)
224
+ AUTH_CONFIG.secret,
225
+ {
226
+ ttlSeconds: 60 * 60 * 24 * 7, // 7 days (default if omitted)
227
+ domain: '.your-product.com', // flat on options, not nested
228
+ secure: true, // defaults to true
229
+ },
230
+ )
231
+
232
+ // Set on the response
233
+ res.setHeader('set-cookie', setCookie)
234
+ ```
235
+
236
+ `setCookie` is the full `Set-Cookie` string — `voltro:session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`. `value` is the raw signed cookie value (`<payload>.<sig>`) if you need it directly. `IssueSessionOptions` carries only `ttlSeconds`, `domain`, and `secure` — `HttpOnly` and `SameSite=lax` are hardcoded in the builder and not configurable.
237
+
238
+ ## Reading
239
+
240
+ ```ts
241
+ import { readSession } from '@voltro/plugin-auth'
242
+
243
+ const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
244
+ // → { type: 'user', id, tenantId } | null
245
+ ```
246
+
247
+ Returns `null` for:
248
+
249
+ - Missing cookie
250
+ - Tampered payload (signature mismatch)
251
+ - Expired session (`exp < now`)
252
+
253
+ ## Clearing
254
+
255
+ ```ts
256
+ import { clearSessionCookie } from '@voltro/plugin-auth'
257
+
258
+ res.setHeader('set-cookie', clearSessionCookie({ domain: '.your-product.com', secure: true }))
259
+ // voltro:session=; HttpOnly; Secure; …; Max-Age=0
260
+ ```
261
+
262
+ `clearSessionCookie` takes the same `IssueSessionOptions` shape (`ttlSeconds` ignored; `domain` / `secure` honoured).
263
+
264
+ ## Secret rotation
265
+
266
+ Zero-downtime rotation is env-driven and applies to **every** verify path — the framework's rpc auth chain, the plugin's `/auth/*` routes, and `readSession` itself:
267
+
268
+ 1. Set the NEW secret as `VOLTRO_SESSION_SECRET` (current).
269
+ 2. Move the OLD secret to `VOLTRO_SESSION_SECRET_PREVIOUS`. Verification now tries current first, then previous — no live session is invalidated. (`VOLTRO_SESSION_KID` / `VOLTRO_SESSION_KID_PREVIOUS` are optional non-secret labels; they default to `k0` / `k-previous`.)
270
+ 3. Leave the window open for one max session lifetime, then drop the `_PREVIOUS` var. Cookies still signed with the old key stop verifying at that point — but they rarely exist by then, because any previous-key cookie that hits an authenticated `/auth/*` route (or `GET /auth/session`) is **re-issued under the current key** in the response.
271
+
272
+ Under the hood: `resolveSessionSecrets()` builds the `{ current, previous? }` keyed set from those env vars; `signSession` always signs with `current` (stamping its `kid`); `verifySessionKeyed` tries `current` then `previous`. The plugin's `AuthConfig` also accepts an explicit `secrets: SessionSecrets` when you'd rather not use env vars. The single-secret `resolveSessionSecret()` still exists for apps that don't rotate.
273
+
274
+ ## Cookie attributes
275
+
276
+ The session cookie ships `HttpOnly`, `SameSite=lax`, and `Path=/` hardcoded; `Secure`, `Domain`, and `Max-Age` (from `ttlSeconds`) come from the options you pass `issueSession`. The full attribute checklist — and why `SameSite=lax` is the right default for cross-origin sign-in — lives on the [Cookie security](/docs/authentication/cookies) page.
277
+
278
+ ## Multi-instance — no shared store needed
279
+
280
+ Because the session is the cookie + signature is deterministic from `(payload, secret)`, every api instance verifies independently. Scale to N replicas without a Redis cache; rolling deploys don't invalidate sessions.
281
+
282
+ For centralised revocation, the plugin writes a `sessions` row on every sign-in and exposes `handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` (mounted at `GET /auth/sessions` + `POST /auth/sessions/revoke` + `/sessions/revoke-others`). This is the device-management + "sign out other devices" surface — a stolen session can be killed without rotating the secret.
283
+
284
+ 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.
285
+
286
+ ## When to use a longer / shorter lifetime
287
+
288
+ | Use case | Lifetime |
289
+ |---|---|
290
+ | Consumer SaaS, low-stakes | 30 days |
291
+ | B2B SaaS, sensitive data | 7 days *(default)* |
292
+ | Admin / billing dashboards | 1 day + idle timeout |
293
+ | Banking / health / compliance | 30 minutes + sliding window |
294
+
295
+ Sliding-window auto-renewal ships: the session payload carries an `iat`, and `verifySessionKeyed` returns a `renew` flag once the session crosses the renewal threshold (default 70% of its lifetime). The plugin acts on that signal on **authenticated `/auth/*` responses** (and whenever the cookie verified under the previous rotation key): the response carries a fresh `Set-Cookie` signed with the current key and the session's *original* lifetime, and the `sessions` row's `expiresAt` slides forward with it. `GET /auth/session` is the probe built for this — it returns the current subject plus `renewed: true|false`, so a client that pings it periodically keeps an active session alive while an idle one still expires on schedule. (Renewal is an HTTP-response mechanism — rpc frames over the WebSocket can't set cookies.)
296
+
297
+ ## Verification details
298
+
299
+ ```ts
300
+ // What readSession does:
301
+ // 1. Parse cookie → { payloadB64, sigB64 }
302
+ // 2. Compute expected = hmacSha256(payloadB64, secret)
303
+ // 3. Constant-time compare expected vs. sigB64
304
+ // 4. Decode payload
305
+ // 5. Check exp (reject if exp < now)
306
+ // 6. Return the decoded Subject
307
+ ```
308
+
309
+ All steps use constant-time comparisons to defeat timing attacks. Implementation lives in `@voltro/plugin-auth/session.ts` — read it for the full story.
310
+
311
+ ## Browser-side considerations
312
+
313
+ The session cookie is `HttpOnly` — **the browser cannot read it from JS**. This is deliberate.
314
+
315
+ In React components on the SSR pass, you can decode the subject from the request via the layout (see [React on the web side](/docs/authentication/react)). On the client (post-hydration), there's no fresh cookie read — the layout's subject is the one you have until next page load.
316
+
317
+ That's enough for UI gating. For actions, the server verifies the cookie on every request — the client never needs to "have" the session, it just needs to send it (which the browser does automatically).
318
+
319
+
320
+
321
+ ---
322
+
323
+ <!-- source: en/authentication/subject.md -->
324
+ ## The Subject
325
+
326
+ _ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator._
327
+
328
+ Every server-side executor receives `ctx.subject` — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit `anonymous` subject), so app code never has to null-check before reading basic fields.
329
+
330
+ ## Shape
331
+
332
+ ```ts
333
+ type Subject =
334
+ | { type: 'user'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
335
+ | { type: 'apiKey'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
336
+ | { type: 'serviceAccount'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
337
+ | { type: 'system'; id: string; tenantId: null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
338
+ | { type: 'anonymous'; id: null; tenantId: string | null }
339
+ ```
340
+
341
+ `scopes` is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and `@voltro/plugin-rbac` compiles a caller's roles onto the same set. There is no `roles` field on the wire; roles are an authoring convenience that resolve to scopes.
342
+
343
+ The `type` discriminator narrows downstream fields:
344
+
345
+ ```ts
346
+ if (ctx.subject.type === 'user') {
347
+ // ctx.subject.id, tenantId are strings
348
+ }
349
+ if (ctx.subject.type === 'anonymous') {
350
+ // ctx.subject.tenantId is string | null; id is null
351
+ }
352
+ ```
353
+
354
+ ## Resolution
355
+
356
+ `AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:
357
+
358
+ 1. **Built-in password cookie** — `voltroPasswordStrategy` reads `voltro:session` and HMAC-verifies it. → `type: 'user'`.
359
+ 2. **API key** — `apiKeyStrategy` from `@voltro/protocol/apikey` maps `Authorization: Bearer <prefix>_<token>` → a scoped `apiKey` subject. This ships today; add it to the `auth.strategies` chain in `app.config.ts`. → `type: 'apiKey'`.
360
+ 3. **In-process system calls** — code running under `runAsSystem` (cron, workflows, backfills) carries a `system` subject; it's never produced from an inbound request. → `type: 'system'`.
361
+ 4. **Anonymous fallback** — nothing matched. The composer's fallback reads the `x-tenant` header and produces `anonymousSubject(tenant)`. → `type: 'anonymous'`.
362
+
363
+ An anonymous request with `x-tenant: <id>` therefore carries that tenant on the subject, scoping reads on public tables. The header is unauthenticated — never trust it for writes.
364
+
365
+ ## Common patterns
366
+
367
+ ### Require sign-in
368
+
369
+ ```ts
370
+ import { Schema } from 'effect'
371
+
372
+ class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}
373
+
374
+ export default async (input, ctx) => {
375
+ if (ctx.subject.type !== 'user') throw new Unauthorised()
376
+ // ctx.subject.id is narrowed to string
377
+ }
378
+ ```
379
+
380
+ ### Require sign-in + specific tenant
381
+
382
+ ```ts
383
+ import { assertOwnTenant } from '@voltro/plugin-multitenancy'
384
+
385
+ export default async (input, ctx) => {
386
+ if (ctx.subject.type !== 'user') throw new Unauthorised()
387
+ assertOwnTenant(input.tenantId, ctx.subject)
388
+ // …
389
+ }
390
+ ```
391
+
392
+ ### Allow system OR user
393
+
394
+ ```ts
395
+ const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
396
+ if (!canTrigger) throw new Unauthorised({})
397
+ ```
398
+
399
+ ### RBAC
400
+
401
+ `@voltro/plugin-rbac` builds on the subject's scopes. Roles **compile to scopes** — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the `permission()` handler guard checks it in one line:
402
+
403
+ ```ts
404
+ import { permission } from '@voltro/plugin-rbac'
405
+
406
+ yield* permission(ctx, 'admin:full') // Effect<void, Forbidden>
407
+ ```
408
+
409
+ See [the RBAC plugin](/docs/plugins/rbac) for the full model.
410
+
411
+ ## Anonymous tenant resolution
412
+
413
+ For public APIs where you want anonymous callers scoped to a tenant (multi-tenant marketing site, public listings), nothing extra to configure: when no strategy matches, `composeAuthStrategies`' default fallback reads the `x-tenant` header and yields `anonymousSubject(tenant)`:
414
+
415
+ ```ts
416
+ import { anonymousSubject } from '@voltro/protocol'
417
+ // fallback output for `x-tenant: foo`:
418
+ // { type: 'anonymous', id: null, tenantId: 'foo' }
419
+ ```
420
+
421
+ Tables with `tenant()` then scope anonymous reads to `foo`. To *require* a tenant on anonymous callers — so a tenant-less request can't read across the whole DB on tables that aren't `tenant()`-scoped — pass `anonymousTenantRequired: true` to `composeAuthStrategies`:
422
+
423
+ ```ts
424
+ const resolveSubject = composeAuthStrategies(strategies, {
425
+ anonymousTenantRequired: true, // no x-tenant + no matched strategy → throws Unauthenticated
426
+ })
427
+ ```
428
+
429
+ When set, an unmatched call with no `x-tenant` header throws `Unauthenticated` instead of yielding a null-tenant anonymous Subject. (Ignored when you supply a custom `fallback` — that function owns the decision.)
430
+
431
+ ## Custom resolvers
432
+
433
+ For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom `AuthStrategy` and add it to the `auth.strategies` chain in `app.config.ts` — there is no `runtime.resolveSubject` field:
434
+
435
+ ```ts
436
+ // app.config.ts
437
+ import { anonymousSubject } from '@voltro/protocol'
438
+
439
+ export default {
440
+ type: 'api' as const,
441
+ name: 'myApi',
442
+ auth: {
443
+ strategies: [
444
+ {
445
+ id: 'mtls',
446
+ resolve: (input) => {
447
+ const cn = input.headers['x-client-cert-cn']
448
+ return cn
449
+ ? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
450
+ : { kind: 'skip' }
451
+ },
452
+ },
453
+ ],
454
+ },
455
+ }
456
+ ```
457
+
458
+ The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.
459
+
460
+ ## ctx.subject in subscriptions
461
+
462
+ `ctx.subject` is captured at subscribe-time. If the cookie expires mid-subscription:
463
+
464
+ - Subsequent mutations from the now-expired client fail with `Unauthorised`.
465
+ - The subscription itself **continues to stream** until the client reconnects or the server drops it.
466
+ - On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.
467
+
468
+ The open stream isn't force-killed on expiry; the next write fails with `Unauthenticated` and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.
469
+
470
+ ## Anti-patterns
471
+
472
+ - **Reading `ctx.subject.id` without narrowing.** It's `string` on `user` / `apiKey` / `serviceAccount` / `system`, but `null` on `anonymous`. Narrow on `type` first.
473
+ - **Trusting `subject.tenantId` for writes.** It's read-only context. Always `assertOwnTenant(input.tenantId, ctx.subject)` for any write that takes a tenant.
474
+ - **Caching subjects across requests.** The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.
475
+
476
+
477
+
478
+ ---
479
+
480
+ <!-- source: en/authentication/strategies.md -->
481
+ ## Auth strategies
482
+
483
+ _The AuthStrategy protocol — how Voltro resolves a Subject from a request, chains multiple identity providers, and lets you plug in your own._
484
+
485
+ Everything above this page describes the **built-in** password + session flow. This page describes the **protocol** underneath it: how the framework turns an incoming request into a [`Subject`](/docs/authentication/subject), and how you swap or stack the mechanism that does it — password cookies, an external IdP, an API key, or your own scheme — without touching handler code.
486
+
487
+ An **auth strategy** is the unit of pluggability. The built-in password auth is *one* strategy (`voltro-password`); [WorkOS / Kinde / Clerk](/docs/authentication/external-idp) are others; you can write your own. The framework evaluates them as a chain and produces a `Subject`.
488
+
489
+ ## The contract
490
+
491
+ A strategy answers one question per request: *"is this my request, and if so, who is it?"*
492
+
493
+ ```ts
494
+ import type { AuthStrategy } from '@voltro/protocol'
495
+
496
+ interface AuthStrategy {
497
+ readonly id: string // 'voltro-password', 'workos', …
498
+ readonly resolve: (input: AuthStrategyInput) =>
499
+ StrategyResolution | Promise<StrategyResolution>
500
+ }
501
+
502
+ interface AuthStrategyInput {
503
+ readonly headers: Readonly<Record<string, string | undefined>>
504
+ readonly clientId: number // per-connection id (for soft re-auth)
505
+ }
506
+ ```
507
+
508
+ `resolve` returns one of three verdicts:
509
+
510
+ | Verdict | Meaning | Composer does |
511
+ |---|---|---|
512
+ | `{ kind: 'skip' }` | Not my request (e.g. my cookie is absent). | Try the next strategy. |
513
+ | `{ kind: 'matched', subject }` | Mine, and here's the verified `Subject`. | Use it. Stop. |
514
+ | `{ kind: 'failed', reason }` | Mine, but verification failed (bad signature, expired). | **Bail to anonymous + log. Do NOT try the next strategy.** |
515
+
516
+ The `failed`-stops-the-chain rule is a **security** decision, not an ergonomic one: a forged `workos` token must not get a second chance to be accepted by some other strategy. A validation failure is treated as a potential attack, not a "wrong door".
517
+
518
+ > Strategies must be **fast on the no-match path** — a cookie-name substring check, no IO — because every strategy runs on every request until one matches. Do JWKS fetches / DB lookups only *after* you've confirmed the request is yours, and cache them.
519
+
520
+ ## Composing the chain
521
+
522
+ `composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
523
+
524
+ ```ts
525
+ import { composeAuthStrategies } from '@voltro/protocol'
526
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
527
+ import { workosStrategy } from '@voltro/plugin-auth-workos'
528
+
529
+ const resolve = composeAuthStrategies(
530
+ [
531
+ voltroPasswordStrategy(), // try our own session cookie first
532
+ workosStrategy({ clientId: process.env.WORKOS_CLIENT_ID! }), // then WorkOS
533
+ ],
534
+ {
535
+ onStrategyFailed: ({ strategyId, reason }) =>
536
+ log.warn('auth strategy failed', { strategyId, reason }),
537
+ },
538
+ )
539
+ ```
540
+
541
+ Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an [anonymous Subject](/docs/authentication/subject) scoped to the `x-tenant` header (or a custom `fallback` you supply).
542
+
543
+ ## Wiring it into the app
544
+
545
+ 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:
546
+
547
+ ```ts
548
+ import { AuthMiddleware } from '@voltro/protocol'
549
+ import { Layer } from 'effect'
550
+
551
+ export const AuthLayer = Layer.succeed(
552
+ AuthMiddleware,
553
+ AuthMiddleware.of(({ headers, clientId }) => resolve({ headers, clientId })),
554
+ )
555
+ ```
556
+
557
+ 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`.
558
+
559
+ ## The built-in: `voltroPasswordStrategy`
560
+
561
+ The reference implementation, and the proof the protocol isn't a special case for third parties — our own auth is just a strategy:
562
+
563
+ ```ts
564
+ voltroPasswordStrategy({
565
+ // secret?: defaults to resolveSessionSecret() (VOLTRO_SESSION_SECRET)
566
+ // cookieName?: defaults to 'voltro:session'
567
+ })
568
+ ```
569
+
570
+ Its `resolve`:
571
+
572
+ 1. Reads the `voltro:session` cookie. Absent → `skip`.
573
+ 2. HMAC-verifies it. Bad signature or expired → `failed` (a forged cookie doesn't fall through to another IdP).
574
+ 3. Valid → `matched`, stamping `metadata.provider = 'voltro-password'`.
575
+
576
+ Fully synchronous, zero IO on no-match. See [sessions](/docs/authentication/sessions) for how the cookie is minted.
577
+
578
+ ## Writing your own strategy
579
+
580
+ Any object satisfying `AuthStrategy` works. As an illustration, a minimal header-keyed strategy:
581
+
582
+ ```ts
583
+ import type { AuthStrategy } from '@voltro/protocol'
584
+
585
+ const myKeyStrategy = (lookup: (key: string) => Promise<{ id: string; tenantId: string } | null>): AuthStrategy => ({
586
+ id: 'my-key',
587
+ resolve: async ({ headers }) => {
588
+ const key = headers['x-api-key']
589
+ if (!key) return { kind: 'skip' } // not my request
590
+ const row = await lookup(key)
591
+ if (!row) return { kind: 'failed', reason: 'unknown api key' }
592
+ return {
593
+ kind: 'matched',
594
+ subject: {
595
+ type: 'apiKey',
596
+ id: row.id,
597
+ tenantId: row.tenantId,
598
+ metadata: { provider: 'my-key' },
599
+ },
600
+ }
601
+ },
602
+ })
603
+ ```
604
+
605
+ Drop it into the `composeAuthStrategies` array. The `metadata.provider` tag lets handler code pattern-match on *which* strategy authenticated the caller (see [the Subject](/docs/authentication/subject)).
606
+
607
+ > You don't need to hand-roll API-key auth — a production `apiKeyStrategy` already ships from `@voltro/protocol/apikey` (prefixed `Authorization: Bearer <prefix>_<token>`, sha256-hashed lookup, scoped `apiKey` subject). Use the example above only for genuinely custom schemes the shipped strategy can't express.
608
+
609
+ ### Strategies that need a server-side callback
610
+
611
+ Pure token-verify strategies (the three IdP plugins, the example above) need no server queries — the credential already arrives on the request. A strategy that must run a **server-side OAuth code exchange** or land a magic link additionally implements `mountRoutes`:
612
+
613
+ ```ts
614
+ interface AuthStrategyWithCallback extends AuthStrategy {
615
+ readonly mountRoutes: (router: AuthCallbackRouter) => void // router.get / router.post
616
+ }
617
+ ```
618
+
619
+ The framework mounts those routes on the HTTP router when present (detected via the `hasCallbackRoutes` type guard). The first-party WorkOS/Kinde/Clerk plugins do **not** use this — their SDKs run the OAuth flow in the browser and set a cookie the strategy then verifies. `mountRoutes` exists for custom OIDC flows that can't.
620
+
621
+ ## Requiring authentication
622
+
623
+ Independent of strategy: any handler can demand a real (non-anonymous) caller.
624
+
625
+ ```ts
626
+ import { assertAuthenticated } from '@voltro/protocol'
627
+
628
+ const execute = async (input, ctx) => {
629
+ assertAuthenticated(ctx.subject) // throws Unauthenticated if anonymous
630
+ // …
631
+ }
632
+ ```
633
+
634
+ `Unauthenticated` crosses the wire with its `_tag` intact, so `@voltro/client` can auto-redirect to sign-in. It's distinct from a tenant-mismatch ("you're signed in but touching the wrong tenant") — this means "no real identity resolved at all".
635
+
636
+ ## Next
637
+
638
+ - [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`.
639
+
640
+
641
+
642
+ ---
643
+
644
+ <!-- source: en/authentication/external-idp.md -->
645
+ ## External identity providers
646
+
647
+ _WorkOS, Kinde, Clerk, Auth0, Supabase Auth, and generic OIDC as Voltro auth strategies — JWT/JWKS verification via the shared jwtBearerStrategy, claims-to-tenant mapping, and how they stack with password auth._
648
+
649
+ Voltro ships first-party [strategy](/docs/authentication/strategies) plugins for six identity providers, including a generic OIDC adapter that covers anything publishing an OpenID Connect discovery document:
650
+
651
+ | Package | Provider | Strategy id |
652
+ |---|---|---|
653
+ | `@voltro/plugin-auth-workos` | WorkOS AuthKit / SSO | `workos` |
654
+ | `@voltro/plugin-auth-kinde` | Kinde | `kinde` |
655
+ | `@voltro/plugin-auth-clerk` | Clerk | `clerk` |
656
+ | `@voltro/plugin-auth-auth0` | Auth0 | `auth0` |
657
+ | `@voltro/plugin-auth-supabase` | Supabase Auth (GoTrue) | `supabase` |
658
+ | `@voltro/plugin-auth-oidc` | Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) | configurable via `id:` |
659
+
660
+ 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.
661
+
662
+ ## The model: verify, don't redirect
663
+
664
+ These strategies do **not** run the OAuth redirect dance on the server. The provider's own SDK runs that in the browser and lands a signed **JWT** — either as a cookie (cookie mode) or an `Authorization: Bearer` header. The Voltro strategy's job is narrow and stateless:
665
+
666
+ ```
667
+ request ─► extract token (Bearer or cookie)
668
+ ─► verify signature against the provider's JWKS (cached)
669
+ ─► map verified claims → Subject (+ tenantId)
670
+ ─► attach raw claims under metadata.claims
671
+ ```
672
+
673
+ This means **you keep your `Subject` model and multi-tenant scope** — the IdP authenticates, but `tenantId` and the typed `Subject` stay the framework's, not the vendor's. No session is stored server-side for these strategies; the JWT *is* the session, re-verified per request (JWKS is cached, so steady-state verification is CPU-local).
674
+
675
+ > Security: the JWKS verifier allows asymmetric algorithms only (`RS256` / `ES256`, optionally `PS256` / `EdDSA`). `HS256` is intentionally rejected on the JWKS path — a shared-secret HMAC over a public JWKS flow is a downgrade vector. (Legacy / self-hosted Supabase that signs symmetrically is the one exception: it verifies through a separate, explicit shared-secret path — `supabaseStrategy({ jwtSecret })` — never the JWKS verifier. See [Supabase Auth](/docs/plugins/auth-supabase).)
676
+
677
+ There are **two ways** to put an external IdP in front of a Voltro app; this page's per-provider sections cover the first. In the **verify** model above, the IdP's JWT *is* the session — every request re-verifies it and no framework session is minted. The **second** model uses the IdP only for the *login step* — redirect to its hosted UI, take the callback, then mint your **own** `voltro:session` — so the IdP is an alternate front door, not the session authority. See [WorkOS SSO login](#workos-sso-login) below for a full example.
678
+
679
+ ## Client: getting the token to the api (HTTP **and** WebSocket)
680
+
681
+ The strategy above only *verifies* — your browser still has to *send* the token on every request, over **both** transports the framework uses:
682
+
683
+ - **HTTP** (`POST /rpc`, loaders, `/auth/*`): the provider SDK's `fetch` (or your own) sends `Authorization: Bearer <token>` normally — nothing framework-specific.
684
+ - **WebSocket** (live subscriptions): a browser **cannot** set headers on a WS upgrade, so the token can't ride the handshake. Pass it to `mount()` instead — the framework attaches it to every rpc **message frame** (not the upgrade), where the same strategy reads it per call:
685
+
686
+ ```ts
687
+ // .framework/main.tsx (or your mount entry)
688
+ mount(App, {
689
+ apis: {
690
+ api: {
691
+ // A thunk is resolved fresh on every (re)connect, so a rotating
692
+ // access token is pulled anew per connect, not frozen at first mount:
693
+ headers: async () => ({
694
+ authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
695
+ }),
696
+ },
697
+ },
698
+ })
699
+ ```
700
+
701
+ This is **required when the api is a separate origin** (the common case — `api.example.com` vs your web origin): there is no shared cookie and no upgrade header, so without `headers` every subscription connects **anonymous** (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.
702
+
703
+ ## WorkOS
704
+
705
+ ```ts
706
+ import { workosStrategy } from '@voltro/plugin-auth-workos'
707
+
708
+ workosStrategy({
709
+ clientId: process.env.WORKOS_CLIENT_ID!, // client_01H… — builds the JWKS URL + audience
710
+ // jwksUrl?: https://api.workos.com/sso/jwks/<clientId> (default)
711
+ // issuer?: https://api.workos.com (default)
712
+ // cookieName?: 'wos-session' (AuthKit cookie mode; null = header-only)
713
+ // defaultTenantId?: fallback when claims expose no org_id
714
+ })
715
+ ```
716
+
717
+ Tenant maps from `claims.org_id`. Verified claims arrive as `subject.metadata.claims` typed loosely as `WorkosClaims` (`sub`, `email`, `org_id`, `role`, `permissions`, …).
718
+
719
+ ## WorkOS SSO login
720
+
721
+ 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.
722
+
723
+ - **`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).
724
+ - **`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).
725
+
726
+ Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency), so they drop into any app's own routing + provisioning.
727
+
728
+ ### The flow
729
+
730
+ ```text
731
+ GET /auth/workos/login
732
+ ─► set a random `state` cookie (CSRF defence)
733
+ ─► 302 → workosAuthorizationUrl({ clientId, redirectUri, state })
734
+
735
+ GET /auth/workos/callback?code=…&state=…
736
+ ─► verify `state` matches the cookie (else 400 — possible CSRF)
737
+ ─► workosAuthenticateWithCode({ clientId, apiKey, code }) → WorkosProfile
738
+ ─► find-or-provision the local user + org (SHARED with password signup)
739
+ ─► issueSession(subject) → Set-Cookie: voltro:session=…
740
+ ─► 302 → dashboard
741
+ ```
742
+
743
+ The callback mints the **same** `voltro:session` as password sign-in, so everything downstream — the session strategy, org switching, audit — behaves identically. **WorkOS handles _authentication_; your app keeps _authorization_.** The IdP never becomes the session authority; it is simply another way in.
744
+
745
+ ### Wiring the routes
746
+
747
+ Wrap the two routes in a small **server-only** plugin and add it to `plugins` in your api's `app.config`:
748
+
749
+ ```ts
750
+ import { randomUUID } from 'node:crypto'
751
+ import { definePlugin } from '@voltro/protocol'
752
+ import { workosAuthorizationUrl, workosAuthenticateWithCode } from '@voltro/plugin-auth-workos'
753
+ import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'
754
+
755
+ export const workosAuthPlugin = () =>
756
+ definePlugin({
757
+ name: 'workos-login',
758
+ httpRoutes: [
759
+ {
760
+ method: 'GET',
761
+ path: '/auth/workos/login',
762
+ handle: async () => {
763
+ const clientId = process.env.WORKOS_CLIENT_ID
764
+ const redirectUri = process.env.WORKOS_REDIRECT_URI
765
+ if (!clientId || !redirectUri) {
766
+ return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
767
+ }
768
+ const state = randomUUID()
769
+ const url = workosAuthorizationUrl({ clientId, redirectUri, state })
770
+ return {
771
+ status: 302,
772
+ headers: { location: url, 'set-cookie': `workos-oauth-state=${state}; Path=/; Max-Age=600; SameSite=Lax` },
773
+ body: '',
774
+ }
775
+ },
776
+ },
777
+ {
778
+ method: 'GET',
779
+ path: '/auth/workos/callback',
780
+ handle: async (req) => {
781
+ const clientId = process.env.WORKOS_CLIENT_ID
782
+ const apiKey = process.env.WORKOS_API_KEY
783
+ if (!clientId || !apiKey) {
784
+ return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
785
+ }
786
+ const code = new URLSearchParams(req.query).get('code')
787
+ if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
788
+ // …also validate `state` against the state cookie (CSRF) before exchanging.
789
+ const profile = await workosAuthenticateWithCode({ clientId, apiKey, code })
790
+ // find-or-provision runs the SAME path as password signup:
791
+ const { userId, orgId } = await findOrProvisionUser(profile)
792
+ const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
793
+ return {
794
+ status: 302,
795
+ headers: {
796
+ location: '/dashboard',
797
+ 'set-cookie': `voltro:session=${issued.value}; Path=/; Max-Age=604800; SameSite=Lax`,
798
+ },
799
+ body: '',
800
+ }
801
+ },
802
+ },
803
+ ],
804
+ })
805
+ ```
806
+
807
+ 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.
808
+
809
+ ### Configuration
810
+
811
+ All three variables are **optional** — the routes return **`503`** until they are set, so the app boots without WorkOS and you enable SSO by supplying credentials:
812
+
813
+ | Env var | Secret | Purpose |
814
+ |---|---|---|
815
+ | `WORKOS_CLIENT_ID` | no | WorkOS Client ID (`client_…`) for the hosted login. |
816
+ | `WORKOS_API_KEY` | **yes** | WorkOS API key (`sk_…`) — the OAuth client secret used in the code exchange. |
817
+ | `WORKOS_REDIRECT_URI` | no | The callback URL, **registered in the WorkOS dashboard** — e.g. `https://app.voltro.cloud/auth/workos/callback`. |
818
+
819
+ Add a "Sign in with WorkOS SSO" link on your sign-in page pointing at `GET /auth/workos/login`, and the round trip runs end to end.
820
+
821
+ ## Kinde
822
+
823
+ ```ts
824
+ import { kindeStrategy } from '@voltro/plugin-auth-kinde'
825
+
826
+ kindeStrategy({
827
+ issuer: 'https://yourcompany.kinde.com', // required
828
+ // jwksUrl?: <issuer>/.well-known/jwks (default)
829
+ // cookieName?: 'kinde_access_token' (null = header-only)
830
+ // defaultTenantId?: for single-tenant setups
831
+ })
832
+ ```
833
+
834
+ Tenant maps from `claims.org_code`, falling back to `claims.org_codes[0]`.
835
+
836
+ ## Clerk
837
+
838
+ ```ts
839
+ import { clerkStrategy } from '@voltro/plugin-auth-clerk'
840
+
841
+ clerkStrategy({
842
+ frontendApi: 'https://clerk.yourapp.com', // Clerk Frontend API host
843
+ // jwksUrl?: <frontendApi>/.well-known/jwks.json (default)
844
+ // issuer?: <frontendApi> (default)
845
+ // cookieName?: '__session' (Clerk's hardcoded cookie name)
846
+ // defaultTenantId?: when no org_id claim is present
847
+ })
848
+ ```
849
+
850
+ Tenant maps from `claims.org_id` (Clerk's "Organizations" feature). Clerk's default `__session` tokens carry no `aud` claim, so the audience check is off unless you configure one.
851
+
852
+ ## Auth0
853
+
854
+ ```ts
855
+ import { auth0Strategy } from '@voltro/plugin-auth-auth0'
856
+
857
+ auth0Strategy({
858
+ domain: 'acme.us.auth0.com', // required — builds JWKS URL + issuer
859
+ audience: 'https://api.acme.com', // your API identifier
860
+ // jwksUrl?: https://<domain>/.well-known/jwks.json (default)
861
+ // issuer?: https://<domain>/ (default — TRAILING SLASH MATTERS)
862
+ // tenantClaim?: 'https://voltro.dev/tenant' (default namespaced URN)
863
+ // cookieName?: null (header-only by default)
864
+ // defaultTenantId?: fallback for single-tenant Auth0 setups
865
+ })
866
+ ```
867
+
868
+ Tenant maps from a namespaced custom claim (Auth0's rules require non-standard claims to live under a URN). Use a custom Auth0 Action to copy your app metadata into the namespaced claim, or configure `tenantClaim` to an unnamespaced field that already exists in your token.
869
+
870
+ ## Supabase Auth
871
+
872
+ ```ts
873
+ import { supabaseStrategy } from '@voltro/plugin-auth-supabase'
874
+
875
+ supabaseStrategy({
876
+ projectRef: 'abcdef', // builds the hosted JWKS URL
877
+ // OR self-hosted GoTrue:
878
+ // jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
879
+ // issuer: 'https://my-supabase.example.com/auth/v1',
880
+
881
+ // tenantClaim?: 'app_metadata.tenant_id' (default, dotted path)
882
+ // audience?: 'authenticated' (default — Supabase's role)
883
+ // defaultTenantId?: for single-tenant deployments
884
+ })
885
+ ```
886
+
887
+ Tenant maps from `app_metadata.tenant_id` by default (a server-controlled blob; users can't forge it). Apps that query tenants via `user_metadata` set `tenantClaim: 'user_metadata.org'` — same syntax, dotted-path resolution. `cookieName` defaults to `null` (header-only); Supabase's JS SDK stores the access token at `sb-<projectRef>-auth-token` as a JSON payload, so most Voltro apps just consume the Bearer header and leave the cookie path off.
888
+
889
+ ## Generic OIDC (Okta, Keycloak, Cognito, Azure AD, …)
890
+
891
+ For any IdP that publishes an OpenID Connect discovery document, `@voltro/plugin-auth-oidc` discovers the JWKS at boot — no per-provider package needed:
892
+
893
+ ```ts
894
+ import { oidcStrategy } from '@voltro/plugin-auth-oidc'
895
+
896
+ oidcStrategy({
897
+ id: 'okta', // stable id for logs + metadata.provider
898
+ issuer: 'https://acme.okta.com',
899
+ audience: 'api://acme',
900
+ tenantClaim: 'acme/tenant_id', // your provider's tenant claim
901
+ })
902
+ ```
903
+
904
+ The strategy fetches `${issuer}/.well-known/openid-configuration` on the first request, extracts `jwks_uri`, and caches the discovery document for the lifetime of the process. Pass `jwksUrl` explicitly to skip the discovery roundtrip entirely (slightly faster cold-start, no behavioural difference). Pass `discoveryUrl` if your provider hosts the document at a non-standard path.
905
+
906
+ Common config combinations:
907
+
908
+ | Provider | `issuer` | `tenantClaim` | Notes |
909
+ |---|---|---|---|
910
+ | Okta | `https://<org>.okta.com/oauth2/default` | `acme/tenant_id` (custom claim) | Use the "default" authorization server unless you've set up a custom one. |
911
+ | Keycloak | `https://<host>/realms/<realm>` | `realm_access.tenant` | Add a custom claim mapper to surface the tenant. |
912
+ | Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolId>` | `custom:tenant_id` | Cognito prefixes custom claims with `custom:`. |
913
+ | Azure AD | `https://login.microsoftonline.com/<tenantId>/v2.0` | `tid` | `tid` IS the Azure tenant — usually you map it 1:1 to Voltro's `tenantId`. |
914
+ | Google Workspace | `https://accounts.google.com` | `hd` | `hd` is the hosted-domain claim. |
915
+
916
+ ## Stacking with password auth
917
+
918
+ Strategies compose — you can accept *both* your own password sessions and an external IdP during a migration, or per surface:
919
+
920
+ ```ts
921
+ import { composeAuthStrategies } from '@voltro/protocol'
922
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
923
+ import { clerkStrategy } from '@voltro/plugin-auth-clerk'
924
+
925
+ const resolve = composeAuthStrategies([
926
+ voltroPasswordStrategy(), // existing users on our cookie
927
+ clerkStrategy({ frontendApi: process.env.CLERK_FRONTEND_API! }), // new users on Clerk
928
+ ])
929
+ ```
930
+
931
+ First match wins, so existing password sessions keep working while new sign-ups flow through Clerk — no big-bang cutover. See [strategies](/docs/authentication/strategies) for chain semantics (and why a `failed` verdict stops the chain rather than falling through).
932
+
933
+ ## Reading provider claims in handlers
934
+
935
+ The verified JWT claims ride along on the Subject so handlers can use provider-specific data without the framework knowing the provider's shape:
936
+
937
+ ```ts
938
+ const execute = async (input, ctx) => {
939
+ const s = ctx.subject
940
+ if (s.metadata?.provider === 'workos') {
941
+ const claims = s.metadata.claims as WorkosClaims
942
+ if (!claims.permissions?.includes('billing:write')) {
943
+ throw new Unauthenticated({ reason: 'missing billing:write' })
944
+ }
945
+ }
946
+ // …
947
+ }
948
+ ```
949
+
950
+ `metadata.provider` is the discriminator; `metadata.claims` is the raw verified payload. The framework never reads either — it's a passthrough slot, so adding a provider never changes the `Subject` type.
951
+
952
+ ## Rolling your own provider
953
+
954
+ Three escape hatches, in order of effort:
955
+
956
+ 1. **`oidcStrategy`** from `@voltro/plugin-auth-oidc` — for any IdP with a standard OIDC discovery document. Most modern providers (Okta, Keycloak, Cognito, Azure AD, Google) work with this; configure `id`, `issuer`, `audience`, `tenantClaim` and you're done.
957
+ 2. **`jwtBearerStrategy`** from `@voltro/protocol/jwt` directly — for non-OIDC bearer-token providers, custom audiences, or when you want absolute control over the JWKS URL + verification rules:
958
+
959
+ ```ts
960
+ import { jwtBearerStrategy } from '@voltro/protocol/jwt'
961
+
962
+ const custom = jwtBearerStrategy({
963
+ id: 'my-idp',
964
+ jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
965
+ issuer: 'https://idp.example.com',
966
+ audience: 'https://api.yourapp.com',
967
+ cookieName: null, // Bearer header only
968
+ tenantIdFromClaims: (claims) => (claims['org_id'] as string) ?? null,
969
+ })
970
+ ```
971
+ 3. **Custom AuthStrategy implementation** — for non-JWT auth (cookies that resolve via a DB lookup, mTLS, …). See [strategies](/docs/authentication/strategies) for the contract.
972
+
973
+ Returning `null` from `tenantIdFromClaims` is a `failed` verdict — the strategy owns the request but can't satisfy the tenant invariant, so it won't silently produce a tenant-less Subject.
974
+
975
+
976
+
977
+ ---
978
+
979
+ <!-- source: en/authentication/handlers.md -->
980
+ ## HTTP handlers
981
+
982
+ _handleSignIn, handleSignUp, handleSignOut — the HTTP-layer functions that turn form fields into session cookies._
983
+
984
+ `@voltro/plugin-auth` exposes three HTTP-shape handlers. They take request bits + a user store + auth config, and return response bits (status, body, cookie, redirect). They're transport-agnostic — wire them into Node's `http`, Express, Hono, anything.
985
+
986
+ ## handleSignIn
987
+
988
+ ```ts
989
+ import { handleSignIn } from '@voltro/plugin-auth'
990
+
991
+ const result = await handleSignIn(
992
+ {
993
+ email: fields.email,
994
+ password: fields.password,
995
+ redirectAfter: true, // 302 redirect vs JSON response
996
+ },
997
+ userStore,
998
+ AUTH_CONFIG,
999
+ )
1000
+
1001
+ // result: {
1002
+ // status: number,
1003
+ // body: string,
1004
+ // contentType: string,
1005
+ // setCookie?: string,
1006
+ // location?: string,
1007
+ // }
1008
+ ```
1009
+
1010
+ What it does:
1011
+
1012
+ 1. `userStore.findByEmail(email)` — fetch the user (or pretend, see [timing oracle](/docs/authentication/passwords#timing-oracle-defence)).
1013
+ 2. `verifyPassword(password, user.passwordHash)` — always run, even on unknown email.
1014
+ 3. On success: `issueSession(subject, config.secret, {...})` → set the `Set-Cookie` header.
1015
+ 4. If `redirectAfter` is true (form POST): 302 to `AUTH_CONFIG.successRedirect`.
1016
+ 5. If `redirectAfter` is false (XHR / fetch): 200 + JSON `{ ok: true, subject }`.
1017
+
1018
+ Failures return a 401 `HandlerResult` with a generic `{ error: 'invalid credentials' }` body — no email-existence disclosure. The handlers encode failures as 4xx/5xx `HandlerResult`s rather than throwing domain errors across the wire.
1019
+
1020
+ ## handleSignUp
1021
+
1022
+ ```ts
1023
+ const result = await handleSignUp(
1024
+ { email, password, redirectAfter: true },
1025
+ userStore,
1026
+ AUTH_CONFIG,
1027
+ )
1028
+ ```
1029
+
1030
+ What it does:
1031
+
1032
+ 1. Reject empty email/password (400) and passwords under 8 characters (400). That length floor is the only built-in policy; richer rules belong in your sign-up route, see [Passwords](/docs/authentication/passwords#password-policy).
1033
+ 2. `userStore.findByEmail(email)` — if exists, return 409.
1034
+ 3. `hashPassword(password)`.
1035
+ 4. `userStore.insert({ email, passwordHash, tenantId: defaultTenantId })`.
1036
+ 5. `issueSession(...)` + 302 redirect or 200 JSON `{ ok: true, subject }`. Any failure in this body collapses to `500 { error: 'signup_failed' }`.
1037
+
1038
+ The new user is added to the `defaultTenantId` from `AuthConfig`. For multi-tenant invite flows where the tenant is decided by an invite token, write a custom handler — `handleSignUp` is the convenient default.
1039
+
1040
+ ## handleSignOut
1041
+
1042
+ ```ts
1043
+ const result = handleSignOut(AUTH_CONFIG)
1044
+
1045
+ // result: {
1046
+ // status: 302,
1047
+ // setCookie: 'voltro:session=; Max-Age=0; …',
1048
+ // location: '/', // config.successRedirect ?? '/'
1049
+ // }
1050
+ ```
1051
+
1052
+ Synchronous — just emits an expired-cookie header + a redirect. The cookie kills any subsequent verifier; no server-side state to update.
1053
+
1054
+ ## Wiring into Node http
1055
+
1056
+ ```ts
1057
+ // apps/api/index.ts
1058
+ import { createServer } from 'node:http'
1059
+ import { Effect } from 'effect'
1060
+ import {
1061
+ handleSignIn, handleSignUp, handleSignOut,
1062
+ postgresUserStore, type AuthConfig,
1063
+ } from '@voltro/plugin-auth'
1064
+
1065
+ const AUTH_CONFIG: AuthConfig = {
1066
+ secret: process.env.AUTH_SECRET!,
1067
+ defaultTenantId: 'acme',
1068
+ cookieSecure: process.env.NODE_ENV === 'production',
1069
+ cookieDomain: '.your-product.com', // optional
1070
+ successRedirect: process.env.AUTH_SUCCESS ?? '/dashboard',
1071
+ }
1072
+
1073
+ // postgresUserStore takes an `@effect/sql` SqlClient and returns a
1074
+ // UserStore synchronously — the caller owns the client's lifecycle.
1075
+ const store = postgresUserStore(sql)
1076
+
1077
+ createServer(async (req, res) => {
1078
+ const url = req.url ?? ''
1079
+ const fields = await parseFormBody(req)
1080
+
1081
+ if (req.method === 'POST' && url === '/auth/signin') {
1082
+ const r = await Effect.runPromise(handleSignIn(
1083
+ { ...fields, redirectAfter: true },
1084
+ store,
1085
+ AUTH_CONFIG,
1086
+ ))
1087
+ res.statusCode = r.status
1088
+ if (r.setCookie) res.setHeader('set-cookie', r.setCookie)
1089
+ if (r.location) res.setHeader('location', r.location)
1090
+ res.setHeader('content-type', r.contentType ?? 'text/plain')
1091
+ res.end(r.body)
1092
+ return
1093
+ }
1094
+
1095
+ // … signup, signout, plus your own queries
1096
+ }).listen(4100)
1097
+ ```
1098
+
1099
+ ## With CORS + credentials
1100
+
1101
+ If your web app + api are on different origins, the form POST works (form submission is cross-origin-allowed) but XHR / fetch needs:
1102
+
1103
+ - Server: `Access-Control-Allow-Credentials: true` + exact-origin allow-list.
1104
+ - Client: `fetch(url, { credentials: 'include' })`.
1105
+ - Cookies: SameSite is hardcoded `lax` in the session builder (it is not an `AuthConfig` field) — which is exactly what cross-origin form POST sign-in needs. `strict` would block them, but the plugin never emits `strict`.
1106
+
1107
+ For single-origin deploys (one Caddy / nginx in front of both), none of this matters — same-origin cookies always flow.
1108
+
1109
+ ## How the handlers surface failures
1110
+
1111
+ The handlers do NOT throw domain errors across the wire — sign-in / sign-up encode failures as 4xx/5xx `HandlerResult`s:
1112
+
1113
+ | Result | When |
1114
+ |---|---|
1115
+ | `400 { error: 'email and password required' }` | missing field |
1116
+ | `400 { error: 'password must be at least 8 characters' }` | sign-up, password too short |
1117
+ | `401 { error: 'invalid credentials' }` | sign-in: email not found OR password mismatch (uniform timing) |
1118
+ | `409 { error: 'email already registered' }` | sign-up: email already in use |
1119
+ | `500 { error: 'signup_failed' }` | any uncaught failure inside `handleSignUp` (e.g. a hash or insert error) |
1120
+
1121
+ The *tagged* errors that the lower-level primitives raise — and that you can `Effect.catchTag` if you compose them yourself — are `PasswordEmptyError`, `PasswordHashError` (from `@voltro/plugin-auth/password`), `UserAlreadyExistsError`, `UserNotFoundError` (from the `UserStore`), and `TotpVerifyError` (from the MFA path). They extend `Data.TaggedError` / `Schema.TaggedError`, so they carry typed payloads.
1122
+
1123
+ ## Customising the response shape
1124
+
1125
+ The default body is JSON for XHR + redirects for form POST. To customise:
1126
+
1127
+ ```ts
1128
+ const r = await handleSignIn({ ... }, store, AUTH_CONFIG)
1129
+ // r.status, r.setCookie, r.location are stable.
1130
+ // Replace r.body with your own JSON / HTML / template render.
1131
+ ```
1132
+
1133
+ For instance, returning HTML on the sign-in page itself (no redirect) for a "you're signed in — refresh to continue" flow.
1134
+
1135
+ ## MFA / TOTP handlers (shipped)
1136
+
1137
+ MFA is enforced at sign-in, not just enrolled. `handleSignIn` reads `user.mfaEnrolledAt`: for an enrolled user it does **not** issue a session — it mints a single-use, short-lived pending token (hash stored in `authTokens`) and returns `{ ok: true, mfaRequired: true, pendingToken }`. The second factor is completed separately:
1138
+
1139
+ - `handleMfaVerify({ pendingToken, code? , recoveryCode? }, store, config)` — redeems the pending token atomically (single-use), verifies the 6-digit TOTP `code` against the stored secret (or a single-use `recoveryCode` as the lost-authenticator fallback), and only then issues the real session via the same `issueSession` path as password sign-in (so rotation + revocation apply). A wrong code is `401`, and the challenge is consumed regardless — a guess can't be retried against the same token.
1140
+
1141
+ Enrolment is a separate ceremony (mount its routes with the plugin's `mfa: { issuer }` config):
1142
+
1143
+ - `handleMfaEnrollStart({ userId, issuer, accountName }, store)` — generates a TOTP secret, stashes it via `store.setMfaSecret`, returns `{ secret, otpauthUrl }` so the dashboard can render the QR.
1144
+ - `handleMfaEnrollVerify({ userId, code }, store)` — verifies the first code; on success marks the user enrolled via `store.markMfaEnrolled` **and returns a one-time batch of `recoveryCodes`** (shown once, stored hashed via `store.replaceRecoveryCodes`).
1145
+ - `handleMfaRegenerateRecoveryCodes({ userId }, store)` — wipes + reissues the recovery-code set.
1146
+ - `handleMfaUnenroll({ userId }, store)` — clears the secret via `store.clearMfa` and wipes the recovery codes.
1147
+
1148
+ They return the same `Effect<HandlerResult>` shape as the others. The `UserStore` MFA methods (`setMfaSecret` / `markMfaEnrolled` / `clearMfa` / `replaceRecoveryCodes` / `consumeRecoveryCode` / `countRecoveryCodes`) back them; both `memoryUserStore` and `postgresUserStore` implement them.
1149
+
1150
+ ## Magic link + password reset
1151
+
1152
+ `handleMagicLinkRequest` / `handleMagicLinkConsume` and `handlePasswordResetRequest` / `handlePasswordResetConfirm` round out the passwordless + recovery flows. The request handlers always return `202` (no account-enumeration); they mint a single-use, hashed, expiring token (`@voltro/plugin-auth/tokens`), persist its hash via `store.insertToken`, and call the injected `config.sendEmail` hook to deliver the link. The consume/confirm handlers redeem the token atomically via `store.consumeToken` (single-use guard) — `handleMagicLinkConsume` issues a session, `handlePasswordResetConfirm` sets the new hashed password and revokes every existing session.
1153
+
1154
+ ```ts
1155
+ const requested = await Effect.runPromise(
1156
+ handleMagicLinkRequest({ email }, store, { ...config, appBaseUrl: 'https://app.example.com', sendEmail }),
1157
+ ) // 202 regardless of whether `email` exists
1158
+ ```
1159
+
1160
+ ## Sessions, memberships, switch-tenant
1161
+
1162
+ `handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` back the device-management UI (`handleSignIn` writes a `sessions` row on every login). `handleListMemberships` + `handleSwitchTenant` drive multi-tenant switching — `handleSwitchTenant` validates membership via `store.membershipRole`, re-issues the cookie with the new active tenant, and rebinds the live connection's Subject when a `clientId` + `rebind` callback are supplied.
1163
+
1164
+ ## CSRF + passkeys
1165
+
1166
+ `handleCsrf` issues a signed double-submit token (`voltro:csrf` cookie + JSON body). The passkey ceremony handlers (`handlePasskeyRegisterOptions` / `…RegisterVerify` / `…AssertOptions` / `…AssertVerify`) live in `@voltro/plugin-auth` and verify challenge, origin, rpId hash, signature, and a strictly-increasing counter. You don't mount any of these by hand — `authRoutesPlugin()` mounts the whole set under `/auth`.
1167
+
1168
+ ## See also
1169
+
1170
+ - [Auth plugin overview](/docs/plugins/auth) — `authRoutesPlugin()`, the mounted route table, passkeys, rotation
1171
+ - [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
1172
+ - [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
1173
+ - [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
1174
+ - [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints
1175
+
1176
+ - [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
1177
+ - [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
1178
+ - [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
1179
+ - [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints
1180
+
1181
+
1182
+
1183
+ ---
1184
+
1185
+ <!-- source: en/authentication/user-stores.md -->
1186
+ ## User stores
1187
+
1188
+ _memoryUserStore, postgresUserStore, and writing your own UserStore against a different backend._
1189
+
1190
+ A `UserStore` is the interface between `@voltro/plugin-auth` and your user data. The plugin ships two implementations + the interface so you can plug in your own.
1191
+
1192
+ ## The interface
1193
+
1194
+ Every method returns an **Effect**, not a Promise:
1195
+
1196
+ ```ts
1197
+ import type { UserStore } from '@voltro/plugin-auth'
1198
+ import { Effect } from 'effect'
1199
+
1200
+ interface UserStore {
1201
+ findByEmail: (email: string) => Effect.Effect<UserRecord | null>
1202
+ findById: (id: string) => Effect.Effect<UserRecord | null>
1203
+ insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>
1204
+ // MFA / TOTP enrolment — back the handlers in HTTP handlers § MFA.
1205
+ setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>
1206
+ markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
1207
+ clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
1208
+ }
1209
+
1210
+ interface UserRecord {
1211
+ readonly id: string
1212
+ readonly email: string
1213
+ readonly passwordHash: string
1214
+ readonly tenantId: string
1215
+ readonly createdAt: Date
1216
+ readonly mfaSecret?: string | null // base32 TOTP secret; null = not enrolled
1217
+ readonly mfaEnrolledAt?: Date | null // first successful verify; null until enrolled
1218
+ }
1219
+ ```
1220
+
1221
+ There is no `updatePasswordHash` method and no `updatedAt` field. The interface is deliberately small. Add your own fields (display name, avatar URL, locale) on a sibling table joined by `userId` — keep the auth-critical fields in the auth table.
1222
+
1223
+ ## memoryUserStore
1224
+
1225
+ For dev + tests:
1226
+
1227
+ ```ts
1228
+ import { memoryUserStore, hashPassword } from '@voltro/plugin-auth'
1229
+ import { Effect } from 'effect'
1230
+
1231
+ const store = memoryUserStore()
1232
+ await Effect.runPromise(Effect.gen(function* () {
1233
+ yield* store.insert({
1234
+ id: 'usr_demo',
1235
+ email: 'demo@example.com',
1236
+ passwordHash: yield* hashPassword('voltro-demo-2026'),
1237
+ tenantId: 'acme',
1238
+ })
1239
+ }))
1240
+ ```
1241
+
1242
+ `memoryUserStore(seed?)` accepts an optional seed array of `UserRecord`s.
1243
+
1244
+ Lives in-process. Restart = data gone. Use it for CI smoke tests + the cloud `/demo` query.
1245
+
1246
+ ## postgresUserStore
1247
+
1248
+ For production:
1249
+
1250
+ ```ts
1251
+ import { postgresUserStore } from '@voltro/plugin-auth'
1252
+ import { SqlClient } from '@effect/sql'
1253
+ import { Effect } from 'effect'
1254
+
1255
+ // postgresUserStore is SYNCHRONOUS — it takes an @effect/sql SqlClient and
1256
+ // returns a UserStore directly. No await, no { url }, no internal pool.
1257
+ // The caller owns the SqlClient's lifecycle.
1258
+ const program = Effect.gen(function* () {
1259
+ const sql = yield* SqlClient.SqlClient
1260
+ const store = postgresUserStore(sql)
1261
+ // … use store …
1262
+ })
1263
+ ```
1264
+
1265
+ What it does:
1266
+
1267
+ - Runs its queries through the **provided** `SqlClient` — it does NOT open or own a connection.
1268
+ - Reads + writes the `users` table from `@voltro/plugin-auth/schema`.
1269
+ - Uses parameterised queries — no SQL injection.
1270
+ - Maps the Postgres `23505` unique-violation on `email` to a typed `UserAlreadyExistsError` on insert; other SQL errors become defects.
1271
+
1272
+ The schema you need is in your `database/schema.ts`:
1273
+
1274
+ ```ts
1275
+ import { usersTable } from '@voltro/plugin-auth/schema'
1276
+ export const users = usersTable
1277
+ ```
1278
+
1279
+ Run `voltro migrate` after adding this — the table + indexes (`email` UNIQUE, `tenantId` btree) get created.
1280
+
1281
+ ## Custom store — example: external IdP
1282
+
1283
+ If you sync users from an external identity provider, write a store that reads from your provider's API + your local cache:
1284
+
1285
+ ```ts
1286
+ import type { UserStore } from '@voltro/plugin-auth'
1287
+ import { Effect } from 'effect'
1288
+
1289
+ const okta = (): UserStore => ({
1290
+ findByEmail: (email) =>
1291
+ Effect.promise(async () => {
1292
+ const oktaUser = await fetch(`https://okta.acme.com/api/v1/users?email=${email}`)
1293
+ .then((r) => r.json())
1294
+ if (!oktaUser) return null
1295
+ return {
1296
+ id: `okta:${oktaUser.id}`,
1297
+ email: oktaUser.profile.email,
1298
+ passwordHash: '', // we never auth password locally — see below
1299
+ tenantId: oktaUser.profile.tenantId,
1300
+ createdAt: new Date(oktaUser.created),
1301
+ }
1302
+ }),
1303
+ // … the remaining methods (findById, insert, setMfaSecret,
1304
+ // markMfaEnrolled, clearMfa) all return Effects too …
1305
+ })
1306
+ ```
1307
+
1308
+ For an external IdP you usually wouldn't use `handleSignIn` at all — sign-in happens at the IdP and a [strategy](/docs/authentication/strategies) (e.g. the Okta path of `@voltro/plugin-auth-oidc`) verifies the resulting JWT. A custom `UserStore` only matters if you also need to persist a local mirror.
1309
+
1310
+ ## Custom store — example: scoped extension
1311
+
1312
+ If you want to keep `postgresUserStore` for the auth-critical bits but augment with your own fields:
1313
+
1314
+ ```ts
1315
+ import { postgresUserStore } from '@voltro/plugin-auth'
1316
+ import type { UserStore } from '@voltro/plugin-auth'
1317
+ import { Effect } from 'effect'
1318
+
1319
+ const base = postgresUserStore(sql) // sql: a provided SqlClient
1320
+
1321
+ const extended: UserStore = {
1322
+ ...base,
1323
+ insert: (user) =>
1324
+ base.insert(user).pipe(
1325
+ Effect.tap((inserted) =>
1326
+ // Side-effect: also create a profile row
1327
+ Effect.promise(() =>
1328
+ ctx.store.insert('profiles', { userId: inserted.id, displayName: '' }),
1329
+ ),
1330
+ ),
1331
+ ),
1332
+ }
1333
+ ```
1334
+
1335
+ Wrap don't fork — the base store is maintained by the framework; you keep your extensions in your code.
1336
+
1337
+ ## Tenant on sign-up
1338
+
1339
+ `UserStore.insert(record)` requires `tenantId`. The handler picks it from:
1340
+
1341
+ 1. `record.tenantId` if you pass it explicitly (custom handler with an invite flow).
1342
+ 2. `AuthConfig.defaultTenantId` otherwise.
1343
+
1344
+ For real apps, you want one of:
1345
+
1346
+ - **Per-invite tenant** — store an `invites` table; sign-up consumes an invite token that names the tenant.
1347
+ - **One-tenant-per-email-domain** — derive tenant from email domain on sign-up.
1348
+ - **Self-serve tenant create** — sign-up creates a new tenant + the user is its admin.
1349
+
1350
+ The framework doesn't pick for you; the auth plugin ships the primitives + you wire the policy.
1351
+
1352
+ ## Anti-patterns
1353
+
1354
+ - **Storing the password hash with the user-facing record.** Even with `HttpOnly` cookies, accidentally returning `passwordHash` from a query is a disaster. Use a separate "PublicUser" type for everything that crosses the wire.
1355
+ - **Using `tenantId: undefined` for "global" users.** Voltro doesn't model global users — every user belongs to exactly one tenant. For cross-tenant admins, use a separate `staff` table or the `system` subject.
1356
+ - **Auth + business data in the same table.** Keep `users` minimal (email, hash, tenantId, timestamps). Profile data, settings, etc. go in joined tables.
1357
+
1358
+
1359
+
1360
+ ---
1361
+
1362
+ <!-- source: en/authentication/react.md -->
1363
+ ## React on the web side
1364
+
1365
+ _SubjectProvider, useSubject, useOptionalSubject, RequireAuth — the client-side auth surface from @voltro/plugin-auth/web._
1366
+
1367
+ `@voltro/plugin-auth/web` is the client-side surface. It never imports `node:crypto`, so it's safe to bundle into the browser. The cookie is verified on the server; this package only provides React glue for "what does the current user look like".
1368
+
1369
+ ## SubjectProvider
1370
+
1371
+ Mount once near the top of your layout tree:
1372
+
1373
+ ```tsx
1374
+ import { SubjectProvider } from '@voltro/plugin-auth/web'
1375
+ import { useServerRequest } from '@voltro/web'
1376
+ import { decodeSubjectFromRequest } from '../lib/auth'
1377
+
1378
+ export default function Layout({ children }) {
1379
+ const req = useServerRequest() // SSR snapshot of cookies + headers
1380
+ const subject = decodeSubjectFromRequest(req)
1381
+ return <SubjectProvider subject={subject}>{children}</SubjectProvider>
1382
+ }
1383
+ ```
1384
+
1385
+ `subject` can be `null` (anonymous) or a real `Subject` (signed in). The provider just stores it in a React context — no async work, no transport.
1386
+
1387
+ ## useSubject
1388
+
1389
+ ```tsx
1390
+ import { useSubject } from '@voltro/plugin-auth/web'
1391
+
1392
+ const Component = () => {
1393
+ const subject = useSubject() // throws if no SubjectProvider OR signed out
1394
+ return <p>Hi {subject.id}</p>
1395
+ }
1396
+ ```
1397
+
1398
+ Throws when:
1399
+
1400
+ - No `<SubjectProvider>` is mounted (dev mistake).
1401
+ - The mounted provider has `subject={null}` (you're calling `useSubject` from outside a guarded subtree).
1402
+
1403
+ Use inside `<RequireAuth>` blocks or pages you've already gated.
1404
+
1405
+ ## useOptionalSubject
1406
+
1407
+ ```tsx
1408
+ import { useOptionalSubject } from '@voltro/plugin-auth/web'
1409
+
1410
+ const Header = () => {
1411
+ const subject = useOptionalSubject() // Subject | null
1412
+ return subject
1413
+ ? <UserMenu subject={subject} />
1414
+ : <SignInButton />
1415
+ }
1416
+ ```
1417
+
1418
+ The right hook for "what do I render based on signed-in vs. not".
1419
+
1420
+ ## RequireAuth
1421
+
1422
+ A render-gate component:
1423
+
1424
+ ```tsx
1425
+ import { RequireAuth } from '@voltro/plugin-auth/web'
1426
+
1427
+ const Dashboard = () => (
1428
+ <RequireAuth fallback={<RedirectToSignIn />}>
1429
+ {(subject) => (
1430
+ <div>
1431
+ <h1>Hi {subject.id}</h1>
1432
+ <Projects tenantId={subject.tenantId} />
1433
+ </div>
1434
+ )}
1435
+ </RequireAuth>
1436
+ )
1437
+ ```
1438
+
1439
+ - Children can be plain JSX or a `(subject: Subject) => ReactNode` render-prop. The render-prop gives you typed Subject access without an extra `useSubject` call.
1440
+ - `fallback` defaults to `null` — pass a component to redirect / show a sign-in form.
1441
+
1442
+ ## Decoding the subject on SSR
1443
+
1444
+ The session cookie value is opaque + signed — on the server you'd verify with HMAC + `AUTH_SECRET`. On the client (which is what the framework's SSR usually serves at request time), you have two choices:
1445
+
1446
+ ### Option A — trust-then-verify
1447
+
1448
+ The web app reads the cookie payload via base64 decode only, gets the subject for UI purposes. EVERY action that mutates state goes back to the api which re-verifies the signature.
1449
+
1450
+ ```ts
1451
+ // web/src/lib/auth.ts
1452
+ export const decodeSubjectFromRequest = (req: ServerRequest | null): Subject | null => {
1453
+ if (!req) return null
1454
+ const cookie = req.cookies['voltro:session']
1455
+ if (!cookie) return null
1456
+ const [payloadB64] = cookie.split('.')
1457
+ if (!payloadB64) return null
1458
+ try {
1459
+ // The payload is `{ subject, exp }` — the whole Subject round-trips
1460
+ // inside the cookie, so read `payload.subject`, not a `sub` claim.
1461
+ const payload = JSON.parse(atob(payloadB64))
1462
+ return payload.subject ?? null
1463
+ } catch {
1464
+ return null
1465
+ }
1466
+ }
1467
+ ```
1468
+
1469
+ The downside: a tampered cookie shows the wrong UI. The upside: the api validates on every real action so the worst case is "wrong avatar in the header".
1470
+
1471
+ This is the cloud dashboard's pattern — keep `node:crypto` out of the web bundle.
1472
+
1473
+ ### Option B — verify on the api, ship the decoded subject
1474
+
1475
+ The api exposes a `/auth/me` endpoint that decodes the cookie + returns the verified Subject. The web app's loader calls it.
1476
+
1477
+ ```tsx
1478
+ export const loader = async ({ headers }) => {
1479
+ const res = await fetch(`${API}/auth/me`, { headers: { cookie: headers.cookie } })
1480
+ return res.ok ? await res.json() : null
1481
+ }
1482
+ ```
1483
+
1484
+ The downside: one extra request per page load. The upside: cryptographically verified subject.
1485
+
1486
+ For high-stakes apps (banking, healthcare), use Option B. For typical SaaS, Option A + server-side verification on actions is fine.
1487
+
1488
+ ## Sign-out button
1489
+
1490
+ Always a form POST — `HttpOnly` cookies mean JS can't clear them, only the server (via a `Set-Cookie: …; Max-Age=0`).
1491
+
1492
+ ```tsx
1493
+ <form action={`${API}/auth/signout`} method="post">
1494
+ <button type="submit">Sign out</button>
1495
+ </form>
1496
+ ```
1497
+
1498
+ `SameSite=lax` cookies flow on form POSTs even cross-origin.
1499
+
1500
+ ## Sign-in form
1501
+
1502
+ ```tsx
1503
+ <form action={`${API}/auth/signin`} method="post">
1504
+ <input name="email" type="email" required />
1505
+ <input name="password" type="password" required />
1506
+ <button type="submit">Sign in</button>
1507
+ </form>
1508
+ ```
1509
+
1510
+ The api 302-redirects to `AUTH_CONFIG.successRedirect` on success. The next page load reads the cookie via `<SubjectProvider>` + your auth-gated UI lights up.
1511
+
1512
+ For inline-validation / "shake on bad password" UX, use a fetch-based form + the JSON response variant of `handleSignIn`.
1513
+
1514
+ ## Switching tenants
1515
+
1516
+ A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships — read them on the client with `subjectMemberships(subject)` to render a tenant switcher:
1517
+
1518
+ ```tsx
1519
+ import { useSubject } from '@voltro/plugin-auth/web'
1520
+ import { subjectMemberships } from '@voltro/plugin-auth'
1521
+
1522
+ const TenantSwitcher = () => {
1523
+ const subject = useSubject()
1524
+ const memberships = subjectMemberships(subject)
1525
+ const switchTo = async (tenantId: string) => {
1526
+ await fetch('/auth/switch-tenant', {
1527
+ method: 'POST',
1528
+ credentials: 'include',
1529
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
1530
+ body: JSON.stringify({ targetTenantId: tenantId }),
1531
+ })
1532
+ }
1533
+ return (
1534
+ <ul>
1535
+ {memberships.map((m) => (
1536
+ <li key={m.tenantId}>
1537
+ <button disabled={m.tenantId === subject.tenantId} onClick={() => switchTo(m.tenantId)}>
1538
+ {m.tenantId} ({m.role})
1539
+ </button>
1540
+ </li>
1541
+ ))}
1542
+ </ul>
1543
+ )
1544
+ }
1545
+ ```
1546
+
1547
+ `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.
1548
+
1549
+ ## Anti-patterns
1550
+
1551
+ - **Reading `document.cookie` for the session.** It's `HttpOnly` — you can't. Use `useSubject` / `useOptionalSubject`.
1552
+ - **Storing the subject in `localStorage` for "fast access".** It goes stale, leaks via XSS, and there's no security model that justifies it. The cookie IS the cache.
1553
+ - **Calling auth hooks in non-React contexts.** They depend on React context — use them inside components or custom hooks.
1554
+
1555
+
1556
+
1557
+ ---
1558
+
1559
+ <!-- source: en/authentication/cookies.md -->
1560
+ ## Cookie security
1561
+
1562
+ _The production cookie checklist — Secure, HttpOnly, SameSite, Domain, Path, Max-Age, CSRF defence._
1563
+
1564
+ A session cookie carries the keys to the user's account. Get the attributes wrong and you're handing them out to attackers. This page is the audit list.
1565
+
1566
+ ## The attributes that matter
1567
+
1568
+ | Attribute | Value | Why |
1569
+ |---|---|---|
1570
+ | `Secure` | `true` in production | Cookie only sent over HTTPS. Without it, an attacker on the network reads the cookie out of an HTTP request. |
1571
+ | `HttpOnly` | always | JS can't read it. Defeats XSS-extraction of session tokens. |
1572
+ | `SameSite` | `lax` (hardcoded) | CSRF defence. Not configurable; the plugin never emits `strict` or `none`. |
1573
+ | `Domain` | unset (default) OR `.your-product.com` | Unset = host-only (strict). Set = shared across subdomains. |
1574
+ | `Path` | `/` | Always covers the whole app. Narrower paths cause subtle "cookie missing on some requests" bugs. |
1575
+ | `Max-Age` | configurable, default 7 days | Session lifetime. |
1576
+
1577
+ ## Production AuthConfig
1578
+
1579
+ ```ts
1580
+ const AUTH_CONFIG: AuthConfig = {
1581
+ secret: process.env.AUTH_SECRET!, // 32+ bytes from a CSPRNG
1582
+ defaultTenantId: 'public',
1583
+ cookieSecure: true, // ALWAYS true in prod
1584
+ cookieDomain: '.your-product.com', // optional; for subdomain sharing
1585
+ successRedirect: 'https://app.your-product.com/',
1586
+ }
1587
+ ```
1588
+
1589
+ `AuthConfig` is exactly `{ secret, defaultTenantId, cookieDomain?, cookieSecure?, successRedirect? }`. **`SameSite` is not configurable** — the session builder hardcodes `SameSite=lax`. There is no `cookieSameSite` field.
1590
+
1591
+ ## SameSite is always lax
1592
+
1593
+ The cookie ships `SameSite=lax`. That's the right choice for the typical sign-in flow: `lax` cookies are sent on top-level GETs and cross-origin form POSTs, so the cookie lands before the post-sign-in redirect. `strict` would block cross-origin sign-in entirely, and `none` (cross-site cookies) is a footgun the framework simply doesn't emit. If you want the stricter same-origin posture, put api + web behind one reverse proxy (below) — same-origin requests carry the cookie regardless.
1594
+
1595
+ ## Same-origin reverse proxy (recommended)
1596
+
1597
+ The cleanest production layout:
1598
+
1599
+ ```
1600
+ https://acme.com/ → web (landing + dashboard)
1601
+ https://acme.com/api/* → api app
1602
+ https://acme.com/docs/* → docs site
1603
+ ```
1604
+
1605
+ Caddyfile:
1606
+
1607
+ ```caddyfile
1608
+ acme.com {
1609
+ reverse_proxy /api/* api:4000
1610
+ reverse_proxy /docs/* docs:5181
1611
+ reverse_proxy * web:5191
1612
+ }
1613
+ ```
1614
+
1615
+ With this:
1616
+
1617
+ - One origin to the browser = `SameSite=strict` works
1618
+ - No `Domain` attribute needed (host-only is fine)
1619
+ - No CORS preflight noise
1620
+ - One TLS certificate covers everything
1621
+
1622
+ This is the layout we recommend for any deploy that isn't multi-region.
1623
+
1624
+ ## Cross-origin (split api / web)
1625
+
1626
+ For multi-region + edge-cached web with the api in one region:
1627
+
1628
+ ```
1629
+ https://acme.com → web (CDN-cached)
1630
+ https://api.acme.com → api (one region)
1631
+ ```
1632
+
1633
+ You need:
1634
+
1635
+ - `cookieDomain: '.acme.com'` so the cookie is set by api.acme.com but flows on acme.com requests
1636
+ - `SameSite=lax` (already the hardcoded default) so form POST sign-in works
1637
+ - CORS allow-list on the api: `Access-Control-Allow-Origin: https://acme.com` + `Access-Control-Allow-Credentials: true`
1638
+
1639
+ The cookie boundary is the registrable domain (`acme.com`), so `Domain=.acme.com` covers both subdomains.
1640
+
1641
+ ## CSRF defence
1642
+
1643
+ `SameSite=lax` blocks GET-based CSRF + most cross-origin write attempts, and it's the first line of defence. On top of it, the plugin ships a signed double-submit CSRF token: `GET /auth/csrf` (handler `handleCsrf`) sets a readable `voltro:csrf` cookie + returns the token in JSON; the SPA echoes it in the `x-csrf-token` header on every state-changing call, and `authRoutesPlugin()` rejects authenticated mutations whose header and cookie don't match a server-signed token (`verifyCsrf`). The token is HMAC-signed so a cookie-injecting network attacker can't forge one.
1644
+
1645
+ ## Cookie secret
1646
+
1647
+ ```bash
1648
+ # Generate a strong secret
1649
+ node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
1650
+ ```
1651
+
1652
+ Store it:
1653
+
1654
+ - **Production:** in your secrets manager (AWS SM, Doppler, 1Password, Vercel env). NEVER commit to git.
1655
+ - **Staging:** different secret than production. Rotating one shouldn't kill the other.
1656
+ - **CI:** mocked or randomly-generated per-job for tests.
1657
+
1658
+ ## Rotation
1659
+
1660
+ Multi-key rotation ships. Set the new secret as `VOLTRO_SESSION_SECRET` and move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS`; `resolveSessionSecrets()` returns both, `signSession` stamps the current key's `kid`, and `verifySessionKeyed` accepts either during the rotation window. Decommission the old key (drop `_PREVIOUS`) after the max session lifetime — nobody is signed out.
1661
+
1662
+ ## Audit checklist before going to prod
1663
+
1664
+ - [ ] `cookieSecure: true`
1665
+ - [ ] `AUTH_SECRET` is 32+ bytes from a real CSPRNG, in a secrets manager
1666
+ - [ ] HTTPS terminates at the edge with HSTS preload enabled
1667
+ - [ ] Cookies are HttpOnly (verify in browser devtools → Application → Cookies)
1668
+ - [ ] No JavaScript reads `document.cookie` for the session
1669
+ - [ ] CORS allow-list is exact origins (no `*` with credentials)
1670
+ - [ ] Sign-out POST endpoint exists and clears the cookie
1671
+ - [ ] Different `AUTH_SECRET` per environment
1672
+ - [ ] Session `Max-Age` matches your risk profile (not 365 days for a banking app)
1673
+
1674
+ ## What the framework does NOT defend against
1675
+
1676
+ - **Server-side bugs that leak the cookie value** (e.g. logging `req.headers.cookie` to an aggregator). Audit your logging pipeline.
1677
+ - **Compromised user device.** A keylogger on a user's machine sees the password. TOTP/MFA enrolment ships today (see [HTTP handlers](/docs/authentication/handlers#mfa--totp-handlers-shipped)); passkeys ship too (phishing-resistant, no shared secret to keylog — see the [auth plugin overview](/docs/plugins/auth#passkeys--webauthn)). Ultimately devices have to be trusted.
1678
+ - **Compromised provider.** If your secrets manager leaks `AUTH_SECRET`, every session is forgeable. Rotate immediately + invalidate every session.
1679
+ - **Phishing.** A user signing in on an attacker-controlled site → attacker has the cookie. Defence is browser-level (SSL pinning, password managers refusing to autofill on wrong domain). Passkeys help here directly: WebAuthn binds the credential to the origin, so an assertion produced on a phishing domain fails the origin/rpId check server-side.
1680
+
1681
+ For high-stakes apps (banking, healthcare), layer on:
1682
+
1683
+ - Step-up auth for sensitive operations (re-prompt for a TOTP code or password before billing changes)
1684
+ - Device fingerprinting + new-device alerts
1685
+ - IP-based anomaly detection
1686
+ - Login notifications via email
1687
+
1688
+
1689
+
1690
+ ---
1691
+
1692
+ <!-- source: en/authentication/authorization.md -->
1693
+ ## Authorization (ReBAC)
1694
+
1695
+ _Relationship-based access control — declare per-resource policies (which relations grant which actions), decide with can()/assertCan() (fail-closed, typed AccessDenied), hide forbidden rows from reads, and reactively drop rows from open subscriptions the moment a grant is revoked._
1696
+
1697
+ Authorization is **relationship-based (ReBAC)**: access follows from
1698
+ *relationships* between a subject and a resource (`owner`, `editor`, `viewer`, a
1699
+ team membership) rather than a flat role. You declare a per-resource policy, the
1700
+ engine decides with `can(...)`, and — because the framework is reactive — a
1701
+ **revoked grant removes the now-forbidden rows from every open subscription
1702
+ live**, with no refresh.
1703
+
1704
+ ## Relationship tuples
1705
+
1706
+ A tuple says *subject `S` has relation `R` on `<type>:<id>`*. They're plain rows
1707
+ (the framework's `_voltro_rebac_tuples` table, or your own relation rows):
1708
+
1709
+ ```ts
1710
+ // alice is the owner of todo:42 ; the acme team can view it
1711
+ { subjectId: 'alice', relation: 'owner', resourceType: 'todo', resourceId: '42' }
1712
+ { subjectId: 'acme', relation: 'viewer', resourceType: 'todo', resourceId: '42' }
1713
+ ```
1714
+
1715
+ ## Declare a policy
1716
+
1717
+ `defineResourcePolicy` maps each action to the relations that grant it (ANY-of),
1718
+ with optional relation `implies` (a transitive closure — `owner ⇒ editor ⇒
1719
+ viewer`). Registered at import; the capability map exposes the graph.
1720
+
1721
+ ```ts
1722
+ import { defineResourcePolicy } from '@voltro/runtime'
1723
+
1724
+ export const todoPolicy = defineResourcePolicy({
1725
+ resourceType: 'todo',
1726
+ actions: {
1727
+ view: ['viewer', 'editor', 'owner'],
1728
+ edit: ['editor', 'owner'],
1729
+ delete: ['owner'],
1730
+ },
1731
+ implies: { owner: ['editor'], editor: ['viewer'] },
1732
+ })
1733
+ ```
1734
+
1735
+ ## Decide — `can` / `assertCan`
1736
+
1737
+ `can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
1738
+ throws the typed `AccessDenied`. It **fails closed**: `admin:full` scope is the
1739
+ only bypass, an anonymous subject is denied, a cross-tenant resource is denied,
1740
+ an unknown action is denied — otherwise allow iff the subject's effective
1741
+ relations intersect the action's grant set.
1742
+
1743
+ ```ts
1744
+ import { assertCan, loadResourceTuples, AccessDenied } from '@voltro/runtime'
1745
+ import { todoPolicy } from '../policies/todo.policy'
1746
+
1747
+ // in a mutation's *.server.ts
1748
+ const tuples = await loadResourceTuples(ctx.store, ctx.request.subject.id, 'todo', input.id)
1749
+ assertCan(
1750
+ ctx.request.subject,
1751
+ 'edit',
1752
+ { type: 'todo', id: input.id, tenantId: ctx.request.subject.tenantId },
1753
+ { policy: todoPolicy, tuples },
1754
+ ) // throws AccessDenied on a deny
1755
+ ```
1756
+
1757
+ Declare `error: AccessDenied` on the descriptor so the denial surfaces to the
1758
+ client **typed + pattern-matchable**, never a bare 500. For per-rpc enforcement
1759
+ without a hand-written guard, `buildRebacInterceptor` runs the same `can()` check
1760
+ in the rpc pipeline; `buildRebacReadFilter` / `visibleRows` hide forbidden rows
1761
+ from a read instead of failing it.
1762
+
1763
+ ## Live revocation
1764
+
1765
+ Because reads are reactive, authorization is too. `revokedIds(before, after)`
1766
+ is the core: the ids a subject could see before a permission-changing write but
1767
+ not after. The reactive layer pushes that delta to every open subscription, so
1768
+ the moment alice's grant on `todo:42` is revoked, the row **disappears from her
1769
+ screen** — no refetch, no refresh. (Enforcement is phased first; revocation is
1770
+ the headline that builds on it.)
1771
+
1772
+ ## Client
1773
+
1774
+ `useResourceCan` / `useResourceCans` resolve a subject's permission reactively
1775
+ (fail-closed), so the UI hides an action the moment it's revoked:
1776
+
1777
+ ```tsx
1778
+ import { useResourceCan } from '@voltro/client'
1779
+
1780
+ const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
1781
+ // canEdit.allowed: boolean | undefined (undefined until resolved); reactive.
1782
+ ```
1783
+
1784
+ ## Capability map
1785
+
1786
+ `rebacPolicyGraph()` returns every resource type, its actions, the relations each
1787
+ grants, and the implication edges — the policy graph a dashboard or an AI agent
1788
+ reads to reason about authority without grepping the code.