@voltro/cli 0.28.0 → 0.29.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 (280) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
  12. package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
  13. package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  25. package/dist/serveEntry.js +2 -2
  26. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  27. package/dist/startEntry.js +2 -2
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +2 -1
  30. package/templates/agent-docs/_index.md +2 -1
  31. package/templates/agent-docs/_manifest.json +10 -1
  32. package/templates/agent-docs/ai.md +135 -0
  33. package/templates/agent-docs/cli.md +124 -6
  34. package/templates/agent-docs/configuration.md +32 -0
  35. package/templates/agent-docs/data.md +282 -0
  36. package/templates/agent-docs/database/migrations.md +47 -0
  37. package/templates/agent-docs/database/misc.md +57 -0
  38. package/templates/agent-docs/database/schema.md +3 -1
  39. package/templates/agent-docs/internationalization.md +161 -1
  40. package/templates/agent-docs/local-first-mobile.md +414 -0
  41. package/templates/agent-docs/routing.md +93 -2
  42. package/templates/agent-docs/schema-driven-ui.md +12 -0
  43. package/templates/agent-docs/templates/apibackends.md +1 -1
  44. package/templates/agent-docs/whats-new.md +162 -181
  45. package/templates/agent-docs/workflows.md +11 -0
  46. package/templates/apps/api-ai/package.json +7 -7
  47. package/templates/apps/api-auth/package.json +8 -8
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/package.json +7 -7
  50. package/templates/apps/api-backend-mail/package.json +8 -8
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  53. package/templates/apps/api-backend-sqlite/README.md +38 -0
  54. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  55. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  56. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  58. package/templates/apps/api-backend-sqlite/package.json +30 -0
  59. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  60. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  61. package/templates/apps/api-backend-sqlite/template.json +6 -0
  62. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  63. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  64. package/templates/apps/api-backend-storage/package.json +8 -8
  65. package/templates/apps/api-cms/README.md +104 -0
  66. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  67. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  68. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  69. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  70. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  71. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  72. package/templates/apps/api-cms/app.config.ts +61 -0
  73. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  74. package/templates/apps/api-cms/content/index.ts +18 -0
  75. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  76. package/templates/apps/api-cms/database/schema.ts +64 -0
  77. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  78. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  79. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  80. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  81. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  82. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  83. package/templates/apps/api-cms/package.json +32 -0
  84. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  85. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  86. package/templates/apps/api-cms/template.json +6 -0
  87. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  88. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  89. package/templates/apps/api-cms/tsconfig.json +5 -0
  90. package/templates/apps/api-data-advanced/package.json +8 -8
  91. package/templates/apps/api-durable/package.json +8 -8
  92. package/templates/apps/api-feature-flags/package.json +9 -9
  93. package/templates/apps/api-governance/package.json +8 -8
  94. package/templates/apps/api-kv/package.json +8 -8
  95. package/templates/apps/api-moderation/package.json +8 -8
  96. package/templates/apps/api-observability/package.json +8 -8
  97. package/templates/apps/api-ratelimit/package.json +8 -8
  98. package/templates/apps/api-rbac/package.json +8 -8
  99. package/templates/apps/api-rest/package.json +7 -7
  100. package/templates/apps/api-saas/package.json +11 -11
  101. package/templates/apps/api-saas-starter/README.md +103 -0
  102. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  103. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  104. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  105. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  106. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  107. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  108. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  109. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  110. package/templates/apps/api-saas-starter/package.json +32 -0
  111. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  112. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  113. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  114. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  115. package/templates/apps/api-saas-starter/template.json +6 -0
  116. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  117. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  118. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/README.md +88 -0
  121. package/templates/apps/api-status/app.config.ts +36 -0
  122. package/templates/apps/api-status/authz.ts +33 -0
  123. package/templates/apps/api-status/database/schema.ts +70 -0
  124. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  125. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  126. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  127. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  128. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  129. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  130. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  131. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  132. package/templates/apps/api-status/package.json +30 -0
  133. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  134. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  135. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  136. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  137. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  138. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  139. package/templates/apps/api-status/template.json +6 -0
  140. package/templates/apps/api-status/tests/status.test.ts +70 -0
  141. package/templates/apps/api-status/tsconfig.json +5 -0
  142. package/templates/apps/api-versioning/package.json +8 -8
  143. package/templates/apps/api-webhooks/package.json +9 -9
  144. package/templates/apps/changelog/package.json +6 -6
  145. package/templates/apps/edge-functions/package.json +2 -2
  146. package/templates/apps/frontend-admin/package.json +8 -8
  147. package/templates/apps/frontend-app/package.json +8 -8
  148. package/templates/apps/frontend-auth/README.md +78 -0
  149. package/templates/apps/frontend-auth/app.config.ts +34 -0
  150. package/templates/apps/frontend-auth/package.json +32 -0
  151. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  152. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  153. package/templates/apps/frontend-auth/src/config.ts +11 -0
  154. package/templates/apps/frontend-auth/src/globals.css +105 -0
  155. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  156. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  157. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  158. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  159. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  160. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  161. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  162. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  163. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  164. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  165. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  166. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  167. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  168. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  169. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  170. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  171. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  172. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  173. package/templates/apps/frontend-auth/template.json +6 -0
  174. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  175. package/templates/apps/frontend-blank/package.json +7 -7
  176. package/templates/apps/frontend-cms/README.md +47 -0
  177. package/templates/apps/frontend-cms/app.config.ts +37 -0
  178. package/templates/apps/frontend-cms/package.json +33 -0
  179. package/templates/apps/frontend-cms/src/config.ts +8 -0
  180. package/templates/apps/frontend-cms/src/globals.css +105 -0
  181. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  182. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  183. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  184. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  185. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  186. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  187. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  188. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  192. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  193. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  194. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  195. package/templates/apps/frontend-cms/template.json +6 -0
  196. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  197. package/templates/apps/frontend-contact/package.json +7 -7
  198. package/templates/apps/frontend-dashboard/package.json +7 -7
  199. package/templates/apps/frontend-docs/package.json +7 -7
  200. package/templates/apps/frontend-i18n/package.json +6 -6
  201. package/templates/apps/frontend-landing/package.json +7 -7
  202. package/templates/apps/frontend-portal/README.md +71 -0
  203. package/templates/apps/frontend-portal/app.config.ts +37 -0
  204. package/templates/apps/frontend-portal/package.json +32 -0
  205. package/templates/apps/frontend-portal/src/config.ts +8 -0
  206. package/templates/apps/frontend-portal/src/globals.css +93 -0
  207. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  208. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  209. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  210. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  211. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  212. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  213. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  214. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  223. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  224. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  225. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  226. package/templates/apps/frontend-portal/template.json +6 -0
  227. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  228. package/templates/apps/frontend-saas/README.md +73 -0
  229. package/templates/apps/frontend-saas/app.config.ts +46 -0
  230. package/templates/apps/frontend-saas/package.json +32 -0
  231. package/templates/apps/frontend-saas/src/config.ts +8 -0
  232. package/templates/apps/frontend-saas/src/globals.css +85 -0
  233. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  234. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  235. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  236. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  237. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  238. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  239. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  240. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  242. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  243. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  244. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  250. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  251. package/templates/apps/frontend-saas/template.json +6 -0
  252. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  253. package/templates/apps/frontend-spa/package.json +7 -7
  254. package/templates/apps/frontend-ssr/package.json +7 -7
  255. package/templates/apps/frontend-ssr-api/package.json +8 -8
  256. package/templates/apps/frontend-static-blog/package.json +6 -6
  257. package/templates/apps/frontend-status/README.md +51 -0
  258. package/templates/apps/frontend-status/app.config.ts +39 -0
  259. package/templates/apps/frontend-status/package.json +32 -0
  260. package/templates/apps/frontend-status/src/config.ts +7 -0
  261. package/templates/apps/frontend-status/src/globals.css +70 -0
  262. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  263. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  264. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  265. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  266. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  267. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  268. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  269. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  270. package/templates/apps/frontend-status/template.json +6 -0
  271. package/templates/apps/frontend-status/tsconfig.json +11 -0
  272. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  273. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  274. package/dist/apiBuild-DgBS9ayv.js +0 -2
  275. package/dist/dbCommand-DvguqlzF.js +0 -2
  276. package/dist/fileConventions-3bffWssN.js +0 -30
  277. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  278. package/dist/inspect-gt8bq-Tz.js +0 -2
  279. package/dist/manifestBuild-ifczArzr.js +0 -2
  280. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -0,0 +1,27 @@
