@voltro/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1968 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-DStmCJPi.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +32 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +27 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +98 -136
- package/templates/agent-docs/workflows.md +231 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
_Password + session-cookie auth — overview. Full docs in the Authentication section._
|
|
13
13
|
|
|
14
|
-
`@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.
|
|
14
|
+
`@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, email verification, tenant invitations, user impersonation, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.
|
|
15
15
|
|
|
16
16
|
The single `authRoutesPlugin()` mounts **every** auth HTTP route under `/auth` — you don't hand-wire endpoints.
|
|
17
17
|
|
|
@@ -37,7 +37,7 @@ Add `authRoutesPlugin()` to your api's `plugins` array. It mounts the entire aut
|
|
|
37
37
|
// app.config.ts
|
|
38
38
|
import { authRoutesPlugin, postgresUserStore, mailSender } from '@voltro/plugin-auth'
|
|
39
39
|
import { mailPlugin, MailService } from '@voltro/plugin-mail'
|
|
40
|
-
import {
|
|
40
|
+
import { bindConnectionCredential } from '@voltro/runtime'
|
|
41
41
|
import { Effect } from 'effect'
|
|
42
42
|
|
|
43
43
|
// `mail` is the yielded MailService from the mail plugin's services layer.
|
|
@@ -49,7 +49,7 @@ const auth = (mail: MailService) =>
|
|
|
49
49
|
cookieSecure: process.env.NODE_ENV === 'production',
|
|
50
50
|
appBaseUrl: 'https://app.example.com',
|
|
51
51
|
sendEmail: mailSender(mail), // ← plugin-mail synergy (the default wiring)
|
|
52
|
-
rebind:
|
|
52
|
+
rebind: bindConnectionCredential, // ← switch-tenant presents the new cookie on the live connection
|
|
53
53
|
passkey: {
|
|
54
54
|
rpId: 'example.com',
|
|
55
55
|
rpName: 'Acme',
|
|
@@ -83,7 +83,7 @@ The plugin:
|
|
|
83
83
|
- Mounts every auth route under `/auth` via `authRoutesPlugin()` (also re-exports the underlying Effect-typed handlers — `handleSignIn`, `handleSignUp`, `handleMagicLinkRequest`, `handleSwitchTenant`, … — if you'd rather mount a subset yourself).
|
|
84
84
|
- Resolves `ctx.subject` via `AuthMiddleware` running the strategy chain — the built-in `voltroPasswordStrategy` reads the session cookie.
|
|
85
85
|
- Exposes the typed user store via `postgresUserStore(sql)` (synchronous; the caller owns the `SqlClient`) or `memoryUserStore()` for dev/tests.
|
|
86
|
-
- Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`) via `authTables`.
|
|
86
|
+
- Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`, `invitationsTable`, `impersonationGrantsTable`, …) via `authTables`.
|
|
87
87
|
|
|
88
88
|
## Routes mounted under `/auth`
|
|
89
89
|
|
|
@@ -97,25 +97,150 @@ The plugin:
|
|
|
97
97
|
| `POST /auth/password-reset` · `/password-reset/confirm` | — | reset flow (confirm revokes all sessions) |
|
|
98
98
|
| `GET /auth/sessions` · `POST /auth/sessions/revoke` · `/sessions/revoke-others` | cookie | device list + revocation |
|
|
99
99
|
| `GET /auth/memberships` · `POST /auth/switch-tenant` | cookie | multi-tenant membership + active-tenant switch |
|
|
100
|
+
| `POST /auth/verify-email` · `/verify-email/callback` | — | request/resend a confirmation link (uniform `202`); redeem one (issues **no** session) |
|
|
101
|
+
| `POST /auth/invitations` · `GET /auth/invitations` · `POST /auth/invitations/revoke` | cookie | create / list / withdraw tenant invitations (needs `invitations` config) |
|
|
102
|
+
| `POST /auth/invitations/preview` · `/invitations/accept` · `/invitations/sign-up` | — / cookie | inspect a token, redeem it as the signed-in user, or redeem it by creating the invited account |
|
|
103
|
+
| `POST /auth/impersonate/start` · `/impersonate/stop` | cookie | act as another user and end it (needs `impersonation` config) |
|
|
100
104
|
| `POST /auth/mfa/enroll/start` · `/enroll/verify` · `/unenroll` · `/recovery-codes/regenerate` | cookie | TOTP enrolment (needs `mfa: { issuer }` config); `enroll/verify` returns one-time recovery codes |
|
|
101
105
|
| `POST /auth/passkey/register/options` · `/register/verify` | cookie | passkey enrolment |
|
|
102
106
|
| `POST /auth/passkey/assert/options` · `/assert/verify` | — | passkey sign-in |
|
|
103
107
|
|
|
104
108
|
State-changing authenticated routes require a valid `x-csrf-token` header matching the `voltro:csrf` cookie.
|
|
105
109
|
|
|
110
|
+
While a session is **impersonated**, the account-security routes are refused with `403 forbidden_while_impersonating` — see [User impersonation](#user-impersonation-log-in-as).
|
|
111
|
+
|
|
106
112
|
## Schema
|
|
107
113
|
|
|
108
114
|
```ts
|
|
109
115
|
import { authTables } from '@voltro/plugin-auth/schema'
|
|
110
116
|
// authTables = [usersTable, sessionsTable, membershipsTable, authTokensTable,
|
|
111
|
-
// passkeysTable, recoveryCodesTable, passkeyChallengesTable
|
|
117
|
+
// passkeysTable, recoveryCodesTable, passkeyChallengesTable,
|
|
118
|
+
// loginAttemptsTable, invitationsTable, impersonationGrantsTable]
|
|
112
119
|
```
|
|
113
120
|
|
|
114
121
|
Spread `authTables` into your `database/index.ts` handle + `voltro migrate` creates the tables.
|
|
115
122
|
|
|
116
123
|
## Multi-tenant memberships + switch-tenant
|
|
117
124
|
|
|
118
|
-
A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` —
|
|
125
|
+
A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` — **presents that same cookie on the connection** via `bindConnectionCredential`.
|
|
126
|
+
|
|
127
|
+
It presents a CREDENTIAL, not a Subject, and that is the whole design. The rebound connection is resolved by the full auth chain on its next call — session revocation, `auth.resolveScopes`, the scope cache, the credential-expiry bound — exactly as a reconnecting browser would be. It used to hand the middleware a resolved Subject, which the middleware returned verbatim: the connection then kept that authority for its whole life, immune to a revocation performed anywhere else. (And it only worked under `voltro dev`; `voltro serve` had no such path at all, so the rebind silently did nothing in production.)
|
|
128
|
+
|
|
129
|
+
What a rebind does NOT do: re-scope subscriptions already open on that connection. They were authorized under the previous cookie and run until the client re-subscribes. Calls made after the rebind see the new tenant.
|
|
130
|
+
|
|
131
|
+
## Email verification
|
|
132
|
+
|
|
133
|
+
`users` carries a nullable `emailVerifiedAt`, and `POST /auth/verify-email` + `/verify-email/callback` mint, send and redeem a confirmation link over the same single-use hashed-token table the magic-link and reset flows use.
|
|
134
|
+
|
|
135
|
+
**What an unverified account may do is a product decision, so it is a config field with three values** rather than a behaviour the framework picks for every app:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
authRoutesPlugin({
|
|
139
|
+
// …store, secret, defaultTenantId, sendEmail…
|
|
140
|
+
emailVerification: {
|
|
141
|
+
policy: 'strict', // 'off' (default) · 'soft' · 'strict'
|
|
142
|
+
exemptAccountsCreatedBefore: new Date('2026-08-11'), // your deploy instant
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| policy | login | the session |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `'off'` (default) | proceeds | carries **no** mark |
|
|
150
|
+
| `'soft'` | proceeds | marked — `isEmailVerified(subject)` is `false` |
|
|
151
|
+
| `'strict'` | refused, `403 email_not_verified` | never issued |
|
|
152
|
+
|
|
153
|
+
**Why `'off'` is the default.** The column arrives `NULL` on every existing row. Under `'strict'` as a default, the first boot after upgrading would refuse the next login of every user you already have — a total authentication outage caused by missing data rather than by anything a user did. `exemptAccountsCreatedBefore` is the adoption seam: accounts created before that instant count as verified, so you can turn `'strict'` on without a backfill.
|
|
154
|
+
|
|
155
|
+
**`isEmailVerified(subject)` is tri-state, and `null` is not `false`.** A session minted while the policy was `'off'` says nothing about verification. Reading "no mark" as "unverified" would refuse every live session at the moment you switch the policy on — the same outage one layer up. Gate on `=== false`.
|
|
156
|
+
|
|
157
|
+
**Three links prove an address, not one.** A magic link and a completed password reset are inbox round-trips exactly as a verification link is, so both stamp `emailVerifiedAt` (keeping the first timestamp — the column answers "since when", not "last clicked"). Without that, `'strict'` deadlocks a magic-link-only user: they can prove their address by signing in, and are refused the sign-in for not having proved it.
|
|
158
|
+
|
|
159
|
+
**A verification link is not a credential.** Redeeming one marks the address and issues **no** session. Treating it as a sign-in would turn a link that sits 24 hours in a mailbox — and in every relay along the way — into a day-long credential.
|
|
160
|
+
|
|
161
|
+
`'strict'` is enforced through the [subject-guard seam](#post-authentication-subject-guards), so it covers password, MFA verify, magic-link and passkey sign-in from one wiring. `authRoutesPlugin` installs the guard when you set the policy; if you mount the handlers yourself, add `emailVerificationGuard(config)` to `subjectGuards` — setting the policy alone changes only what the session is *marked* with.
|
|
162
|
+
|
|
163
|
+
The request endpoint answers a uniform `202` (unknown address, already-verified address and cooled-down resend are indistinguishable) and sends at most one mail per `resendCooldownSeconds` (default 60). Without that bound, "resend" is a mail-bomb primitive aimed at any address known to have an account.
|
|
164
|
+
|
|
165
|
+
## Tenant invitations
|
|
166
|
+
|
|
167
|
+
`invitations` joins `authTables`, and six routes mount when you pass an `invitations` config:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
authRoutesPlugin({
|
|
171
|
+
// …store, secret, defaultTenantId, sendEmail, appBaseUrl…
|
|
172
|
+
invitations: {
|
|
173
|
+
ttlSeconds: 7 * 24 * 60 * 60, // default
|
|
174
|
+
inviterRoles: ['owner', 'admin'], // default
|
|
175
|
+
roleRank: ['owner', 'admin', 'member', 'viewer'], // default, most privileged first
|
|
176
|
+
maxPending: 500, // default
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**An invitation is a credential that grants access to someone else's data**, so it gets the token discipline of one — 32 CSPRNG bytes, only the SHA-256 stored, single-use via one conditional `UPDATE … RETURNING`, an expiry — plus three properties a login token has no need for:
|
|
182
|
+
|
|
183
|
+
- **It is addressed.** The invited address is compared against the accepting user's; a mismatch is `403 invitation_email_mismatch`. A link forwarded, leaked into a channel or intercepted is refused rather than silently granting whoever opens it first.
|
|
184
|
+
- **It carries its own authority, chosen by the inviter.** The accept request is `{ token }` and nothing else — there is no field an invitee could use to name their own role.
|
|
185
|
+
- **It is revocable**, tenant-scoped in the SQL predicate, so an admin of one tenant cannot withdraw another's by id.
|
|
186
|
+
|
|
187
|
+
**An inviter can never grant a role above their own.** That direction is not configurable: an `admin` who can mint an `owner` invitation and accept it from a second address has promoted themselves, which makes every role boundary in the product advisory. `canGrantRole` replaces the whole rule for a model that is not a line (a matrix, a per-tenant plan) — keep it a refusal by default. A role the ranking does not know is grantable only by the top role; treating an unfamiliar `superadmin` as probably-harmless is how it gets handed out by an `admin`.
|
|
188
|
+
|
|
189
|
+
Both "the invitee already has an account" cases are covered. Signed in as the invited address, `POST /auth/invitations/accept` writes the membership. Signed out with no account, `POST /auth/invitations/sign-up` creates one — with the address taken from the **invitation**, never from the request, and marked already verified, since the invitation arrived in that mailbox and came back. An existing address is answered `409 account_exists` and the invitation is not burned.
|
|
190
|
+
|
|
191
|
+
Re-inviting **supersedes**: every pending invitation for the same (tenant, address) is revoked before the new one is issued, so a resend cannot leave live tokens behind. `maxPending` bounds a tenant so a compromised admin account is not a mail cannon. The admin list never returns `tokenHash` — a hash verifies a guessed plaintext offline, and an admin list is not a place to publish a verifier.
|
|
192
|
+
|
|
193
|
+
`invitations` is registered with the retention sweep at 90 days (`VOLTRO_INVITATIONS_TTL_HOURS`), armed only when the feature is configured.
|
|
194
|
+
|
|
195
|
+
## User impersonation ("log in as")
|
|
196
|
+
|
|
197
|
+
An impersonated session that is indistinguishable from a real one does not merely lack a feature — it retroactively destroys the audit trail of the whole product. Every row an agent touches is attributed to the user, so afterwards nobody can answer *"did the customer delete this, or did we?"* — including for the incident where it matters. Everything below follows from that.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { authRoutesPlugin, impersonationAuditRedactor } from '@voltro/plugin-auth'
|
|
201
|
+
import { auditPlugin } from '@voltro/plugin-audit'
|
|
202
|
+
import { Effect } from 'effect'
|
|
203
|
+
|
|
204
|
+
authRoutesPlugin({
|
|
205
|
+
// …store, secret, defaultTenantId…
|
|
206
|
+
impersonation: {
|
|
207
|
+
// REQUIRED. A function, never a role — see below.
|
|
208
|
+
authority: ({ actorUser }) => Effect.succeed(supportAgentIds.has(actorUser.id)),
|
|
209
|
+
// REQUIRED. Fires for 'started', 'stopped' AND 'refused'.
|
|
210
|
+
audit: (event) => { void recordSupportEvent(event) },
|
|
211
|
+
maxDurationSeconds: 900, // default — 15 minutes
|
|
212
|
+
requireReason: true, // default
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// So the mark reaches the durable audit trail too — read the note below.
|
|
217
|
+
auditPlugin({ sink: 'datastore', redactSubject: impersonationAuditRedactor() })
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**`authority` is a function, never a role, and has no default.** Two separate reasons. "A role that happens to be admin-ish" is how this becomes a privilege-escalation path in a product where `admin` means "can edit the pricing page". And mechanically: the subject these routes resolve comes from the session cookie, which [carries identity only](/docs/authentication/sessions#the-cookie-carries-identity-never-authority) — it has no `scopes` at all, so a scope-based default would be unsatisfiable by every caller, i.e. a feature that refuses everyone.
|
|
221
|
+
|
|
222
|
+
**`audit` is required** because impersonation's whole risk is an unrecorded action. A config that let you enable it while leaving the destination unset would make the dangerous half optional and the safe half opt-in.
|
|
223
|
+
|
|
224
|
+
**The mark lives in two places with different failure modes.** `subject.metadata.impersonation` travels with the cookie and reaches the client, so a banner needs no extra endpoint (`impersonationOf(subject)` reads it). An `impersonationGrants` **row** is written *before* the session exists — so a session can never be reachable without the record naming who is behind it — and no redaction policy can drop it.
|
|
225
|
+
|
|
226
|
+
> **Wire `impersonationAuditRedactor()` or your audit rows will not say who was behind an impersonated action.** `auditPlugin`'s default is `redactSubject: 'metadata'`, which replaces the whole metadata bag — correct in general (that bag is where a per-user provider credential lands) and it takes this mark with it. The redactor keeps the mark and redacts everything else: strictly safer than `'none'`, strictly more informative than the default.
|
|
227
|
+
|
|
228
|
+
**Time-bounded means the cookie expires**, not that a row says it should. The grant duration is the cookie's `Max-Age`, the session row's `expiresAt` and the grant's `expiresAt`, minted from one number. A caller may request *less*; a request for more is clamped, never obeyed.
|
|
229
|
+
|
|
230
|
+
**Stopping** closes the grant, deletes the impersonated session row and drops it from the revocation cache — so a copy of that cookie taken during the grant dies immediately, not at its own expiry — then re-issues the impersonator's own, never-revoked session for its **remaining** lifetime. Restoring does not extend their login. An actor whose own session died meanwhile is signed out rather than left as somebody else.
|
|
231
|
+
|
|
232
|
+
**Four escalation refusals:**
|
|
233
|
+
|
|
234
|
+
| refusal | what it stops |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `self_impersonation` | acting as yourself — every action marked impersonated with no second identity behind it, i.e. noise in the field an investigator reads |
|
|
237
|
+
| `nested_impersonation` | A→B, then as B→C. The mark carries one actor, so a chain attributes C's session to B — reaching any account with a **forged** attribution |
|
|
238
|
+
| `target_may_impersonate` | acting as someone who can themselves impersonate. The probe is `authority` evaluated with the identities **swapped** ("could the target impersonate me?"), so there is no second policy to keep in step |
|
|
239
|
+
| `forbidden_while_impersonating` | MFA enrolment/removal, recovery-code regeneration, passkey registration, switch-tenant, revoke-other-sessions, and starting another impersonation. Without this a 15-minute grant converts to permanent access in one request: enrol a passkey as the user and the time bound is decoration |
|
|
240
|
+
|
|
241
|
+
The impersonator also gains no authority the target lacks, by construction: the cookie **is** the target's identity, carries no scopes, and authority is re-resolved per request from that identity. Being impersonated does not clear the target's brute-force lockout either — a support action must not undo the protection on the account someone is hammering.
|
|
242
|
+
|
|
243
|
+
`impersonationGrants` is registered with the retention sweep at 365 days (`VOLTRO_IMPERSONATION_GRANTS_TTL_HOURS`) — matching the audit log, because it answers the same class of question — armed only when the feature is configured.
|
|
119
244
|
|
|
120
245
|
## MFA / TOTP sign-in enforcement
|
|
121
246
|
|
|
@@ -42,13 +42,14 @@ export default {
|
|
|
42
42
|
|
|
43
43
|
What it provides:
|
|
44
44
|
|
|
45
|
-
- The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims) via `extendSchema.tables`.
|
|
45
|
+
- The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) via `extendSchema.tables`.
|
|
46
46
|
- The `BillingService` Context.Tag — yield it in any handler.
|
|
47
47
|
- The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
|
|
48
48
|
- A webhook receiver at `POST /billing/webhook`.
|
|
49
|
-
- The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
|
|
49
|
+
- The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.entitlementStatus`, `billing.reportUsage`, `billing.previewChange`, `billing.invoices`, `billing.changePlan`, `billing.changeSeats`. Every route resolves the tenant from the caller's subject, and each declares its access decision: `billing.subscription` + `billing.entitlementStatus` are `openAccess` (the tenant-scoped reads every member's account UI renders), while the routes that change what the tenant pays — `startCheckout`, `portalUrl`, `previewChange`, `changePlan`, `changeSeats` — and the invoice history (`invoices`) require the **`billing:manage`** scope, and `reportUsage` requires **`billing:report`** (a metering credential's scope — an unguarded usage report would let any session inflate its tenant's counters). Grant the scopes via an rbac role or your auth strategy's `resolveScopes`; `admin:full` passes, as always.
|
|
50
50
|
- **Seat-based billing**; proration and failed-payment retries are Stripe's.
|
|
51
|
-
-
|
|
51
|
+
- **Dunning** — a past-due notification sequence, a grace period and a lockout, composed on Stripe's outcomes.
|
|
52
|
+
- The typed `BillingError`, `EntitlementExceeded` + `SubscriptionLocked` errors, merged into every procedure's wire error union.
|
|
52
53
|
- A browser-safe `useStartCheckout()` hook on the `/web` subpath.
|
|
53
54
|
|
|
54
55
|
## The BillingService
|
|
@@ -68,7 +69,7 @@ export default (input: { tenantId: string }, _ctx) =>
|
|
|
68
69
|
})
|
|
69
70
|
```
|
|
70
71
|
|
|
71
|
-
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns `'free'`
|
|
72
|
+
The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. `plan()` returns the plan whose **limits apply right now**: `'free'` with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole [grace period](#failed-payments--stripe-retries-dunning-composes-on-the-outcome) after a failed payment, falling back only once the lockout is real.
|
|
72
73
|
|
|
73
74
|
## Entitlement checks
|
|
74
75
|
|
|
@@ -142,6 +143,8 @@ billingPlugin({
|
|
|
142
143
|
|
|
143
144
|
`onEvent` keys are the normalized `BillingEvent` tags (`subscriptionUpserted`, `invoicePaid`, `invoicePaymentFailed`, `customerLinked`, `subscriptionCanceled`), not raw Stripe types.
|
|
144
145
|
|
|
146
|
+
Every event carries an `occurredAt` (Stripe's `event.created`). Webhook delivery is at-least-once **and unordered**, so the subscription and invoice rows compare it against the timestamp of the state they already reflect and **drop anything older** — a redelivered `active` from before a decline cannot un-do the past-due, and a late `payment_failed` cannot flip a paid invoice back to `open`.
|
|
147
|
+
|
|
145
148
|
## Checkout + upgrade flows
|
|
146
149
|
|
|
147
150
|
The `/web` subpath ships a browser-safe hook. It imports nothing from the server module — no secret, no node-only lib ever reaches the browser bundle. Pass it the generated `billing.startCheckout` rpc binding:
|
|
@@ -196,13 +199,14 @@ yield* billing.flushUsage()
|
|
|
196
199
|
|
|
197
200
|
## Tables
|
|
198
201
|
|
|
199
|
-
All
|
|
202
|
+
All six are `_voltro_`-prefixed and built from the cross-dialect schema DSL (no raw SQL, no pg-only types, no `TEXT` defaults). Money is `integer` minor units + a `currency` text column:
|
|
200
203
|
|
|
201
204
|
- `_voltro_billing_customers` — tenant ↔ provider customer link.
|
|
202
205
|
- `_voltro_billing_subscriptions` — one subscription per tenant (plan, status, seat `quantity`, period start + end, cancel-at).
|
|
203
206
|
- `_voltro_billing_invoices` — invoice history (`amountMinor` integer + `currency`).
|
|
204
207
|
- `_voltro_billing_usage` — per-tenant metered counters keyed by `(tenantId, entitlementKey, period)`.
|
|
205
208
|
- `_voltro_billing_flush_claims` — INSERT-wins flush-window claims (multi-instance autopilot coordination); short-lived, retention defaults to 1 hour via `VOLTRO_BILLING_FLUSH_CLAIM_TTL_HOURS`.
|
|
209
|
+
- `_voltro_billing_dunning_notices` — the sent-notice ledger, `UNIQUE (tenantId, episode, stepId)`. It is the **send gate**, not a report: a notice is claimed here before it goes out, so a duplicated webhook sends nothing. Retention defaults to ~400 days via `VOLTRO_BILLING_DUNNING_TTL_HOURS` — deliberately generous, because pruning a row belonging to a still-open episode would let its notice go out a second time.
|
|
206
210
|
|
|
207
211
|
`_voltro_billing_usage` is append-only — one upserted counter row per `(tenant, key, period)` — so a closed period's row would otherwise live forever. The plugin registers a retention sweep on the row's `updatedAt`: a row is only touched while its window is current, so once a period closes it ages out, while the live period's row stays fresh and survives regardless. The bound defaults to ~400 days (a conservative window with headroom for end-of-period flush + back-dated reads) and is tunable via the `VOLTRO_BILLING_USAGE_TTL_HOURS` env var; the boot retention sweep drains rows past the TTL.
|
|
208
212
|
|
|
@@ -285,9 +289,9 @@ const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
|
|
|
285
289
|
|
|
286
290
|
Never quote a locally estimated number. The one Stripe previews is the one it charges.
|
|
287
291
|
|
|
288
|
-
## Failed payments — Stripe retries,
|
|
292
|
+
## Failed payments — Stripe retries, dunning composes on the outcome
|
|
289
293
|
|
|
290
|
-
|
|
294
|
+
Stripe Smart Retries runs the retry schedule (configured in the Stripe Dashboard, where it can use Stripe's own timing models) and reports the outcome as a subscription status change:
|
|
291
295
|
|
|
292
296
|
| From | On | To |
|
|
293
297
|
| --- | --- | --- |
|
|
@@ -295,21 +299,134 @@ There is no dunning subsystem here. Stripe Smart Retries runs the retry schedule
|
|
|
295
299
|
| `pastDue` | payment recovers | `active` |
|
|
296
300
|
| `pastDue` | Stripe gives up | `canceled` |
|
|
297
301
|
|
|
298
|
-
|
|
302
|
+
The framework does not reimplement that cadence and never will — a local retry schedule ran here once, on a fixed `[1,3,5,7]`-day rhythm, and drifted from Stripe's the moment the two disagreed. What the plugin adds is the part Stripe does not do for your app: a **past-due notification sequence**, a **grace period**, and a **lockout** your entitlement checks can read.
|
|
303
|
+
|
|
304
|
+
### The grace clock is a column, not a timer
|
|
305
|
+
|
|
306
|
+
When the provider confirms a subscription is past due, the plugin stamps `pastDueSince` on the subscription row. Everything else is derived from it at read time — so there is no job to miss a tick, fire twice, or run on two replicas at once, and nothing "expires" a grace period in the background.
|
|
307
|
+
|
|
308
|
+
Two rules keep it honest, and both exist because sending an email and locking a customer out cannot be undone:
|
|
309
|
+
|
|
310
|
+
- **`pastDueSince` is only written after the provider confirms it.** A failed-payment event triggers a *reconcile* that reads the subscription's current status from Stripe (`subscriptions.retrieve`) and writes the row from that — never from the event body. Webhook delivery is at-least-once **and unordered**, so a `payment_failed` genuinely can arrive after the retry that succeeded; reconciling against the object settles it.
|
|
311
|
+
- **An unreachable provider never escalates.** If the reconcile cannot reach Stripe, `pastDueSince` stays unset — and without it there is no clock to expire, so the tenant stays in grace. Failing open is the only safe direction: the alternative is locking a paying customer out because your network was down.
|
|
312
|
+
|
|
313
|
+
### Asking the truthful question
|
|
314
|
+
|
|
315
|
+
`entitlementStatus()` is the one answer to "is this tenant entitled right now". It is a pure read of the local row — no provider call — so it is safe on a hot path:
|
|
299
316
|
|
|
300
317
|
```ts
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
import { Effect } from 'effect'
|
|
319
|
+
import { BillingService } from '@voltro/plugin-billing'
|
|
320
|
+
|
|
321
|
+
export default (input: { tenantId: string }, _ctx) =>
|
|
322
|
+
Effect.gen(function* () {
|
|
323
|
+
const billing = yield* BillingService
|
|
324
|
+
const status = yield* billing.entitlementStatus(input.tenantId)
|
|
325
|
+
// { plan, billedPlan, status, entitled, inGrace, graceEndsAt, lockedSince, lockout }
|
|
326
|
+
return status
|
|
327
|
+
})
|
|
306
328
|
```
|
|
307
329
|
|
|
308
|
-
`
|
|
330
|
+
- `entitled: false` means **dunning has locked this tenant out**. A canceled subscription is not a lockout — it is simply the free tier.
|
|
331
|
+
- `inGrace: true` with a `graceEndsAt` is the "your payment failed, you have until …" state. Show the banner and a billing-portal link.
|
|
332
|
+
- `plan` is the plan whose **limits apply right now**; `billedPlan` is what they are subscribed to. Under a hard lockout the two differ.
|
|
333
|
+
|
|
334
|
+
The same shape is available to the browser as the `billing.entitlementStatus` rpc query (timestamps as ISO strings).
|
|
335
|
+
|
|
336
|
+
For a feature with no numeric quota to degrade — an export, an admin action — guard it directly:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { Effect } from 'effect'
|
|
340
|
+
import { requireEntitled } from '@voltro/plugin-billing'
|
|
341
|
+
|
|
342
|
+
export default (input: { id: string }, ctx) =>
|
|
343
|
+
Effect.gen(function* () {
|
|
344
|
+
yield* requireEntitled(ctx) // fails SubscriptionLocked once grace expired
|
|
345
|
+
return { ok: true }
|
|
346
|
+
})
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`SubscriptionLocked` is a typed `Schema.TaggedError` carrying `{ tenantId, status, lockedSince, lockout }`, decoded on the client like every other framework error — so the UI can route to the billing portal instead of showing a generic failure.
|
|
350
|
+
|
|
351
|
+
### The sequence
|
|
352
|
+
|
|
353
|
+
Steps are declared with an `afterHours` measured from `pastDueSince`, so a step cannot be pulled forward by how often the provider happens to retry. Each provider event is the tick that evaluates whichever steps have come due:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
import { billingPlugin, dunningMailNotifier } from '@voltro/plugin-billing'
|
|
357
|
+
import { MailService } from '@voltro/plugin-mail'
|
|
358
|
+
|
|
359
|
+
billingPlugin({
|
|
360
|
+
plans: { /* … */ },
|
|
361
|
+
dunning: {
|
|
362
|
+
graceHours: 168, // default: 7 days
|
|
363
|
+
steps: [ // default: exactly these three
|
|
364
|
+
{ id: 'payment-failed', afterHours: 0 },
|
|
365
|
+
{ id: 'reminder', afterHours: 72 },
|
|
366
|
+
{ id: 'final-warning', afterHours: 144 },
|
|
367
|
+
],
|
|
368
|
+
lockout: 'hard', // default | 'soft'
|
|
369
|
+
portalReturnUrl: 'https://acme.com/billing',
|
|
370
|
+
notify: (notice) => Effect.sync(() => { /* your transport */ }),
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Each notice carries `{ stepId, tenantId, to, plan, status, pastDueSince, graceEndsAt, locked, lockout, portalUrl }` **plus** ready-to-send default `subject` / `html` / `text` — plain and unbranded, so the sequence works the moment `notify` is wired without inviting you to ship it unchanged.
|
|
376
|
+
|
|
377
|
+
**With no `notify`, nothing is sent.** The sequence claims and logs. That is the deliberate default: the framework cannot address a customer on your behalf, and an email is irreversible.
|
|
378
|
+
|
|
379
|
+
To send with `@voltro/plugin-mail`, bridge it — the plugin types the mail service structurally, so it takes on no dependency:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const mail = yield* MailService
|
|
383
|
+
billingPlugin({ dunning: { notify: dunningMailNotifier(mail) } })
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The recipient is resolved as: your `dunning.resolveRecipient(tenantId)` first, then the provider's customer email. A notice with no resolvable recipient still reaches `notify` (with `to: null`) so you can route it in-product.
|
|
387
|
+
|
|
388
|
+
### Idempotency, and what happens when delivery lies
|
|
389
|
+
|
|
390
|
+
Every send is claimed in `_voltro_billing_dunning_notices` under a `UNIQUE (tenantId, episode, stepId)` **before** it goes out. The episode key IS the clock — the epoch-ms of `pastDueSince` — so:
|
|
391
|
+
|
|
392
|
+
- a **duplicated** webhook lands on a claimed key and sends nothing;
|
|
393
|
+
- a **recovery** clears `pastDueSince`, which cancels every step still pending for that episode — a later failure opens a genuinely new episode and legitimately starts over;
|
|
394
|
+
- a **stale** `customer.subscription.updated` cannot un-do a past-due, and a stale `invoice.payment_failed` cannot flip a paid invoice back to `open`: both rows carry the event's timestamp and drop anything older than the state they already reflect.
|
|
395
|
+
|
|
396
|
+
Claim-then-send is on purpose. Its failure mode is one notice that never arrives; send-then-claim's is a customer receiving the same dunning email twice.
|
|
397
|
+
|
|
398
|
+
### The lockout
|
|
399
|
+
|
|
400
|
+
Once `graceEndsAt` passes, `entitled` turns false and one `locked` notice fires on the next reconcile.
|
|
401
|
+
|
|
402
|
+
- **`lockout: 'hard'`** (default) — entitlement limits fall to the `'free'` plan's. Every existing `requireEntitlement` / `enforce` check starts answering with the free tier's numbers; you write no new code.
|
|
403
|
+
- **`lockout: 'soft'`** — limits stay on the paid plan and only `entitlementStatus()` reports the lockout, so your app decides what to withhold.
|
|
404
|
+
|
|
405
|
+
Recovery at any point — including after the lockout — restores the paid entitlements on the next reconcile.
|
|
406
|
+
|
|
407
|
+
### Tunables
|
|
408
|
+
|
|
409
|
+
Every number the framework picked on your behalf is a field with a default and an env override:
|
|
410
|
+
|
|
411
|
+
| Option | Env | Default |
|
|
412
|
+
| --- | --- | --- |
|
|
413
|
+
| `dunning.enabled` | `VOLTRO_BILLING_DUNNING` (`on` / `off`) | `true` |
|
|
414
|
+
| `dunning.graceHours` | `VOLTRO_BILLING_GRACE_HOURS` | `168` (7 days) |
|
|
415
|
+
| `dunning.steps[].afterHours` | `VOLTRO_BILLING_DUNNING_STEP_HOURS` (positional, comma-separated) | `0,72,144` |
|
|
416
|
+
| `dunning.lockout` | `VOLTRO_BILLING_LOCKOUT` (`hard` / `soft`) | `hard` |
|
|
417
|
+
| notice-ledger retention | `VOLTRO_BILLING_DUNNING_TTL_HOURS` | `9600` (~400 days) |
|
|
418
|
+
|
|
419
|
+
The env parsers **fail the boot** on a value they cannot read, and `VOLTRO_BILLING_DUNNING_STEP_HOURS` refuses a list whose length differs from the declared sequence — an operator re-timing a sequence they are not looking at is exactly the quiet wrong number this refuses to become.
|
|
420
|
+
|
|
421
|
+
With `dunning.enabled: false` there is no grace clock and no reconcile: `pastDue` degrades entitlements at once, which is what this plugin did before dunning existed.
|
|
422
|
+
|
|
423
|
+
### The optional sweep
|
|
424
|
+
|
|
425
|
+
Provider events drive the sequence, and that covers the normal case — Stripe emits an event per retry attempt. `billing.dunningSweep()` reconciles every past-due tenant in one pass, for a step configured at an hour the provider happens not to emit an event at, or a reconcile missed during a provider outage. **Nothing schedules it for you**; wire it from your own `*.cron.tsx` if you want it. It is idempotent, it never charges anything, and it never asks the provider to retry.
|
|
309
426
|
|
|
310
427
|
## Provider portability
|
|
311
428
|
|
|
312
|
-
The surface (`BillingService`, the entitlement engine, the DB rows) is provider-agnostic. A `BillingProvider` is a dumb adapter: checkout/portal URL minting, usage push, and a pure `normalizeEvent` mapping the provider's payload to a `BillingEvent`. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
|
|
429
|
+
The surface (`BillingService`, the entitlement engine, the DB rows) is provider-agnostic. A `BillingProvider` is a dumb adapter: checkout/portal URL minting, usage push, a `fetchSubscription` direct read (the current status dunning refuses to lock a customer out without), and a pure `normalizeEvent` mapping the provider's payload to a `BillingEvent`. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
|
|
313
430
|
|
|
314
431
|
```ts
|
|
315
432
|
import { billingPlugin } from '@voltro/plugin-billing'
|
|
@@ -44,6 +44,11 @@ export default {
|
|
|
44
44
|
maxAttempts: 5, // per-record delivery attempts before dead-letter
|
|
45
45
|
backoffBaseMs: 200, // first retry delay; doubles per attempt, jittered
|
|
46
46
|
deliveryTimeoutMs: 10_000, // per-attempt timeout — aborts the sink call
|
|
47
|
+
leaseTtlMs: 15_000, // leader lease; heartbeat renews at ttl/3
|
|
48
|
+
// Fleet-scope handoff (see "Delivery guarantees"):
|
|
49
|
+
dedupWindowMs: 60_000, // how long an enqueue claim is kept — must exceed leaseTtlMs
|
|
50
|
+
handoffBufferMs: 60_000, // how far back each replica buffers for a takeover to drain
|
|
51
|
+
handoffBufferSize: 10_000, // hard ceiling on buffered changes per replica
|
|
47
52
|
}),
|
|
48
53
|
],
|
|
49
54
|
}
|
|
@@ -73,12 +78,44 @@ via `extendSchema`, migrated by `voltro dev`). The row's TypeID id **is** the
|
|
|
73
78
|
record's `deliveryKey` — unique across replicas, stable across restarts and
|
|
74
79
|
retries.
|
|
75
80
|
|
|
76
|
-
- **
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
- **Enqueue is de-duplicated per observed change, fleet-wide, across leadership
|
|
82
|
+
handovers — with one hole: a replica that dies between winning a change's
|
|
83
|
+
claim and inserting its outbox row loses that change, because the claim
|
|
84
|
+
survives and nothing rescans orphan claims.** (Those are two statements with
|
|
85
|
+
no transaction around them, which is why this does not say "exactly-once".)
|
|
86
|
+
|
|
87
|
+
On `changeScope: 'local'` stores each replica enqueues only its OWN commits
|
|
88
|
+
(injected cross-replica events are skipped), so a change is observed by
|
|
89
|
+
exactly one process and needs nothing further.
|
|
90
|
+
|
|
91
|
+
On `'fleet'` stores (postgres `changeStrategy: 'cdc'` LISTEN/NOTIFY, mysql
|
|
92
|
+
binlog) EVERY replica sees the full stream, so every replica **buffers** it
|
|
93
|
+
in memory (`handoffBufferMs`, capped at `handoffBufferSize` entries, oldest
|
|
94
|
+
dropped first). The holder of the leader lease (`_voltro_cdcout_leases`,
|
|
95
|
+
TTL-heartbeat) enqueues as it goes; a replica that WINS the lease drains the
|
|
96
|
+
window its predecessor never got to. Enqueue is **idempotent per change
|
|
97
|
+
identity**: each change is keyed by a `changeKey` every replica computes
|
|
98
|
+
alike — a digest of `(pipe, op, row id, new image, old image)` plus an
|
|
99
|
+
occurrence counter that keeps two byte-identical changes to one row apart —
|
|
100
|
+
and claimed in `_voltro_cdcout_claims` under `unique(pipe, changeKey)`. So
|
|
101
|
+
the rows the dying leader already wrote collapse instead of duplicating, and
|
|
102
|
+
the ones it never reached are written by its successor.
|
|
103
|
+
|
|
104
|
+
A replica that boots into a fleet that is **already running** adopts the
|
|
105
|
+
fleet's occurrence counters from the claims already in the database before it
|
|
106
|
+
keys anything — otherwise its first sighting of an already-claimed change
|
|
107
|
+
would key occurrence 0, collide, and be dropped as a duplicate it is not.
|
|
108
|
+
`GET /_voltro/inspect/plugins/cdc-out/sinks` reports `handoff.seeded` and
|
|
109
|
+
`handoff.awaitingSeed` so you can see that happen rather than assume it.
|
|
110
|
+
|
|
111
|
+
What still bounds it, stated plainly: a change **no surviving replica
|
|
112
|
+
observed** is gone (the transport delivered it only to the dead process); a
|
|
113
|
+
handoff that takes longer than `handoffBufferMs` loses whatever aged out of
|
|
114
|
+
the buffer, and that count is reported as `handoff.dropped` on
|
|
115
|
+
`GET /_voltro/inspect/plugins/cdc-out/sinks` rather than dropped silently.
|
|
116
|
+
`dedupWindowMs` (default `max(60_000, 4 × leaseTtlMs)`) is how long a claim
|
|
117
|
+
is kept; it **must exceed `leaseTtlMs`** — a claim that expires mid-handoff
|
|
118
|
+
is a duplicate window, and the plugin refuses to boot with one.
|
|
82
119
|
- **At-least-once FROM ENQUEUE.** The tap is post-commit — a crash in the
|
|
83
120
|
narrow window between commit and the outbox insert loses that one event;
|
|
84
121
|
the plugin does not claim better. From enqueue on, delivery survives
|
|
@@ -92,7 +129,9 @@ retries.
|
|
|
92
129
|
`GET /_voltro/inspect/plugins/cdc-out/dead-letter` — and unblocks the pipe.
|
|
93
130
|
- **Bounded storage.** Delivered/dead rows are purged by the framework
|
|
94
131
|
retention sweep after `retentionHours` (default 72, env
|
|
95
|
-
`CDCOUT_RETENTION_HOURS`); pending rows are never purged.
|
|
132
|
+
`CDCOUT_RETENTION_HOURS`); pending rows are never purged. Enqueue claims are
|
|
133
|
+
short-lived by design — purged after `dedupWindowMs`, which only has to
|
|
134
|
+
outlive a leadership handoff.
|
|
96
135
|
|
|
97
136
|
## Multi-tenancy
|
|
98
137
|
|
|
@@ -61,8 +61,39 @@ on graceful shutdown.
|
|
|
61
61
|
| `password` | `string` | — | |
|
|
62
62
|
| `database` | `string` | `'default'` | The plugin creates the events table inside it on first boot. |
|
|
63
63
|
| `table` | `string` | `'events'` | Override the events-table name. |
|
|
64
|
-
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (a `ReplacingMergeTree`) so analytical queries JOIN events against live user data. |
|
|
64
|
+
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (a `ReplacingMergeTree(version)` over `{ id, data, version, is_deleted }`) so analytical queries JOIN events against live user data. The version is the framework's commit-order stamp, so a change that arrives late loses the collapse. |
|
|
65
65
|
| `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
|
|
66
|
+
| `batch` | `{ maxSize?, flushIntervalMs? } \| false` | **on** — `{ maxSize: 20, flushIntervalMs: 5000 }` | Client-side batching of `track()` inserts. `false` opts out (one immediate, confirmed insert per event). |
|
|
67
|
+
|
|
68
|
+
## Batching is on by default
|
|
69
|
+
|
|
70
|
+
One HTTP insert per event is how a MergeTree gets **part-exploded** under load —
|
|
71
|
+
every single-row insert creates a data part the server must merge away. So
|
|
72
|
+
`track()` **batches by default**: rows buffer in the process and flush in ONE
|
|
73
|
+
multi-row insert once 20 events are pending, 5 seconds after the first buffered
|
|
74
|
+
event (whichever comes first), and on graceful shutdown (the `dispose` hook
|
|
75
|
+
drains the buffer before the client closes).
|
|
76
|
+
|
|
77
|
+
What changes observably with batching on:
|
|
78
|
+
|
|
79
|
+
- A successful `track()` means **"buffered"**, not "ClickHouse accepted the
|
|
80
|
+
row" — the insert happens later, off the call path.
|
|
81
|
+
- Events become visible to reads up to `flushIntervalMs` later than they were
|
|
82
|
+
tracked.
|
|
83
|
+
- A flush failure is logged and that batch **dropped** (best-effort analytics —
|
|
84
|
+
re-queuing a partially-applied insert would risk duplicates). A hard crash
|
|
85
|
+
loses whatever is still buffered; a graceful shutdown loses nothing.
|
|
86
|
+
|
|
87
|
+
If you need per-event delivery confirmation, opt out explicitly:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
analytics: clickhouseAnalytics({
|
|
91
|
+
url: process.env.CLICKHOUSE_URL!,
|
|
92
|
+
batch: false, // one immediate insert per track(); success = row accepted
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
…or tune the window: `batch: { maxSize: 500, flushIntervalMs: 2000 }`.
|
|
66
97
|
|
|
67
98
|
`database`, `table`, and each `mirrorTables` entry are validated as SQL
|
|
68
99
|
identifiers at boot (they're interpolated into DDL) — a bad name fails loudly at
|
|
@@ -53,7 +53,7 @@ connection + instance are closed on graceful shutdown.
|
|
|
53
53
|
| Option | Type | Default | Notes |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| `path` | `string` | in-memory | A file path (`.voltro/analytics.duckdb`) → durable across restarts, single-process. Omit or pass `:memory:` → events live in process memory, lost on restart (ephemeral dev / tests). |
|
|
56
|
-
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` inside DuckDB so analytical queries JOIN events against live user data. |
|
|
56
|
+
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `voltro_mirror_<table>` (`{ id, data, version, is_deleted }`) inside DuckDB so analytical queries JOIN events against live user data. Deletes write a tombstone — filter `is_deleted = false`. |
|
|
57
57
|
| `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
|
|
58
58
|
|
|
59
59
|
```ts
|