@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
|
@@ -55,6 +55,14 @@ SLO works in **both directions**. A `LogoutRequest` must reference the IdP `Name
|
|
|
55
55
|
- **SP-initiated** (`GET /saml/logout`) — loads the stored NameID/SessionIndex, builds the (signed) `LogoutRequest`, clears the session cookie, and redirects to the IdP SLO endpoint.
|
|
56
56
|
- **IdP-initiated** (`GET|POST /saml/slo`) — the IdP's signed `LogoutRequest` clears the SP session and is answered with a `LogoutResponse`; a bad signature is rejected (`401`) and the session is left intact.
|
|
57
57
|
|
|
58
|
+
An SLO message with **no signature at all** is rejected `401` before the SAML
|
|
59
|
+
library is consulted. This is not a formality: the underlying verifier treats
|
|
60
|
+
an absent redirect-binding signature as "nothing to verify" and returns
|
|
61
|
+
valid, and `/saml/slo` is a `GET` that is deliberately CSRF-exempt — so an
|
|
62
|
+
unsigned message would let any page log a visitor out with an `<img>` tag.
|
|
63
|
+
**If your IdP does not sign SLO, it now gets a 401**; enable message signing
|
|
64
|
+
on the IdP side.
|
|
65
|
+
|
|
58
66
|
The SLO state store mirrors the replay cache — in-process by default (single replica), shared via the DataStore with `sloStore: { store: true }` (a `_voltro_saml_logout` table) so a login on one replica can log out on another.
|
|
59
67
|
|
|
60
68
|
```ts
|
|
@@ -104,22 +112,53 @@ samlSsoPlugin({
|
|
|
104
112
|
|
|
105
113
|
All three secrets come from env / a secrets backend — never a literal, never logged.
|
|
106
114
|
|
|
107
|
-
##
|
|
115
|
+
## Which signatures are required
|
|
116
|
+
|
|
117
|
+
Two separate requirements, and only one of them is a choice:
|
|
118
|
+
|
|
119
|
+
| | Required? | Option |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| **Assertion** signature | Always. Not configurable. | — |
|
|
122
|
+
| **Response** envelope signature | No, by default | `wantAuthnResponseSigned` |
|
|
123
|
+
|
|
124
|
+
The assertion is the part that matters: only signature-covered XML is ever read, and the assertion is what carries the NameID, the attributes, the audience restriction, the validity window and the `SubjectConfirmationData`. An envelope signature additionally covers the response-level `Status`, `Destination` and `InResponseTo`.
|
|
108
125
|
|
|
109
|
-
|
|
126
|
+
**The default accepts an assertion-signed, envelope-unsigned response** — Okta's default application profile ("Sign assertion", response unsigned), and Azure AD's. Set `wantAuthnResponseSigned: true` if your IdP signs the response; it is one checkbox on both, and it is the stronger posture:
|
|
110
127
|
|
|
111
128
|
```ts
|
|
112
129
|
samlSsoPlugin({
|
|
113
130
|
// …idp / sp / sessionSecret / onLogin…
|
|
114
|
-
|
|
131
|
+
wantAuthnResponseSigned: true, // require the envelope signature too
|
|
115
132
|
})
|
|
116
133
|
```
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
One edge worth knowing at the default: an envelope signature that does **not** verify is treated the same as no envelope signature — it is discarded and the assertion signature decides. That is not a bypass (an attacker holding a validly signed assertion would simply send no envelope signature, and an attacker-signed *assertion* is refused at every setting), but it does mean an IdP misconfigured to sign responses with the wrong key goes unnoticed. `wantAuthnResponseSigned: true` surfaces it.
|
|
136
|
+
|
|
137
|
+
## Replay / `InResponseTo` protection
|
|
138
|
+
|
|
139
|
+
**On by default, backed by the DataStore.** The `AuthnRequest` id is stored at `/saml/login` and **consumed** at `/saml/acs`, so the same assertion can't be replayed and a response referencing no request this SP issued is rejected.
|
|
140
|
+
|
|
141
|
+
- `{ store: true, ttlMs? }` (**default**) — backed by the framework DataStore (a contributed `_voltro_saml_replay` table). Shared across replicas. Declares `store:write`. `ttlMs` bounds an outstanding request's validity (default 10 min).
|
|
142
|
+
- `true` — an **in-process** cache. Correct for one replica only; a login and its ACS POST that land on different processes fail the check (boot warns). The default is store-backed rather than this precisely because the in-process mode is not a milder version of the same protection — under more than one replica it is a total login outage.
|
|
143
|
+
- `false` — off, and a captured `SAMLResponse` is replayable within its validity window.
|
|
144
|
+
|
|
145
|
+
### It refuses IdP-initiated SSO — and `false` is the only way back
|
|
146
|
+
|
|
147
|
+
Replay protection sets `validateInResponseTo: 'always'`, which means a `SAMLResponse` carrying no `InResponseTo` is rejected. That is exactly an **IdP-initiated** login: the Okta / Azure dashboard app tile, rather than a user arriving at `/saml/login`.
|
|
148
|
+
|
|
149
|
+
You cannot keep both, and the reason is structural rather than a missing feature: the protection *is* the requirement that the response answer a request this SP issued, and an unsolicited response answers none. (`'ifPresent'` looks like the compromise and is not one — an attacker replaying a captured response just deletes the attribute and the check declines to run.)
|
|
150
|
+
|
|
151
|
+
If you need the app tile, opt out deliberately:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
samlSsoPlugin({
|
|
155
|
+
// …idp / sp / sessionSecret / onLogin…
|
|
156
|
+
replayProtection: false, // accepts unsolicited responses — and replays
|
|
157
|
+
wantAuthnResponseSigned: true, // recommended if you must run unsolicited
|
|
158
|
+
})
|
|
159
|
+
```
|
|
121
160
|
|
|
122
|
-
|
|
161
|
+
Boot warns when replay protection is off, because nothing about it is visible at runtime.
|
|
123
162
|
|
|
124
163
|
## Notes
|
|
125
164
|
|
|
@@ -129,4 +168,4 @@ The `AuthnRequest` id is stored at `/saml/login` and **consumed** at `/saml/acs`
|
|
|
129
168
|
|
|
130
169
|
## Permissions
|
|
131
170
|
|
|
132
|
-
|
|
171
|
+
`store:write` by default — replay protection is on and store-backed, so the plugin contributes `_voltro_saml_replay`. `sloStore: { store: true }` adds `_voltro_saml_logout` under the same permission. Setting `replayProtection: false` (and leaving `sloStore` at its in-process default) drops the permission entirely: the plugin then only serves the SAML routes + mints a session.
|
|
@@ -417,6 +417,17 @@ procedure's wire-error union so they decode typed on the client:
|
|
|
417
417
|
`storage.revoke`, `storage.listGrants`, `storage.listRefs`. (The upload hook picks
|
|
418
418
|
the right ones per `prefer`; you rarely call them directly.)
|
|
419
419
|
|
|
420
|
+
Every route declares its access decision. The self-service upload/read flow is
|
|
421
|
+
`openAccess` — what bounds it is structural (mints bind the caller's subject +
|
|
422
|
+
tenant into a signed ticket, finalize/complete refuse another subject's token,
|
|
423
|
+
reads go through the per-object access policy + grants, the grant RPCs enforce
|
|
424
|
+
owner-or-admin in-handler). Three routes require a scope instead: **`storage.mintUploadUrl`**
|
|
425
|
+
(the raw bring-your-own-key PUT, which bypasses limits + scan) and
|
|
426
|
+
**`storage.ingestUrl`** (the server fetches a caller-supplied URL — an SSRF
|
|
427
|
+
surface) need `storage:manage`; **`storage.listRefs`** (the tenant-WIDE media
|
|
428
|
+
library index) needs `storage:browse`. Grant them via an rbac role or
|
|
429
|
+
`resolveScopes`; `admin:full` passes.
|
|
430
|
+
|
|
420
431
|
### The grant RPCs are owner-only
|
|
421
432
|
|
|
422
433
|
`storage.share`, `storage.revoke` and `storage.listGrants` require that the
|
|
@@ -328,6 +328,8 @@ import { Schema } from 'effect'
|
|
|
328
328
|
|
|
329
329
|
export const subscribeWebhook = defineMutation({
|
|
330
330
|
name: 'webhooks.subscribe',
|
|
331
|
+
// Registers a URL this server will POST your events to — never openAccess.
|
|
332
|
+
guards: [{ scope: 'webhooks:manage' }],
|
|
331
333
|
input: Schema.Struct({ url: Schema.String }),
|
|
332
334
|
output: Schema.Struct({ targetId: Schema.String }),
|
|
333
335
|
error: WebhookSubscribeInvalid,
|
|
@@ -374,6 +376,37 @@ The endpoint mounts at `/webhooks/<id>` by default; override with
|
|
|
374
376
|
`VOLTRO_WEBHOOK_SECRET_STRIPE` (non-alphanumeric characters in the id become
|
|
375
377
|
`_`).
|
|
376
378
|
|
|
379
|
+
### Verification is not optional
|
|
380
|
+
|
|
381
|
+
An incoming webhook is a **public POST that runs your application code**, so the
|
|
382
|
+
framework refuses to mount one that has made no decision about who may call it.
|
|
383
|
+
A descriptor with no `signature` and no `provider` fails the boot, naming the
|
|
384
|
+
endpoint. Four ways to satisfy it:
|
|
385
|
+
|
|
386
|
+
| Declaration | Means |
|
|
387
|
+
|---|---|
|
|
388
|
+
| `provider: stripeProvider()` | a preset brings the scheme, replay window and idempotency key |
|
|
389
|
+
| `signature: { _tag: 'hmac', … }` | a hand-declared scheme for a sender with its own convention |
|
|
390
|
+
| `verification: 'provider'` | your handler verifies with the provider's own SDK |
|
|
391
|
+
| `verification: 'none'` | deliberately public — a gateway or IP allow-list owns the boundary |
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
export default defineIncomingWebhook({
|
|
395
|
+
id: 'internal.reindex',
|
|
396
|
+
verification: 'none', // behind the cluster gateway; nothing else may reach it
|
|
397
|
+
payload: Schema.Struct({ index: Schema.String }),
|
|
398
|
+
handler: async (ctx) => { /* … */ },
|
|
399
|
+
})
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`verification: 'none'` logs a warning at every boot, on purpose — an open
|
|
403
|
+
endpoint should stay visible.
|
|
404
|
+
|
|
405
|
+
**A declared signature with no configured secret answers 503**, on every
|
|
406
|
+
delivery, naming the variable to set. It does not skip the check: that fallback
|
|
407
|
+
meant one missing env var silently turned a verified webhook into an open one.
|
|
408
|
+
The framework never generates the secret — the sender holds the other half of it.
|
|
409
|
+
|
|
377
410
|
### Provider presets
|
|
378
411
|
|
|
379
412
|
Built-in presets configure the signature scheme, idempotency extraction, and
|
|
@@ -425,6 +458,78 @@ export default defineIncomingWebhook({
|
|
|
425
458
|
})
|
|
426
459
|
```
|
|
427
460
|
|
|
461
|
+
## Signing — Standard Webhooks v1.0.0
|
|
462
|
+
|
|
463
|
+
Outbound deliveries are signed to the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) spec by default. That is the point of a spec: every conformant consumer library verifies your deliveries with no code specific to you.
|
|
464
|
+
|
|
465
|
+
```http
|
|
466
|
+
POST /your/hook
|
|
467
|
+
webhook-id: 3f6a… ← the delivery id — USE THIS AS THE IDEMPOTENCY KEY
|
|
468
|
+
webhook-timestamp: 1786503000 ← unix seconds
|
|
469
|
+
webhook-signature: v1,K5oZfzN95Z…= ← base64 HMAC-SHA256, space-delimited during a rotation
|
|
470
|
+
x-voltro-event: orders.paid
|
|
471
|
+
x-voltro-attempt: 1
|
|
472
|
+
content-type: application/json
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
The signed content is `<webhook-id>.<webhook-timestamp>.<raw body>`. The key is `whsec_` + base64 of 24–64 random bytes, and the HMAC runs over the **decoded** bytes — `subscribe()` mints the right shape automatically. Passing your own hex secret for a spec-signed target is REFUSED at subscribe rather than producing signatures no consumer accepts: a hex string is also valid base64, so a lenient decoder would key the HMAC on bytes that round-trip against itself and fail against everyone else.
|
|
476
|
+
|
|
477
|
+
Implemented: the symmetric `v1` scheme, multi-signature rotation, the `.`-delimiter constraint on the id (asserted, not assumed), a constant-time compare, a 300s replay tolerance (the spec requires a tolerance and names no number — this one is ours, and tunable). NOT implemented: the asymmetric half (ed25519 / `v1a` / `whsk_`). A `v1a`-only signature is rejected by name rather than reported as a generic mismatch.
|
|
478
|
+
|
|
479
|
+
Other schemes stay available as explicit choices — `genericHmacSignature()` (the previous house format, `X-Webhook-Signature: t=…,v1=<hex>`), `stripeSignature()`, `githubSignature()`, `slackSignature()`:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
subscribe({ event: 'orders.paid', url, signing: genericHmacSignature() })
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Delivery semantics the spec dictates
|
|
486
|
+
|
|
487
|
+
| response | what happens |
|
|
488
|
+
|---|---|
|
|
489
|
+
| `2xx` | success |
|
|
490
|
+
| `3xx` | **failure. The redirect is not followed** — the target URL is the one we validated, and following one walks past that check |
|
|
491
|
+
| `410 Gone` | the target is disabled **immediately**, whatever `autoDisableAfter` says. The receiver answered the question |
|
|
492
|
+
| `429` / `5xx` | retried with backoff; `Retry-After` is honoured |
|
|
493
|
+
|
|
494
|
+
Each attempt has a **30s wire timeout** (`VOLTRO_WEBHOOK_TIMEOUT_MS`, or `timeoutMs` on the delivery workflow) — the top of the spec's recommended 15–30s band.
|
|
495
|
+
|
|
496
|
+
## `voltro webhooks consumer` — the package your subscribers install
|
|
497
|
+
|
|
498
|
+
Your app already knows every event a subscriber can register for, every payload's shape, and the exact scheme it signs with. So generate the verification package rather than making the receiving team write it:
|
|
499
|
+
|
|
500
|
+
```
|
|
501
|
+
voltro webhooks consumer --out ../partner-sdk
|
|
502
|
+
voltro webhooks events --json # what a subscriber can register for
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
It emits `index.js` + `index.d.ts` + `package.json` + a README, with **no runtime dependencies at all** — it imports `node:crypto` and nothing else. That constraint is the design, not an optimisation: the package runs in your subscriber's service, which is usually a different codebase and often not a Voltro app, and a dependency list is where "npm i and paste this in" stops being true.
|
|
506
|
+
|
|
507
|
+
```js
|
|
508
|
+
import { createVerifier, WebhookVerificationError } from 'acme-webhooks'
|
|
509
|
+
|
|
510
|
+
const verifier = createVerifier({ secret: process.env.WEBHOOK_SECRET })
|
|
511
|
+
|
|
512
|
+
app.post('/webhooks/acme', (req, res) => {
|
|
513
|
+
let delivery
|
|
514
|
+
try {
|
|
515
|
+
delivery = verifier.verify(req.rawBody, req.headers) // ← RAW bytes
|
|
516
|
+
} catch (err) {
|
|
517
|
+
if (err instanceof WebhookVerificationError) return res.status(401).send(err.reason)
|
|
518
|
+
throw err
|
|
519
|
+
}
|
|
520
|
+
if (alreadyProcessed(delivery.id)) return res.sendStatus(200) // webhook-id
|
|
521
|
+
switch (delivery.event) { /* … */ }
|
|
522
|
+
res.sendStatus(200)
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Notes worth knowing before you hand it over:
|
|
527
|
+
|
|
528
|
+
- **The raw body is the whole trap.** The signature covers the exact bytes that arrived; a JSON body-parser re-serialises and the signature then never matches. The generated README carries the per-framework recipe (Express / Fastify / Next / Hono).
|
|
529
|
+
- **Node 18+**, deliberately. Web Crypto's HMAC is async, which would make `verify()` return a Promise and force every handler using it to be async too.
|
|
530
|
+
- **Payload types only.** They are generated from each event's Schema; no decoder ships. The types describe what you send — the signature is what proves you sent it.
|
|
531
|
+
- Pass an array as `secret` during a rotation; each is tried.
|
|
532
|
+
|
|
428
533
|
## Managing targets
|
|
429
534
|
|
|
430
535
|
```ts
|
|
@@ -19,7 +19,7 @@ The framework ships some plugins; you write your own; the contract is small enou
|
|
|
19
19
|
|
|
20
20
|
- [The plugin contract](/docs/plugins/contract) — `definePlugin`, lifecycle hooks, rpc interceptors, framework-version compatibility
|
|
21
21
|
- [plugin-audit](/docs/plugins/audit) — mutation audit log + `audit()` mixin
|
|
22
|
-
- [plugin-auth](/docs/plugins/auth) — full auth suite: password (rehash-on-verify) + sessions (multi-key rotation + sliding-window) + magic-link/reset + passkeys (atomic clone detection, BYO multi-replica challenge store) + CSRF + session revocation + memberships/switch-tenant + TOTP/MFA (sign-in enforcement + recovery codes), mounted by `authRoutesPlugin()` (see also the [Authentication section](/docs/authentication/overview))
|
|
22
|
+
- [plugin-auth](/docs/plugins/auth) — full auth suite: password (rehash-on-verify) + sessions (multi-key rotation + sliding-window) + magic-link/reset + email verification + tenant invitations + user impersonation + passkeys (atomic clone detection, BYO multi-replica challenge store) + CSRF + session revocation + memberships/switch-tenant + TOTP/MFA (sign-in enforcement + recovery codes), mounted by `authRoutesPlugin()` (see also the [Authentication section](/docs/authentication/overview))
|
|
23
23
|
- [plugin-multitenancy](/docs/plugins/multitenancy) — `tenant()` schema mixin + `assertOwnTenant` write-guard + `TenantMismatch`
|
|
24
24
|
- [plugin-soft-delete](/docs/plugins/soft-delete) — `softDelete()` schema mixin (hide on delete, `hardDelete()` bypass)
|
|
25
25
|
- [plugin-rbac](/docs/plugins/rbac) — roles + permissions + the `permission()` guard
|
|
@@ -46,7 +46,8 @@ The framework ships some plugins; you write your own; the contract is small enou
|
|
|
46
46
|
- [plugin-governance](/docs/plugins/governance) — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
|
|
47
47
|
- [plugin-openapi](/docs/plugins/openapi) — OpenAPI 3.1 spec + Swagger-UI docs generated from your `defineRestRoute` descriptors and (opt-in) rpc procedures
|
|
48
48
|
- [plugin-versioning](/docs/plugins/versioning) — full row history + time-travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions`); what-changed-to-what on every write
|
|
49
|
-
- [plugin-presence](/docs/plugins/presence) — ephemeral realtime presence: heartbeat roster per channel + `usePresence` / `useTyping` hooks, cross-instance
|
|
49
|
+
- [plugin-presence](/docs/plugins/presence) — ephemeral realtime presence: heartbeat roster per channel + `usePresence` / `useTyping` hooks, held in memory; cross-instance with [plugin-broadcast](/docs/plugins/broadcast)
|
|
50
|
+
- [plugin-auth-social](/docs/plugins/auth-social) — first-party Sign in with Google / GitHub / Apple: mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, sessions issued through plugin-auth
|
|
50
51
|
- [plugin-scim](/docs/plugins/scim) — SCIM 2.0 provisioning (Users + Groups at `/scim/v2`) so an enterprise IdP can create/deactivate users
|
|
51
52
|
- [plugin-sso-saml](/docs/plugins/sso-saml) — enterprise SAML 2.0 SSO: SP-initiated login + Single Logout, ACS, metadata (+ IdP-metadata-URL auto cert rotation, encrypted assertions, SP request signing); mints a framework session
|
|
52
53
|
- [API keys](/docs/configuration/api-keys) — **first-class** (not a plugin): `apiKeys: true` enables Bearer-key auth + admin-gated issue/list/revoke
|
|
@@ -60,7 +61,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
60
61
|
| Plugin | Status | What it does |
|
|
61
62
|
|---|---|---|
|
|
62
63
|
| `@voltro/plugin-audit` | ✓ | Mutation audit log + `audit()` mixin |
|
|
63
|
-
| `@voltro/plugin-auth` | ✓ | Full auth suite via `authRoutesPlugin()`: password (rehash-on-verify), sessions (multi-key rotation + sliding-window), magic-link + password-reset, passkeys/WebAuthn (atomic clone detection, BYO multi-replica challenge store), CSRF, session enumeration + revocation, memberships + switch-tenant, TOTP/MFA (sign-in enforcement + recovery codes); `authTables` schemas |
|
|
64
|
+
| `@voltro/plugin-auth` | ✓ | Full auth suite via `authRoutesPlugin()`: password (rehash-on-verify), sessions (multi-key rotation + sliding-window), magic-link + password-reset, email verification (off/soft/strict policy), tenant invitations (addressed, single-use, role chosen by the inviter), user impersonation (marked, time-bounded, escalation-proof), passkeys/WebAuthn (atomic clone detection, BYO multi-replica challenge store), CSRF, session enumeration + revocation, memberships + switch-tenant, TOTP/MFA (sign-in enforcement + recovery codes); `authTables` schemas |
|
|
64
65
|
| `@voltro/plugin-multitenancy` | ✓ | `tenant()` schema mixin (read-scope + write-fill) + `assertOwnTenant` guard + typed `TenantMismatch` |
|
|
65
66
|
| `@voltro/plugin-soft-delete` | ✓ | `softDelete()` schema mixin — `deletedAt` / `deletedBy`; `delete` → UPDATE, `hardDelete()` bypass |
|
|
66
67
|
| `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `ScopeError` |
|
|
@@ -71,6 +72,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
71
72
|
| `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
|
|
72
73
|
| `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
|
|
73
74
|
| `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
|
|
75
|
+
| `@voltro/plugin-auth-social` | ✓ | First-party social login — Sign in with Google / GitHub / Apple with no identity vendor: authorize URL + code exchange + JWKS-verified ID tokens, mandatory PKCE (S256) and `state`, an explicit account-linking policy (`never` by default), Apple's signed-JWT client secret / one-time name / private-relay email all handled; sessions via `issueUserSession`. [→ details](/docs/plugins/auth-social) |
|
|
74
76
|
| `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
|
|
75
77
|
| `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics#voltroplugin-analytics-postgres) |
|
|
76
78
|
| `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics#voltroplugin-duckdb) |
|
|
@@ -91,7 +93,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
|
|
|
91
93
|
| `@voltro/plugin-governance` | ✓ | Data governance — retention TTL sweep (delete / anonymise), GDPR subject export + erasure (admin-gated routes + `GovernanceService`), consent ledger, field encryption. [→ details](/docs/plugins/governance) |
|
|
92
94
|
| `@voltro/plugin-openapi` | ✓ | OpenAPI 3.1 spec (`GET /openapi.json`) + Swagger-UI (`GET /docs`) generated from `defineRestRoute` descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → `POST /rpc/<name>`) — input/output/error Schemas via `JSONSchema.make`. [→ details](/docs/plugins/openapi) |
|
|
93
95
|
| `@voltro/plugin-versioning` | ✓ | Full row history + time-travel — value snapshot of every insert/update/delete on listed tables into `_voltro_row_history` (rides the ChangeEvent tap); `rowHistory` / `rowAsOf` queries + `restoreAsOf` / `diffVersions`; TTL + per-row cap retention. [→ details](/docs/plugins/versioning) |
|
|
94
|
-
| `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator
|
|
96
|
+
| `@voltro/plugin-presence` | ✓ | Ephemeral realtime presence — heartbeat roster per channel (`presence.heartbeat`/`list`/`leave` + `usePresence`), a `useTyping` typing indicator. Held **in memory**, owner-partitioned — no table is written; cross-instance requires [`@voltro/plugin-broadcast`](/docs/plugins/broadcast), and without a broker each replica sees only its own clients. [→ details](/docs/plugins/presence) |
|
|
95
97
|
| `@voltro/plugin-scim` | ✓ | SCIM 2.0 provisioning — Users + Groups REST at `/scim/v2` (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), `userName`/`externalId`/`displayName eq` filters, pagination, unique `userName`, `active:false` deactivation; `_voltro_scim_users`/`_voltro_scim_groups`. [→ details](/docs/plugins/scim) |
|
|
96
98
|
| `@voltro/plugin-sso-saml` | ✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under `/saml`; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via `@node-saml/node-saml` (optional+lazy), mints a framework session. [→ details](/docs/plugins/sso-saml) |
|
|
97
99
|
|
|
@@ -126,7 +128,7 @@ Order matters: the framework composes outer→inner, so the rate-limit intercept
|
|
|
126
128
|
| `extendSchema` | Contribute tables + custom SQL migrations (tracked in `_voltro_plugin_migrations`). |
|
|
127
129
|
| `services` | Provide an Effect `Layer` whose Tags every handler can `yield*` (e.g. `MailService`, `StorageService`). |
|
|
128
130
|
| `routes` | Register plugin-owned rpc queries / mutations / actions (alias-prefixed tags). |
|
|
129
|
-
| `httpRoutes` | Serve public raw-HTTP endpoints on the framework listener (e.g. `GET /_voltro/storage/:id`). The request carries `store` — the app's DataStore — for a route that must read or write (a login endpoint minting a session row cannot be an rpc mutation). Not tenant-scoped: raw HTTP has no resolved Subject, so scope it yourself. |
|
|
131
|
+
| `httpRoutes` | Serve public raw-HTTP endpoints on the framework listener (e.g. `GET /_voltro/storage/:id`). The request carries `store` — the app's DataStore — for a route that must read or write (a login endpoint minting a session row cannot be an rpc mutation), and `remoteAddr`, the client address already resolved through `security.trustedProxies` (use it instead of `x-forwarded-for`). Not tenant-scoped: raw HTTP has no resolved Subject, so scope it yourself. A state-changing route is [origin-checked](/docs/security/overview#cross-site-requests-are-refused) unless it declares `originGuard: 'exempt'`. |
|
|
130
132
|
| `inspectEndpoints` | Mount tooling under `/_voltro/inspect/plugins/<alias>/…`. |
|
|
131
133
|
| `onScheduleFire` / `onWorkflowStep` / `onHttpRequest` | Wrap every cron firing, every workflow `step()`, every pre-auth HTTP request. |
|
|
132
134
|
| `onInstall` / `onActivate` / `onDeactivate` / `onUninstall` | Lifecycle hooks at first-install, boot, shutdown, and removal. |
|
|
@@ -207,10 +209,15 @@ To REPLACE one deliberately, declare it:
|
|
|
207
209
|
|
|
208
210
|
```ts
|
|
209
211
|
// Adopt the plugin's namespace, add your own leaves beside it…
|
|
210
|
-
defineQuery({ name: 'notifications.archive', … })
|
|
211
|
-
|
|
212
|
-
// …and REPLACE just the one you need to behave differently.
|
|
213
|
-
|
|
212
|
+
defineQuery({ name: 'notifications.archive', guards: [{ scope: 'notifications:read' }], … })
|
|
213
|
+
|
|
214
|
+
// …and REPLACE just the one you need to behave differently. Your replacement is
|
|
215
|
+
// YOUR procedure, so it needs its own access decision — the plugin's does not
|
|
216
|
+
// carry over with the tag.
|
|
217
|
+
defineMutation({
|
|
218
|
+
name: 'notifications.markRead', overridesPlugin: true,
|
|
219
|
+
guards: [{ scope: 'notifications:write' }], …
|
|
220
|
+
})
|
|
214
221
|
```
|
|
215
222
|
|
|
216
223
|
The plugin's route is dropped, not merely permitted alongside yours — permitting
|
|
@@ -345,6 +352,8 @@ onChangeEvent: (event: PluginChangeEvent) => Effect.Effect<void, unknown>
|
|
|
345
352
|
// PluginChangeEvent = {
|
|
346
353
|
// table, op: 'insert'|'update'|'delete', new: Row | null, old: Row | null,
|
|
347
354
|
// origin?: 'inline' | 'injected', changeScope: 'local' | 'fleet',
|
|
355
|
+
// traceId?, subjectId?, procedure?,
|
|
356
|
+
// oversized?: 'rehydrated' | 'tombstone' | 'unrecovered',
|
|
348
357
|
// }
|
|
349
358
|
```
|
|
350
359
|
|
|
@@ -359,6 +368,8 @@ onChangeEvent: (event) =>
|
|
|
359
368
|
)
|
|
360
369
|
```
|
|
361
370
|
|
|
371
|
+
**Check `event.oversized` before you treat an image as a snapshot.** It is absent on an ordinary event, and set when the transport could not carry the row and the images were reconstructed — a row over postgres' 8000-byte NOTIFY cap. `'rehydrated'` means `new` is the row re-read from the database (correct to index or forward, but the row as it is NOW rather than the image at commit); `'tombstone'` means a delete whose `old` is the primary key and nothing else (enough to REMOVE the row, never a record of what it held); `'unrecovered'` means both images are null and the content is gone. A tap that stores history must not write a tombstone as a snapshot. Full guarantee: [postgres — oversized rows](/docs/database/dialects/postgres).
|
|
372
|
+
|
|
362
373
|
Runs under BOTH `voltro dev` and `voltro serve` (the prod serve path fans out the same way). Requires the `'store:changes:read'` permission. It is NOT durable at the framework layer — a crash between commit and the fork loses the event; build durability INSIDE the Effect (insert into an outbox and retry against the typed error channel, the way `@voltro/plugin-cdc-out` does). Exactly-once / change-scope semantics are unchanged: read `event.origin` + `event.changeScope` inside the Effect to act once per change fleet-wide (skip `origin: 'injected'` on `'local'` scope; elect one worker on `'fleet'`). Used by `@voltro/plugin-search` to mirror rows into an external index. For in-transaction reactions use a mutation; for best-effort per-table reactions in app code prefer a `*.subscribe.ts` — `onChangeEvent` is the plugin-level equivalent.
|
|
363
374
|
|
|
364
375
|
### `onInstall` / `onUninstall` / `onActivate` / `onDeactivate` lifecycle
|
|
@@ -523,9 +534,8 @@ export const auditPlugin = (): VoltroPlugin =>
|
|
|
523
534
|
})
|
|
524
535
|
```
|
|
525
536
|
|
|
526
|
-
Tag derivation: `<plugin-alias>.<query.name
|
|
527
|
-
|
|
528
|
-
and kebab→camelCase:
|
|
537
|
+
Tag derivation: `<plugin-alias>.<query.name>`. The plugin alias strips
|
|
538
|
+
`@scope/` + the `plugin-` prefix and kebab→camelCase:
|
|
529
539
|
|
|
530
540
|
| Plugin name | Alias |
|
|
531
541
|
|---|---|
|
|
@@ -535,6 +545,71 @@ and kebab→camelCase:
|
|
|
535
545
|
| `plain-name` | `plainName` |
|
|
536
546
|
| `@voltro/audit` | `audit` (no `plugin-` to strip) |
|
|
537
547
|
|
|
548
|
+
A `query.name` that already contains a dot is handled by whether it names the
|
|
549
|
+
plugin's OWN namespace:
|
|
550
|
+
|
|
551
|
+
- `'notifications.inbox'` on a plugin whose canonical name is
|
|
552
|
+
`@voltro/plugin-notifications` is re-namespaced — under
|
|
553
|
+
`alias: 'inbox'` it becomes `inbox.inbox`, not `notifications.inbox`.
|
|
554
|
+
A deeper path keeps its depth: `'audit.admin.events'` under `alias: 'trail'`
|
|
555
|
+
becomes `trail.admin.events`.
|
|
556
|
+
- `'acme.legacyBridge'` — a namespace that is not the plugin's own — passes
|
|
557
|
+
through untouched. That is the escape hatch, and it is the only case that
|
|
558
|
+
still bypasses the alias.
|
|
559
|
+
|
|
560
|
+
The re-namespacing needs the plugin to declare `baseName` (its canonical name,
|
|
561
|
+
before any app-supplied `alias`); a plugin that omits it keeps the older
|
|
562
|
+
behaviour where any dotted name passes through.
|
|
563
|
+
|
|
564
|
+
### Naming a plugin: `alias` vs `name`
|
|
565
|
+
|
|
566
|
+
Two different app-side problems land on a plugin's name, so first-party plugins
|
|
567
|
+
that carry tables or routes accept two separate options:
|
|
568
|
+
|
|
569
|
+
| Option | Question it answers | Effect |
|
|
570
|
+
|---|---|---|
|
|
571
|
+
| `alias` | "your namespace collides with mine" | replaces the namespace — tags become `<alias>.<route>`, the inspect mount becomes `/_voltro/inspect/plugins/<alias>/…` |
|
|
572
|
+
| `name` | "I want two of these" | appends a `#suffix` discriminator so two installs never register the same tag |
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
notificationsPlugin({ alias: 'alerts' }) // alerts.inbox
|
|
576
|
+
notificationsPlugin({ name: 'ops' }) // notifications#ops.inbox
|
|
577
|
+
notificationsPlugin({ alias: 'alerts', name: 'ops' }) // alerts#ops.inbox
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
`alias` exists to escape a tag collision, which is fatal at codegen. Two costs
|
|
581
|
+
are worth knowing before you reach for it:
|
|
582
|
+
|
|
583
|
+
- the local and cloud dashboards fetch a plugin's inspect panel at its DEFAULT
|
|
584
|
+
slug, so an aliased plugin keeps serving its inspect endpoints while its
|
|
585
|
+
dashboard panel stops resolving;
|
|
586
|
+
- the plugin-migration ledger key is `<plugin-alias>__<migration.id>`, so
|
|
587
|
+
aliasing a plugin that ships `extendSchema.migrations` makes its already-applied
|
|
588
|
+
migrations look unapplied. Choose the alias before first boot, not after.
|
|
589
|
+
|
|
590
|
+
### `tables: false` — keeping your own tables
|
|
591
|
+
|
|
592
|
+
Plugins whose tables carry no authorization or safety decision accept
|
|
593
|
+
`tables: false`, which stops them contributing DDL through `extendSchema` so an
|
|
594
|
+
app can keep equivalent tables it already has. Everything else — routes,
|
|
595
|
+
inspect, interceptors — is unchanged.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
notificationsPlugin({ tables: false }) // you declare the six notification tables
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
It is offered on `@voltro/plugin-rbac`, `@voltro/plugin-audit`,
|
|
602
|
+
`@voltro/plugin-notifications` and `@voltro/plugin-ai-flows`. The plugin still
|
|
603
|
+
writes to those tables BY NAME, so you take over declaring each one with the
|
|
604
|
+
shape the package exports, and a missing or mis-shaped table fails at the first
|
|
605
|
+
write rather than at boot.
|
|
606
|
+
|
|
607
|
+
It is deliberately NOT offered on plugins whose tables carry a guarantee — the
|
|
608
|
+
SAML assertion replay cache, SCIM provisioning state, billing's usage counters,
|
|
609
|
+
cdc-out's delivery outbox, the governance consent ledger, search's tenant-scoped
|
|
610
|
+
index rows. A `tables: false` there would disable a security or correctness
|
|
611
|
+
decision with no signal to the app that it now owns it.
|
|
612
|
+
|
|
538
613
|
Boot fails with a clear error on tag collisions (between two plugins, or
|
|
539
614
|
with a user-authored tag).
|
|
540
615
|
|
|
@@ -770,7 +845,7 @@ markers. The plugin sees the api name + the list of discovered user-query
|
|
|
770
845
|
rpc tags so it can emit per-rpc bindings. Returning `null` contributes
|
|
771
846
|
nothing.
|
|
772
847
|
|
|
773
|
-
### `templates: PluginTemplate[]` — ship
|
|
848
|
+
### `templates: PluginTemplate[]` — ship scaffolding templates
|
|
774
849
|
|
|
775
850
|
```ts
|
|
776
851
|
definePlugin({
|
|
@@ -819,8 +894,14 @@ export const rateLimitPlugin = (opts: { perMinute: number }) =>
|
|
|
819
894
|
name: '@vendor/plugin-rate-limit',
|
|
820
895
|
permissions: ['http:intercept'],
|
|
821
896
|
onHttpRequest: async (next, ctx) => {
|
|
822
|
-
|
|
823
|
-
|
|
897
|
+
// `ctx.remoteAddr` is ALREADY resolved through the app's
|
|
898
|
+
// `security.trustedProxies` policy — the same value the framework's own
|
|
899
|
+
// rate limiter, geo-block and audit rows use. Never read
|
|
900
|
+
// `ctx.headers['x-forwarded-for']`: it is a request header, so any
|
|
901
|
+
// caller can write it, and a limiter keyed on it is bypassed by one
|
|
902
|
+
// extra header. `undefined` only when the socket address is unavailable.
|
|
903
|
+
const clientAddr = ctx.remoteAddr ?? 'unknown'
|
|
904
|
+
if (buckets.consume(clientAddr, opts.perMinute) === 'exhausted') {
|
|
824
905
|
return {
|
|
825
906
|
status: 429,
|
|
826
907
|
headers: { 'retry-after': '60', 'content-type': 'application/json' },
|
|
@@ -832,10 +913,28 @@ export const rateLimitPlugin = (opts: { perMinute: number }) =>
|
|
|
832
913
|
})
|
|
833
914
|
```
|
|
834
915
|
|
|
916
|
+
**`ctx.remoteAddr` is the client address — `x-forwarded-for` is not.**
|
|
917
|
+
The framework resolves `remoteAddr` through the app's
|
|
918
|
+
`security.trustedProxies` policy (`VOLTRO_TRUSTED_PROXIES`) before it hands you
|
|
919
|
+
the context: with no trusted proxy declared the header is ignored entirely and
|
|
920
|
+
the socket address wins, and with one declared only the hops that are actually
|
|
921
|
+
a configured proxy are believed. Reading `ctx.headers['x-forwarded-for']`
|
|
922
|
+
yourself throws that away and keys your limiter on a string the caller typed —
|
|
923
|
+
one extra header and every request looks like a new client. The same rule
|
|
924
|
+
applies to a plugin's raw HTTP routes, where the resolved value arrives as
|
|
925
|
+
`req.remoteAddr`; see [Trusted proxies](/docs/security/overview#the-same-address-reaches-your-plugin-routes).
|
|
926
|
+
|
|
835
927
|
A per-plugin `plugin.<name>.http-intercept` metric is auto-emitted so
|
|
836
928
|
the dashboard's Plugins panel surfaces HTTP-intercept latency next to
|
|
837
929
|
RPC-intercept latency.
|
|
838
930
|
|
|
931
|
+
**Runs on both boot paths.** The chain is composed and installed identically by
|
|
932
|
+
`voltro dev` and `voltro serve` — this is a production capability, and for a
|
|
933
|
+
pre-auth shield production is the point. There is exactly ONE exemption, and it
|
|
934
|
+
is deliberate: `GET /internal/liveness` and `GET /internal/readiness` are
|
|
935
|
+
answered before the interceptor, so a rate-limit or geo-block plugin cannot 503
|
|
936
|
+
a Kubernetes probe and take the replica out of rotation.
|
|
937
|
+
|
|
839
938
|
### `extendSchema: { tables, migrations }` — contribute schema + migrations
|
|
840
939
|
|
|
841
940
|
A plugin contributes BOTH declarative table descriptors AND custom SQL
|
|
@@ -848,6 +947,14 @@ The ledger key is `<plugin-alias>__<migration.id>` so two plugins can
|
|
|
848
947
|
each ship `'001-init'` without collision. Failure aborts boot;
|
|
849
948
|
re-runs are no-ops.
|
|
850
949
|
|
|
950
|
+
**Which commands run them:** `voltro dev`'s boot auto-migrate, `voltro db apply`
|
|
951
|
+
(bare and `--plan`) and `voltro migrate --create-only`. NOT `voltro serve` —
|
|
952
|
+
serve never applies a schema, so a plugin's steps land in the pre-deploy job
|
|
953
|
+
alongside the schema, which is where they belong. Until 0.34.0 only the `voltro
|
|
954
|
+
dev` boot ran them, so a plugin's SQL steps executed on every developer machine
|
|
955
|
+
and on no deployed database; if you ship migrations, verify against a deployed
|
|
956
|
+
database rather than a dev boot.
|
|
957
|
+
|
|
851
958
|
```ts
|
|
852
959
|
import { Effect, Schema } from 'effect'
|
|
853
960
|
import { definePlugin } from '@voltro/protocol'
|
|
@@ -1387,17 +1494,44 @@ analytics: duckdbAnalytics({
|
|
|
1387
1494
|
}),
|
|
1388
1495
|
```
|
|
1389
1496
|
|
|
1390
|
-
The postgres-lite (`postgresAnalytics({ mirrorTables: [...] })`) and ClickHouse (`clickhouseAnalytics({ url, mirrorTables: [...] })`) sinks take the same options. Each mirrored table lands as `_voltro_mirror_<table>` (`voltro_mirror_<table>` on DuckDB / ClickHouse) holding `{ id, data }` — `id` is the source row's primary key, `data` is the full row as JSON. Analytical queries JOIN events against the mirror:
|
|
1497
|
+
The postgres-lite (`postgresAnalytics({ mirrorTables: [...] })`) and ClickHouse (`clickhouseAnalytics({ url, mirrorTables: [...] })`) sinks take the same options. Each mirrored table lands as `_voltro_mirror_<table>` (`voltro_mirror_<table>` on DuckDB / ClickHouse) holding `{ id, data, version, is_deleted }` — `id` is the source row's primary key, `data` is the full row as JSON, `version` orders the writes (see below) and `is_deleted` marks a tombstone. Analytical queries JOIN events against the mirror and filter tombstones out:
|
|
1391
1498
|
|
|
1392
1499
|
```sql
|
|
1393
1500
|
-- DuckDB: events per user tier
|
|
1394
1501
|
SELECT json_extract_string(m.data, '$.tier'), COUNT(*)
|
|
1395
1502
|
FROM voltro_events e
|
|
1396
|
-
JOIN voltro_mirror_users m ON m.id = e.subject_id
|
|
1503
|
+
JOIN voltro_mirror_users m ON m.id = e.subject_id AND m.is_deleted = false
|
|
1397
1504
|
GROUP BY 1
|
|
1398
1505
|
```
|
|
1399
1506
|
|
|
1400
|
-
The mirror is **opt-in** (omit `mirrorTables` → events-only) and **idempotent** — inserts/updates upsert by primary key, deletes
|
|
1507
|
+
The mirror is **opt-in** (omit `mirrorTables` → events-only) and **idempotent** — inserts/updates upsert by primary key, deletes write a tombstone by key, so a re-delivered change (e.g. after a reconnect) is a no-op-equivalent overwrite.
|
|
1508
|
+
|
|
1509
|
+
### Delivery guarantee
|
|
1510
|
+
|
|
1511
|
+
**At-least-once for the lifetime of the process, ordered per row.** Read that sentence literally — every clause is a promise the framework keeps, and the sentence stops where the implementation does:
|
|
1512
|
+
|
|
1513
|
+
- **Retried, not dropped.** A failing mirror write is retried with exponential backoff (`retryAttempts`, default 5). A write that outlives its retries is queued for **repair**: a timer re-reads the row's *current* state from your database and re-applies it, so the mirror converges on the truth rather than on a stale change that happened to be in flight.
|
|
1514
|
+
- **Ordered per row.** Writes for the same primary key are applied one at a time, in commit order, and every write carries a **version** stamped when the change left the store — never a clock read inside the warehouse client. A change that arrives late therefore *loses*: ClickHouse's `ReplacingMergeTree(version)` keeps the highest version, and the DuckDB / postgres mirrors apply the update only when the incoming version is newer. Different rows are still mirrored concurrently.
|
|
1515
|
+
- **A delete is a tombstone, not a row removal.** Filter `is_deleted = false` (`is_deleted = 0` / `FINAL` on ClickHouse). A physical delete would leave nothing for a late, stale insert of the same key to lose against — the row would silently come back. The tombstone also keeps its version, which is what lets a replacement replica seed a key's numbering after a delete.
|
|
1516
|
+
- **N replicas converge to ONE row per change.** Under postgres CDC / mysql binlog every replica observes every change and mirrors it, so a change is written N times — but the version is derived from the CHANGE (the warehouse's own high-water mark for the key, plus the change's position in the fleet stream), never from a replica's clock. The N duplicates are byte-identical, and the sink's version guard collapses them for free: no leader election, and no clock-skew window in which an older image could out-version a newer one. A replica that boots mid-stream reads each key's high-water mark from the warehouse before its first write, so it continues the fleet's numbering instead of restarting it.
|
|
1517
|
+
- **Never surfaces to the request.** The OLTP write that produced the change has already committed; a warehouse outage is isolated into the log channel and the metrics below.
|
|
1518
|
+
- **A graceful shutdown is not a crash.** On SIGINT / SIGTERM the mirror stops taking new changes and then **settles** what is already queued and in flight, before the sink itself is disposed. That drain is bounded (3 s of the teardown budget you set with `VOLTRO_SHUTDOWN_GRACE_MS`, default 10 s): a warehouse that has stopped answering cannot hold the process open until the orchestrator's SIGKILL, which would lose strictly more. The two outcomes log differently — `analytics mirror drained` at info, or a `warn` naming what was still pending when the deadline cut it, because that is the moment those counters can still be read.
|
|
1519
|
+
- **Not durable across a crash.** The repair queue lives in memory. A change still awaiting repair when the process dies — SIGKILL, an OOM, a host failure — is lost, as is one evicted after `repairQueueLimit`. Both are logged at error level and counted by `voltro_analytics_mirror_dropped_total` — if that counter is non-zero, the affected tables need a re-seed.
|
|
1520
|
+
|
|
1521
|
+
Metrics: `voltro_analytics_mirror_forwarded_total`, `..._retries_total`, `..._repair_queued_total`, `..._dropped_total`.
|
|
1522
|
+
|
|
1523
|
+
### Tuning
|
|
1524
|
+
|
|
1525
|
+
Every number the mirror picks on your behalf has a default and an environment override:
|
|
1526
|
+
|
|
1527
|
+
| Env var | Default | Meaning |
|
|
1528
|
+
|---|---|---|
|
|
1529
|
+
| `VOLTRO_ANALYTICS_MIRROR_RETRY_ATTEMPTS` | `5` | Total attempts per mirror write (`1` = no retry). |
|
|
1530
|
+
| `VOLTRO_ANALYTICS_MIRROR_RETRY_BASE_MS` | `100` | First backoff delay; doubles per attempt. |
|
|
1531
|
+
| `VOLTRO_ANALYTICS_MIRROR_RETRY_MAX_MS` | `30000` | Ceiling for the doubling backoff. |
|
|
1532
|
+
| `VOLTRO_ANALYTICS_MIRROR_REPAIR_INTERVAL_MS` | `60000` | How often the repair loop re-drives exhausted changes (`0` disables it). |
|
|
1533
|
+
| `VOLTRO_ANALYTICS_MIRROR_REPAIR_QUEUE_LIMIT` | `10000` | Maximum keys held for repair before the oldest is dropped and counted. |
|
|
1534
|
+
| `VOLTRO_ANALYTICS_MIRROR_VERSION_STATE_LIMIT` | `100000` | Maximum keys whose fleet-scope version state stays in memory; an evicted key re-seeds from the warehouse on its next change. |
|
|
1401
1535
|
|
|
1402
1536
|
## Default — no sink configured
|
|
1403
1537
|
|