1
+ // `content.list` — a LIVE, tenant-scoped list of one content type's rows. The
2
+ // editor subscribes with `useSubscription('cms', 'content.list', { type })`; a
3
+ // publish/save wakes the subscription so the list updates without a refetch.
4
+ // `status` picks the table: 'draft' (the editor's working set, the default) or
5
+ // 'published' (what consumers read).
6
+ //
7
+ // The output pins only the columns a list view needs — the runtime drops the
8
+ // rest (`Schema.Struct` ignores excess), so ONE query shape serves every content
9
+ // type even though their full rows differ. `title`/`slug` are optional so a type
10
+ // without them still decodes.
11
+ import { defineQuery } from '@voltro/protocol'
12
+ import { Schema } from 'effect'
13
+
14
+ export const listContent = defineQuery({
15
+ name: 'content.list',
16
+ input: Schema.Struct({
17
+ type: Schema.NonEmptyString,
18
+ status: Schema.optional(Schema.Literal('draft', 'published')),
19
+ }),
20
+ output: Schema.Struct({
21
+ id: Schema.String,
22
+ status: Schema.String,
23
+ title: Schema.optional(Schema.String),
24
+ slug: Schema.optional(Schema.String),
25
+ updatedAt: Schema.Date,
26
+ }),
27
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "api-cms",
3
+ "kind": "api",
4
+ "summary": "A headless CMS backend composed from @voltro/cms: content types declared as code (defineContentType + the Schema field DSL) compile to draft/published tables, the write pipeline (saveDraft → derive+validate, publish/unpublish) drives the lifecycle, and a typed rpc surface (content.list/get/types/saveDraft/publish/unpublish) feeds an editor. Auth via @voltro/plugin-auth; tenant-scoped content. Pair with the frontend-cms editor web template. Boots zero-infra.",
5
+ "tags": ["api", "cms", "content", "headless", "drafts", "publishing", "editor", "auth"]
6
+ }
@@ -0,0 +1,64 @@
1
+ // Descriptor + executor contract pins — the shapes the client codegen and the
2
+ // editor depend on. No database, no running server. Run with `voltro test`.
3
+
4
+ import { describe, it, expect } from 'vitest'
5
+ import { Schema } from 'effect'
6
+ import { makeTestContext } from '@voltro/testing'
7
+ import '../database/schema' // registers the core + derived content tables
8
+ import { saveDraft } from '../mutations/content.saveDraft.mutation'
9
+ import { listContent } from '../queries/content.list.query'
10
+ import contentTypesExecute from '../actions/content.types.action.server'
11
+
12
+ describe('content.saveDraft descriptor', () => {
13
+ it('decodes an input carrying arbitrary per-type values', () => {
14
+ const decode = Schema.decodeUnknownSync(saveDraft.input)
15
+ const input = { type: 'blogPost', values: { title: 'Hi', body: 'x' } }
16
+ expect(decode(input)).toEqual(input)
17
+ })
18
+
19
+ it('encodes both arms of the result union', () => {
20
+ const encode = Schema.encodeUnknownSync(saveDraft.output)
21
+ expect(encode({ ok: true, id: 'blogPost_1', status: 'draft' })).toEqual({ ok: true, id: 'blogPost_1', status: 'draft' })
22
+ const fail = { ok: false, violations: [{ field: 'title', rule: 'required', message: 'missing' }] }
23
+ expect(encode(fail)).toEqual(fail)
24
+ })
25
+ })
26
+
27
+ describe('content.list descriptor', () => {
28
+ it('accepts a type + optional status and pins the list projection shape', () => {
29
+ const decodeIn = Schema.decodeUnknownSync(listContent.input)
30
+ expect(decodeIn({ type: 'blogPost', status: 'draft' })).toEqual({ type: 'blogPost', status: 'draft' })
31
+ // The output carries only what a list view needs — id/status/title?/slug?/
32
+ // updatedAt; the runtime drops a row's other columns against this shape.
33
+ const encode = Schema.encodeUnknownSync(listContent.output)
34
+ const wire = encode({ id: 'p1', status: 'published', title: 'T', slug: 't', updatedAt: new Date(0) })
35
+ expect(wire.id).toBe('p1')
36
+ expect(wire.status).toBe('published')
37
+ expect(wire.title).toBe('T')
38
+ })
39
+ })
40
+
41
+ describe('content.types executor', () => {
42
+ it('projects the registered types to a wire-safe, order-preserving descriptor', async () => {
43
+ const ctx = makeTestContext({ subject: { type: 'user', id: 'u1', tenantId: 'acme' } })
44
+ const out = await contentTypesExecute({}, ctx)
45
+
46
+ const names = out.types.map((t) => t.name)
47
+ expect(names).toContain('blogPost')
48
+ expect(names).toContain('page')
49
+
50
+ const blog = out.types.find((t) => t.name === 'blogPost')!
51
+ const fieldNames = blog.fields.map((f) => f.name)
52
+ expect(fieldNames).toEqual(['title', 'slug', 'body', 'excerpt', 'publishedAt'])
53
+ // The slug field is a string with a richer widget than plain text is fine —
54
+ // and its derived transform (a function) is NOT on the wire shape.
55
+ const slug = blog.fields.find((f) => f.name === 'slug')!
56
+ expect(slug.kind).toBe('string')
57
+ expect('transform' in slug).toBe(false)
58
+ // The Literal `layout` on `page` carries its value set for the <select>.
59
+ const page = out.types.find((t) => t.name === 'page')!
60
+ const layout = page.fields.find((f) => f.name === 'layout')!
61
+ expect(layout.editorHint).toBe('select')
62
+ expect(layout.literals).toEqual(['default', 'wide', 'landing'])
63
+ })
64
+ })
@@ -0,0 +1,85 @@
1
+ // The CMS write pipeline against the REAL runtime store spine — an
2
+ // InMemoryDataStore wrapped by `wrapStoreWithMixinBehaviour` with a registry
3
+ // built from the SAME derived tables the app registers. That's what makes these
4
+ // assertions honest: derivation + validation come from @voltro/cms, and
5
+ // tenant-scoping/stamping come from the spine, not a mock. Run with `voltro test`.
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import {
9
+ InMemoryDataStore,
10
+ makeSchemaRegistry,
11
+ wrapStoreWithMixinBehaviour,
12
+ type RegistryTableLike,
13
+ } from '@voltro/runtime'
14
+ import { ContentValidationFailed, publish, saveDraft } from '@voltro/cms'
15
+ import { blogPost } from '../content'
16
+ import { blogEntities, pageEntities } from '../database/schema'
17
+
18
+ // Registry over the real derived tables → hasTenant/hasAudit/idScheme reflect a
19
+ // booted app exactly.
20
+ const registry = makeSchemaRegistry([
21
+ blogEntities.draft, blogEntities.published,
22
+ pageEntities.draft, pageEntities.published,
23
+ ] as ReadonlyArray<RegistryTableLike>)
24
+
25
+ // A request-scoped store for one tenant over a shared underlying store.
26
+ const storeFor = (underlying: InMemoryDataStore, tenantId: string) =>
27
+ wrapStoreWithMixinBehaviour(underlying, {
28
+ subject: { type: 'user', id: 'usr_editor', tenantId },
29
+ schemaRegistry: registry,
30
+ })
31
+
32
+ const validInput = {
33
+ title: 'Hello World',
34
+ body: { type: 'doc', content: [{ type: 'paragraph' }] },
35
+ }
36
+
37
+ describe('saveDraft → derive + validate + write', () => {
38
+ it('derives the slug from the title, stamps status/tenant, generates an id', async () => {
39
+ const store = storeFor(new InMemoryDataStore(), 'tnt_A')
40
+ const draft = await saveDraft(store, blogPost, validInput)
41
+
42
+ expect(draft['slug']).toBe('hello-world') // derivedFrom('title', slugify)
43
+ expect(draft['status']).toBe('draft')
44
+ expect(draft['tenantId']).toBe('tnt_A') // stamped by the spine
45
+ expect(typeof draft['id']).toBe('string')
46
+ })
47
+
48
+ it('rejects an invalid row with ContentValidationFailed carrying per-field violations', async () => {
49
+ const store = storeFor(new InMemoryDataStore(), 'tnt_A')
50
+ // Missing required `title` (so the derived slug is empty → pattern fails too).
51
+ await expect(saveDraft(store, blogPost, { body: 'x' })).rejects.toBeInstanceOf(ContentValidationFailed)
52
+ })
53
+ })
54
+
55
+ describe('publish → copy draft to published', () => {
56
+ it('publishes a draft and the published row keeps the same id + slug', async () => {
57
+ const store = storeFor(new InMemoryDataStore(), 'tnt_A')
58
+ const draft = await saveDraft(store, blogPost, validInput)
59
+
60
+ const published = await publish(store, blogPost, draft['id'] as string)
61
+ expect(published['id']).toBe(draft['id'])
62
+ expect(published['slug']).toBe('hello-world')
63
+ expect(published['status']).toBe('published')
64
+ })
65
+ })
66
+
67
+ describe('tenant isolation (REAL scoping)', () => {
68
+ it("tenant B never sees tenant A's published content", async () => {
69
+ const underlying = new InMemoryDataStore()
70
+ const a = storeFor(underlying, 'tnt_A')
71
+ const b = storeFor(underlying, 'tnt_B')
72
+
73
+ const draft = await saveDraft(a, blogPost, validInput)
74
+ await publish(a, blogPost, draft['id'] as string)
75
+
76
+ const seenByA = await a.query({
77
+ table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
78
+ })
79
+ const seenByB = await b.query({
80
+ table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
81
+ })
82
+ expect(seenByA).toHaveLength(1)
83
+ expect(seenByB).toHaveLength(0)
84
+ })
85
+ })
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/database": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/plugin-governance": "0.28.0",
19
- "@voltro/plugin-multitenancy": "0.28.0",
20
- "@voltro/protocol": "0.28.0",
21
- "@voltro/runtime": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/database": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/plugin-governance": "0.29.0",
19
+ "@voltro/plugin-multitenancy": "0.29.0",
20
+ "@voltro/protocol": "0.29.0",
21
+ "@voltro/runtime": "0.29.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.28.0",
25
+ "@voltro/testing": "0.29.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/database": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/plugin-multitenancy": "0.28.0",
19
- "@voltro/protocol": "0.28.0",
20
- "@voltro/runtime": "0.28.0",
21
- "@voltro/workflow": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/database": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/plugin-multitenancy": "0.29.0",
19
+ "@voltro/protocol": "0.29.0",
20
+ "@voltro/runtime": "0.29.0",
21
+ "@voltro/workflow": "0.29.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.28.0",
25
+ "@voltro/testing": "0.29.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,18 +13,18 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/plugin-flags": "0.28.0",
20
- "@voltro/plugin-multitenancy": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
23
- "@voltro/sql-postgres": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/plugin-flags": "0.29.0",
20
+ "@voltro/plugin-multitenancy": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
+ "@voltro/sql-postgres": "0.29.0",
24
24
  "effect": "^3.22.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.28.0",
27
+ "@voltro/testing": "0.29.0",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.10"
30
30
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/plugin-audit": "0.28.0",
20
- "@voltro/plugin-governance": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/plugin-audit": "0.29.0",
20
+ "@voltro/plugin-governance": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.28.0",
26
+ "@voltro/testing": "0.29.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/database": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/kv": "0.28.0",
19
- "@voltro/plugin-multitenancy": "0.28.0",
20
- "@voltro/protocol": "0.28.0",
21
- "@voltro/runtime": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/database": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/kv": "0.29.0",
19
+ "@voltro/plugin-multitenancy": "0.29.0",
20
+ "@voltro/protocol": "0.29.0",
21
+ "@voltro/runtime": "0.29.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.28.0",
25
+ "@voltro/testing": "0.29.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/plugin-moderation": "0.28.0",
20
- "@voltro/plugin-multitenancy": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/plugin-moderation": "0.29.0",
20
+ "@voltro/plugin-multitenancy": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.28.0",
26
+ "@voltro/testing": "0.29.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/plugin-multitenancy": "0.28.0",
19
- "@voltro/plugin-prometheus": "0.28.0",
20
- "@voltro/plugin-sentry": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/plugin-multitenancy": "0.29.0",
19
+ "@voltro/plugin-prometheus": "0.29.0",
20
+ "@voltro/plugin-sentry": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.28.0",
26
+ "@voltro/testing": "0.29.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/plugin-multitenancy": "0.28.0",
20
- "@voltro/plugin-ratelimit": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/plugin-multitenancy": "0.29.0",
20
+ "@voltro/plugin-ratelimit": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.28.0",
26
+ "@voltro/testing": "0.29.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/plugin-multitenancy": "0.28.0",
20
- "@voltro/plugin-rbac": "0.28.0",
21
- "@voltro/protocol": "0.28.0",
22
- "@voltro/runtime": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/plugin-multitenancy": "0.29.0",
20
+ "@voltro/plugin-rbac": "0.29.0",
21
+ "@voltro/protocol": "0.29.0",
22
+ "@voltro/runtime": "0.29.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.28.0",
26
+ "@voltro/testing": "0.29.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/plugin-openapi": "0.28.0",
19
- "@voltro/protocol": "0.28.0",
20
- "@voltro/runtime": "0.28.0",
21
- "@voltro/sql-postgres": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/plugin-openapi": "0.29.0",
19
+ "@voltro/protocol": "0.29.0",
20
+ "@voltro/runtime": "0.29.0",
21
+ "@voltro/sql-postgres": "0.29.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.28.0",
25
+ "@voltro/testing": "0.29.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,20 +13,20 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/database": "0.28.0",
18
- "@voltro/plugin-analytics-postgres": "0.28.0",
19
- "@voltro/plugin-billing": "0.28.0",
20
- "@voltro/plugin-multitenancy": "0.28.0",
21
- "@voltro/plugin-notifications": "0.28.0",
22
- "@voltro/plugin-presence": "0.28.0",
23
- "@voltro/protocol": "0.28.0",
24
- "@voltro/runtime": "0.28.0",
25
- "@voltro/sql-postgres": "0.28.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/database": "0.29.0",
18
+ "@voltro/plugin-analytics-postgres": "0.29.0",
19
+ "@voltro/plugin-billing": "0.29.0",
20
+ "@voltro/plugin-multitenancy": "0.29.0",
21
+ "@voltro/plugin-notifications": "0.29.0",
22
+ "@voltro/plugin-presence": "0.29.0",
23
+ "@voltro/protocol": "0.29.0",
24
+ "@voltro/runtime": "0.29.0",
25
+ "@voltro/sql-postgres": "0.29.0",
26
26
  "effect": "^3.22.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@voltro/testing": "0.28.0",
29
+ "@voltro/testing": "0.29.0",
30
30
  "typescript": "^6.0.3",
31
31
  "vitest": "^4.1.10"
32
32
  }
@@ -0,0 +1,103 @@
1
+ # {{capProjectName}} {{capAppName}} — the full SaaS backend
2
+
3
+ The whole SaaS loop wired end to end in ONE api — **sign up → hit a paywall →
4
+ upgrade → invite a teammate** — composing two shipped plugins that are normally
5
+ wired separately, plus a small tenant-scoped domain. Boots **zero-infra**
6
+ (`store: 'memory'`, `mock` billing provider, in-process users).
7
+
8
+ Pair it with the **frontend-saas** web template for the dashboard UI:
9
+
10
+ ```bash
11
+ voltro init my-saas --api=api-saas-starter --web=frontend-saas
12
+ ```
13
+
14
+ ## What it composes
15
+
16
+ - **Auth** ([`@voltro/plugin-auth`](https://voltro.cloud/docs/plugins/auth)) —
17
+ password **sign-up / sign-in / sign-out** over an HttpOnly **session cookie**,
18
+ and `voltroPasswordStrategy` resolving that cookie into a typed `Subject` on
19
+ every rpc/ws call. `session.me` returns it (the frontend's SSR gate reads it).
20
+ - **Billing** ([`@voltro/plugin-billing`](https://voltro.cloud/docs/plugins/overview)) —
21
+ subscriptions + **entitlements** over the in-memory `mock` provider. `plans`
22
+ is the single source of tier→quota truth: `free` caps `projects` at 3 and
23
+ `seats` at 1; `pro` lifts both.
24
+ - **Domain** — tenant-scoped `projects` + `invites`. `projects.create` spends
25
+ the `projects` quota; `invites.create` spends the `seats` quota. Over quota →
26
+ typed **`EntitlementExceeded`** (the paywall signal), the row never written.
27
+
28
+ ## The loop
29
+
30
+ | Step | Call | Result |
31
+ |---|---|---|
32
+ | Sign up | `POST /auth/sign-up` | session cookie; lands in tenant `acme` on the `free` plan |
33
+ | Work | `projects.create` ×3 | ok |
34
+ | **Paywall** | `projects.create` (4th) | typed `EntitlementExceeded { entitlement: 'projects', limit: 3 }` |
35
+ | Upgrade | `billing.startCheckout` → `pro` | `projects` becomes `unlimited`, `seats` → 10 |
36
+ | Invite | `invites.create` | spends a `seats` entitlement |
37
+ | Buy seats | `billing.changeSeats` | raises the seat quantity |
38
+
39
+ `billing.*` (`subscription`, `startCheckout`, `changePlan`, `changeSeats`,
40
+ `invoices`) and `/auth/*` are contributed by the plugins — you don't author them.
41
+
42
+ ## Run it
43
+
44
+ ```bash
45
+ voltro dev .
46
+ # → http://localhost:4000 (rpc + /auth/* HTTP routes)
47
+ ```
48
+
49
+ `voltro dev` mints a unique `VOLTRO_SESSION_SECRET` into a gitignored
50
+ `.env.local` on first boot — no secret ships with the template.
51
+
52
+ ### Try the loop (curl)
53
+
54
+ ```bash
55
+ # Sign up (CSRF-protected — grab a token first, keep the cookie jar)
56
+ curl -s -c jar.txt http://localhost:4000/auth/csrf # → { "csrfToken": "<csrf>" }
57
+ curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
58
+ -H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
59
+ -d '{"email":"ada@example.com","password":"hunter2hunter2"}'
60
+
61
+ # Create projects — the free plan allows 3, the 4th trips the paywall
62
+ for i in 1 2 3 4; do
63
+ curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
64
+ -H 'content-type: application/json' \
65
+ -d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
66
+ done
67
+ # → the 4th: { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, … } }
68
+ ```
69
+
70
+ ## Files
71
+
72
+ ```
73
+ app.config.ts authRoutesPlugin + voltroPasswordStrategy + billingPlugin (free/pro plans)
74
+ database/schema.ts core actors/tenants + tenant-scoped projects + invites
75
+ actions/me.action.ts(.server) session.me — the resolved Subject (SSR gate reads it)
76
+ queries/
77
+ projects.list.query.* live, tenant-scoped subscription
78
+ invites.list.query.* live, tenant-scoped subscription
79
+ mutations/
80
+ projects.create.* entitlement-gated (projects quota → paywall)
81
+ invites.create.* entitlement-gated (seats quota)
82
+ tests/ descriptor pins + tenant isolation + session.me
83
+ ```
84
+
85
+ ## Going to production
86
+
87
+ | Want… | Do |
88
+ |---|---|
89
+ | Durable accounts | swap `memoryUserStore()` for `postgresUserStore({ sql })` + `store: 'postgres'` |
90
+ | Real Stripe checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
91
+ | Email flows (magic-link, reset) | pass `sendEmail: mailSender(mailService)` from `@voltro/plugin-mail` |
92
+ | Cookie hardening | `cookieSecure: true` (+ `cookieDomain`) — already ON in production here |
93
+
94
+ ## Anti-patterns
95
+
96
+ - **Authoring your own `billing.*` / `/auth/*` procedures.** The plugins own
97
+ those namespaces — boot fails on a collision. Use the plugin routes + services.
98
+ - **Skipping the entitlement gate on a paid action.** A scope says "may you call
99
+ this"; an entitlement says "do you have quota left". `requireEntitlement` is
100
+ one line and fails closed — that's what makes the paywall real.
101
+ - **Reading `VOLTRO_SESSION_SECRET` at config time for a fallback.** The plugin
102
+ reads it per request, after the env gate mints it. A hardcoded fallback is a
103
+ published signing key.
@@ -0,0 +1,18 @@
1
+ // Executor for `session.me`. Reads the Subject the AuthMiddleware resolved (via
2
+ // voltroPasswordStrategy reading the session cookie) and returns it.
3
+ import type { AppContext } from '@voltro/runtime'
4
+
5
+ const execute = async (_input: Record<string, never>, ctx: AppContext) => {
6
+ const subject = ctx.request.subject as {
7
+ readonly type: string
8
+ readonly id: string | null
9
+ readonly tenantId: string | null
10
+ }
11
+ return {
12
+ type: subject.type,
13
+ id: subject.id ?? null,
14
+ tenantId: subject.tenantId ?? null,
15
+ }
16
+ }
17
+
18
+ export default execute
@@ -0,0 +1,20 @@
1
+ // `session.me` — returns the caller's resolved identity. This is the proof the
2
+ // auth loop works end-to-end, and the frontend's SSR auth gate reads it: call
3
+ // it with no session cookie and you get an anonymous Subject; sign in via POST
4
+ // /auth/sign-in (which sets the cookie), call it again, and
5
+ // `voltroPasswordStrategy` has resolved that cookie into a `user` Subject.
6
+ // Browser-safe descriptor — no server imports here.
7
+ import { defineAction } from '@voltro/protocol'
8
+ import { Schema } from 'effect'
9
+
10
+ export const me = defineAction({
11
+ name: 'session.me',
12
+ input: Schema.Struct({}),
13
+ output: Schema.Struct({
14
+ // 'anonymous' | 'user' | 'apiKey' | 'serviceAccount'
15
+ type: Schema.String,
16
+ // null while anonymous; the user id once signed in.
17
+ id: Schema.NullOr(Schema.String),
18
+ tenantId: Schema.NullOr(Schema.String),
19
+ }),
20
+ })