@voltro/cli 0.33.0 → 0.34.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 +1801 -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-BICVGnEC.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DZF_f0_I.js} +46 -46
- 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-CE7Ca9gm.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Dv8r9tGO.js +231 -0
- package/dist/checkCommand-YZDmkAGd.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-CNXKlcvD.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-DC6w2tNZ.js +30 -0
- package/dist/codemodRunner-DRRqXR74.js +5243 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-gutsz-Ac.js +808 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-3YG8p-UA.js +25 -0
- package/dist/dataCommand-qL0r7fPO.js +535 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-B6X0FZbq.js} +551 -401
- package/dist/dbCommand-CpdKLeQq.js +2 -0
- package/dist/{dev-C_P8FLSx.js → dev-CerMd0mW.js} +3122 -3129
- package/dist/dev-CoG-ZPx8.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-BvqGBwNG.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-CnDXQxfa.js} +520 -1278
- package/dist/dormancyCommand-Dszo57d6.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-C0sKVRo1.js +73 -0
- package/dist/envCommand-DPgdV1Bq.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-DHpkgjgH.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-BGHmck-x.js +2 -0
- package/dist/{frameworkTableAssembly-BwIrO5nv.js → frameworkTableAssembly-DkkP6BgC.js} +184 -148
- package/dist/generateCommand-oibemh97.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-BJw9nLUR.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CBqFtAKA.js} +82 -40
- package/dist/inspect-C_T_WGvl.js +2 -0
- package/dist/inspectCmd-Bppy-GGw.js +224 -0
- package/dist/inspectFetch-Cm8_wVvp.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-CCca3KRZ.js +260 -0
- package/dist/manifestBuild-ChsKAhmn.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-sxpwdKY1.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-CBwOt_iV.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/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-CRxalXTs.js +91 -0
- package/dist/scheduleCmd--jksTrf6.js +69 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-CYJscZC7.js} +111 -209
- package/dist/seedRunner-TFHHiToI.js +329 -0
- package/dist/serveCommand-B_isw7q4.js +1647 -0
- package/dist/serveCommand-DOvbgRnQ.js +2 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-9LiUOfES.js +1087 -0
- package/dist/start-B-9Nsp-S.js +3 -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-CkEZQrtt.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-Bkptutss.js +585 -0
- package/dist/updateCommand-us1_hdIC.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-CBYvPqQr.js} +975 -1559
- package/dist/webDev-Cg-fFiyd2.js +2 -0
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-CID96Rga.js +267 -0
- package/dist/workflowsCmd-D1VTmLMY.js +608 -0
- package/package.json +179 -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 +22 -5
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +265 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +794 -50
- package/templates/agent-docs/data.md +550 -11
- package/templates/agent-docs/database/migrations.md +174 -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 +69 -3
- package/templates/agent-docs/introduction.md +87 -16
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +95 -20
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +161 -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 +1 -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 +157 -6
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +150 -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 +10 -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 +1700 -104
- package/templates/agent-docs/workflows.md +130 -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/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-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
|
@@ -45,7 +45,16 @@ const Room = ({ channel }: { channel: string }) => {
|
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
`usePresence(channel, opts)` heartbeats on an interval (`heartbeatMs`, default 15s) and publishes per-member `meta` (status, cursor position, …). The roster is **push-driven** — `presence.list` is a reactive plugin query
|
|
48
|
+
`usePresence(channel, opts)` heartbeats on an interval (`heartbeatMs`, default 15s) and publishes per-member `meta` (status, cursor position, …). The roster is **push-driven** — `presence.list` is a reactive plugin query whose `source:` is a [reactivity channel](/docs/data/subscriptions#reactivity-channels), so the framework pushes a fresh roster over the subscription transport with NO client polling. `key` defaults to the subject id; pass an explicit `key` for anonymous members.
|
|
49
|
+
|
|
50
|
+
A member is `{ key, meta }`. **It pushes only when the roster actually moves** — a join, a leave, a change to someone's `meta`, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
|
|
51
|
+
|
|
52
|
+
> **There is a second `usePresence`, and it is a different hook.**
|
|
53
|
+
> [`@voltro/local-first/react`](/docs/local-first/overview#presence--awareness)
|
|
54
|
+
> exports one for peer-to-peer *awareness* — `usePresence(roomId, self, { channel })`
|
|
55
|
+
> → `{ presence, others, setPresence }` — carrying high-frequency cursor and
|
|
56
|
+
> selection state over a pub/sub channel. This one is the server-backed roster.
|
|
57
|
+
> Different packages, different signatures; pick by the question you are asking.
|
|
49
58
|
|
|
50
59
|
## Typing indicator
|
|
51
60
|
|
|
@@ -85,7 +94,9 @@ an event declared `delivery: 'latest'` rather than in presence metadata.
|
|
|
85
94
|
|
|
86
95
|
## Notes
|
|
87
96
|
|
|
88
|
-
- The roster is push-driven: `presence.list`
|
|
97
|
+
- The roster is push-driven: `presence.list` declares a [reactivity channel](/docs/data/subscriptions#reactivity-channels) as its `source:`, so the framework re-runs it and pushes deltas over the subscription transport — no client polling. There is **no table**, and there is no longer a table NAME either: presence used to declare `_voltro_presence` and never write to it, purely to own a name the reactivity layer would route on. If you have an existing `_voltro_presence`, it is empty and the upgrade does not drop it (the differ never plans a drop for a framework table no app declares) — remove it by hand when convenient.
|
|
98
|
+
- **A repeat heartbeat does not push.** Measured on this machine at 2.7 µs per subscriber per publish, an unconditional push cost a steady room of N clients N² × that per heartbeat interval — ~107 ms of CPU per 15s at N=200, and a per-node ceiling around 750 subscribers that nothing in the app controlled. Publishing only on a real change removes that term entirely; what remains is linear in actual roster churn. Reproduce with `node --import tsx packages/plugin-presence/scripts/rosterFanoutBody.ts`.
|
|
99
|
+
- **A member has no `lastSeen`.** It used to, and it was the owning replica's clock — "active 3 minutes ago" rendered from it is wrong by whatever the skew between two pods is. Use `meta` for anything you need to show.
|
|
89
100
|
- A member counts as online for `timeoutMs` after its last heartbeat. The sweep runs every `timeoutMs / 3`, so a vanished member is gone within roughly 1.3× the window.
|
|
90
101
|
- The sweep needs **no cluster coordination**, and that follows from the design rather than being a shortcut: every member is owned by exactly one replica and nobody else may touch it, so each replica sweeps its own and there is nothing to contend over. (The table version *did* need coordination — its rows were shared.)
|
|
91
102
|
|
|
@@ -22,6 +22,15 @@ upgrade — never the individual calls. The Effect-native rpc interceptors are
|
|
|
22
22
|
the only surface that sees each call **and** the resolved subject (needed for
|
|
23
23
|
per-tenant limits).
|
|
24
24
|
|
|
25
|
+
> **This plugin is opt-in, and nothing rate-limits your RPC surface until you add it.**
|
|
26
|
+
> There is no built-in per-IP, per-API-key or per-tenant request cap — an app that
|
|
27
|
+
> does not install and configure `@voltro/plugin-ratelimit` accepts calls as fast as
|
|
28
|
+
> they arrive. The one throttle that *is* on by default is `plugin-auth`'s
|
|
29
|
+
> [brute-force lockout](/docs/authentication/passwords#brute-force-lockout) on sign-in,
|
|
30
|
+
> and it only covers credential attempts. Put a per-IP cap at your ingress as well —
|
|
31
|
+
> see [production hardening](/docs/deployment/production-hardening); that layer holds
|
|
32
|
+
> per-IP state across replicas, which an in-process limiter cannot.
|
|
33
|
+
|
|
25
34
|
Live — `demo.limited` is capped at 5/min per visitor; the 6th call within a
|
|
26
35
|
minute fails with the typed `RateLimited` error (carrying `retryAfterMs`):
|
|
27
36
|
|
|
@@ -24,15 +24,19 @@ export default {
|
|
|
24
24
|
name: 'api',
|
|
25
25
|
plugins: [
|
|
26
26
|
searchPlugin({
|
|
27
|
-
// backend: defaults to memory
|
|
28
|
-
//
|
|
27
|
+
// backend: defaults to memory — dev + tests only. Under NODE_ENV=production
|
|
28
|
+
// the memory backend REFUSES TO BOOT (see "The memory backend refuses to
|
|
29
|
+
// boot in production" below). Name a durable engine for a deployment:
|
|
30
|
+
// backend: { engine: 'typesense', url, apiKey } | { engine: 'meilisearch', … } | { engine: 'algolia', appId, apiKey }
|
|
29
31
|
indexes: {
|
|
30
32
|
posts: {
|
|
31
33
|
index: 'posts',
|
|
32
34
|
tenantField: 'tenantId', // index documents carry the tenant → query scopes by it
|
|
35
|
+
queryableFields: ['title', 'body', 'status'], // optional: fields a caller may filter/facet/highlight on
|
|
33
36
|
map: (row) => ({ id: String(row.id), title: String(row.title), body: String(row.body) }),
|
|
34
37
|
},
|
|
35
38
|
},
|
|
39
|
+
// allowedEngineParams: ['query_by'], // optional: widen the engineParams allowlist (see below)
|
|
36
40
|
}),
|
|
37
41
|
],
|
|
38
42
|
}
|
|
@@ -44,6 +48,50 @@ The plugin declares `onChangeEvent`. On every committed write to a configured ta
|
|
|
44
48
|
|
|
45
49
|
For pre-existing rows, `backfillIndex(backend, spec, rows)` indexes the rows you supply (call it from a `*.startup.tsx` or a one-off script) — or use the **Reindex** button / `POST /reindex` inspect endpoint, which reads the table's current rows for you.
|
|
46
50
|
|
|
51
|
+
## When the engine is down
|
|
52
|
+
|
|
53
|
+
The database commit has already happened by the time the tap runs, so a failed index write is **drift**: the row is in your database and missing from — or stale in — your index. That is not left to a log line.
|
|
54
|
+
|
|
55
|
+
1. **Retry.** A failure the backend marks *transient* (network, engine unavailable, 5xx, rate limited) is retried inside the tap's own Effect with capped exponential backoff. A *permanent* failure — an unsupported query shape, a `map(row)` that throws on one row's shape — is **not** retried: repeating it cannot succeed, and it lands in step 2 immediately.
|
|
56
|
+
2. **Record.** A change that outlives the retry is written to the framework-owned `_voltro_search_drift` table, one row per `(index, source row)`. Nothing is lost at that point: the entry lives in your own database — the thing that just committed successfully — while the search engine is what is down.
|
|
57
|
+
3. **Repair.** A cluster-coordinated sweep re-reads each recorded row **from the database** and re-derives its document. It never replays the failed event, and that is what makes repair order-free and idempotent: a row updated three times during an outage converges in one pass, and a row deleted since the failure converges to a removal.
|
|
58
|
+
|
|
59
|
+
The one case that is still a loss is the engine failing **and** the ledger write failing — and that one fails the tap loudly rather than reporting success.
|
|
60
|
+
|
|
61
|
+
Every number here is yours to set (defaults shown):
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
searchPlugin({
|
|
65
|
+
indexes: { /* … */ },
|
|
66
|
+
sync: {
|
|
67
|
+
retries: 5, // retries after the first attempt; transient failures only. 0 disables retry
|
|
68
|
+
retryBaseDelayMs: 200, // first backoff — doubles per attempt
|
|
69
|
+
retryMaxDelayMs: 10_000, // ceiling for any single wait
|
|
70
|
+
resyncIntervalMs: 60_000, // repair-sweep interval. 0 turns the sweep off (POST /resync still repairs on demand)
|
|
71
|
+
resyncBatchSize: 200, // max ledger entries repaired per sweep
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Drift is visible **without reading logs**:
|
|
77
|
+
|
|
78
|
+
| Endpoint | Shows |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `GET /_voltro/inspect/plugins/search/indexes` | per index: `dropped`, `pendingDrift`, `lastDriftAt`, `drifted` — plus the retry policy actually in force |
|
|
81
|
+
| `GET …/search/drift` | the failing rows themselves, oldest first, with their last error and attempt count |
|
|
82
|
+
| `POST …/search/resync` | runs a repair pass now (`{ scanned, repaired, failed }`) |
|
|
83
|
+
|
|
84
|
+
An entry whose `attempts` keeps climbing is telling you something a retry cannot fix — a `map(row)` that throws on that row, a document the engine rejects. That is the case to look at by hand; everything else drains on its own.
|
|
85
|
+
|
|
86
|
+
`_voltro_search_drift` is applied by the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect — there is nothing to migrate.
|
|
87
|
+
|
|
88
|
+
### Under multiple replicas
|
|
89
|
+
|
|
90
|
+
Index **writes** and index **counters** are deliberately treated differently when your store's change scope is `fleet` (every replica receives every change — postgres `LISTEN/NOTIFY` CDC, for instance):
|
|
91
|
+
|
|
92
|
+
- **The write runs on every replica.** An `upsert`/`remove` of the same document is idempotent, so a duplicate costs write amplification — while electing a single writer would cost a *lost* update whenever that replica dies mid-change, and would import the leadership-gap window with it.
|
|
93
|
+
- **The count runs everywhere too, but is aggregated as a maximum.** Each replica's stats row is already a fleet-wide count of the same changes, so `/indexes` takes the highest rather than the sum — the panel reports one sync per change, not one per replica. Under `local` scope only the replica that actually made the write counts (peers see an echo), and the rows are summed. Neither path needs a leader, so neither has a window in which counting stops.
|
|
94
|
+
|
|
47
95
|
## Querying
|
|
48
96
|
|
|
49
97
|
`search.query` is a typed **action** (not a streaming query — search results aren't reactive):
|
|
@@ -90,27 +138,130 @@ const res = await backend.query('posts', {
|
|
|
90
138
|
- **facets** — per-value counts for the named fields (over the full matched set, before paging).
|
|
91
139
|
- **highlight** — matched-term snippets per field, returned as `hit.highlights[field]`.
|
|
92
140
|
- **fuzziness** — a max edit distance (`0` = exact) or `'auto'`.
|
|
93
|
-
- **engineParams** —
|
|
141
|
+
- **engineParams** — the engine's **presentation-only** params (see the next section). Ignored by the memory backend.
|
|
94
142
|
|
|
95
143
|
Native support degrades honestly: memory, Typesense, Meilisearch and Algolia all do filters/facets/highlighting; Typesense honors a numeric typo count (`num_typos` 0–2) while Meilisearch and Algolia only toggle typo tolerance on/off (`fuzziness: 0` disables it, other values keep their built-in tolerance). A backend op fails with a typed `SearchBackendError`.
|
|
96
144
|
|
|
145
|
+
## What the server validates
|
|
146
|
+
|
|
147
|
+
`search.query` is a public wire surface, and three of its inputs — the index name, the field names, and `engineParams` — become the search engine's **control plane**. The plugin validates all three server-side, before the engine sees them, and refuses with a typed error rather than answering a query it cannot scope:
|
|
148
|
+
|
|
149
|
+
| Input | Rule | On violation |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `index` | must be one of the indexes you declared in `searchPlugin({ indexes })` | `SearchIndexNotFound` |
|
|
152
|
+
| `filters[].field`, `facets[]`, `highlight.fields[]` | a plain field path (`^[A-Za-z_][A-Za-z0-9_.]*$`), and — if the index declares `queryableFields` — one of those | `SearchFieldRejected` |
|
|
153
|
+
| `engineParams` | only the engine's presentation-only keys (paging, ordering, typo tolerance, highlight shaping) | the key is dropped and logged |
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { SearchFieldRejected, SearchIndexNotFound } from '@voltro/plugin-search'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Why each one, because the reasoning is what tells you whether your own declaration is tight enough:
|
|
160
|
+
|
|
161
|
+
- **An unknown index has no spec, therefore no `tenantField`, therefore no tenant clause.** Answering it would run the caller's query unscoped against whatever collection of that name exists on your engine — and a Typesense / Meilisearch / Algolia instance is usually shared with indexes this app never declared. It is refused, not answered empty.
|
|
162
|
+
- **A field name is spliced into the engine's filter DSL.** Typesense's `filter_by` is one flat string that supports `||`, so a crafted name can re-group the boolean tree around the tenant clause appended after it. The identifier pattern is the floor every index gets; `queryableFields` narrows it further to what your UI actually needs. Your `tenantField` does **not** belong in that list — the tenant clause is injected after this check, by the server, and is never a caller's to name.
|
|
163
|
+
- **`engineParams` is not a filter hatch.** It used to be merged last into the engine's params, so a caller could set `filter_by` (Typesense), `filter` (Meilisearch) or `facetFilters` (Algolia) and overwrite the tenant clause. Keys that could select a different document set — those, plus `query_by`, `restrictSearchableAttributes`, `preset`, `pinned_hits`, `enableRules`, … — are dropped. Express a filter as a `filters[]` clause instead: those are validated *and* ANDed with the tenant scope rather than replacing it.
|
|
164
|
+
|
|
165
|
+
If your app genuinely needs one more engine key, widen the allowlist **server-side**, where it is a deliberate decision instead of a caller's:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
searchPlugin({
|
|
169
|
+
indexes: { /* … */ },
|
|
170
|
+
allowedEngineParams: ['query_by'], // this app trusts callers with this key
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Document-selecting keys stay refused even when listed there — that is the authority the tenant clause holds.
|
|
175
|
+
|
|
97
176
|
## Backends
|
|
98
177
|
|
|
99
178
|
| Backend | Notes |
|
|
100
179
|
|---|---|
|
|
101
|
-
| `memoryBackend` (default) | Fully in-process; dev + tests.
|
|
180
|
+
| `memoryBackend` (default) | Fully in-process; dev + tests. **Refuses to boot under `NODE_ENV=production`** — see below. |
|
|
102
181
|
| `typesenseBackend` | Optional dep `typesense`. Lazy-loaded. |
|
|
103
182
|
| `meilisearchBackend` | Optional dep `meilisearch`. Lazy-loaded. |
|
|
104
183
|
| `algoliaBackend` | Optional dep `algoliasearch`. Lazy-loaded. |
|
|
105
184
|
|
|
106
185
|
A backend is the `SearchBackend` interface (`upsert` / `remove` / `query`) — bring your own (OpenSearch, Elastic, …).
|
|
107
186
|
|
|
187
|
+
### The memory backend refuses to boot in production
|
|
188
|
+
|
|
189
|
+
This is a **refusal, not a warning, and not a scale caveat**. Under
|
|
190
|
+
`NODE_ENV=production` on the in-memory backend, `onActivate` throws
|
|
191
|
+
`SearchBackendNotDurable` and the process does not start:
|
|
192
|
+
|
|
193
|
+
```text
|
|
194
|
+
plugin-search refuses to boot in production on the in-memory backend.
|
|
195
|
+
|
|
196
|
+
The memory index lives in THIS process's heap. Two consequences, both silent:
|
|
197
|
+
• every replica holds a different index, so a result depends on which replica served you;
|
|
198
|
+
• the index starts EMPTY after every restart/deploy, and nothing re-seeds it automatically.
|
|
199
|
+
|
|
200
|
+
Configure a durable engine in app.config.ts:
|
|
201
|
+
searchPlugin({ backend: { engine: 'typesense', url: …, apiKey: … }, indexes })
|
|
202
|
+
searchPlugin({ backend: { engine: 'meilisearch', url: …, apiKey: … }, indexes })
|
|
203
|
+
searchPlugin({ backend: { engine: 'algolia', appId: …, apiKey: … }, indexes })
|
|
204
|
+
or pass your own `SearchBackend` implementation.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Running one replica does not make it correct**, which is why the refusal is
|
|
208
|
+
not conditional on detecting a cluster. Two independent things are wrong with an
|
|
209
|
+
in-process index in a deployment and only one of them is the multi-replica
|
|
210
|
+
story:
|
|
211
|
+
|
|
212
|
+
1. **Per-process.** N replicas hold N divergent indexes. Which results you get
|
|
213
|
+
depends on which replica served the request — including *zero hits* for a
|
|
214
|
+
document that demonstrably exists.
|
|
215
|
+
2. **Non-durable.** The index lives in the heap, so every restart and every
|
|
216
|
+
deploy starts EMPTY and nothing re-seeds it: `backfillIndex` is a function
|
|
217
|
+
your app calls, not something the plugin does at boot.
|
|
218
|
+
|
|
219
|
+
(2) is what a single replica does not fix. It only removes one of the two ways
|
|
220
|
+
the backend is wrong.
|
|
221
|
+
|
|
222
|
+
Dev is **silent** — the memory backend is exactly right there, and a warning
|
|
223
|
+
that fires on every `voltro dev` boot is a warning nobody reads.
|
|
224
|
+
|
|
225
|
+
#### The escape hatch — `singleProcessMemoryIndex`
|
|
226
|
+
|
|
227
|
+
If this deployment genuinely is ONE process that re-seeds its index at startup,
|
|
228
|
+
say so and the boot proceeds:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
searchPlugin({
|
|
232
|
+
singleProcessMemoryIndex: true, // exactly one process, and it calls backfillIndex at startup
|
|
233
|
+
indexes: { /* … */ },
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Two things to be clear about before you reach for it:
|
|
238
|
+
|
|
239
|
+
- **It is a claim about your topology, not a mute switch.** The plugin holds you
|
|
240
|
+
to both halves: exactly one process serves search, and a `*.startup.tsx`
|
|
241
|
+
calls `backfillIndex` for every index — because the index *is* empty after
|
|
242
|
+
each restart until something fills it. The boot logs a `note` at info
|
|
243
|
+
restating what you signed up for.
|
|
244
|
+
- **The plugin checks the claim against reality.** When the instance-membership
|
|
245
|
+
registry reports that a peer replica joined, the plugin logs that the
|
|
246
|
+
declaration is now false — in *any* environment, because a peer announcing
|
|
247
|
+
itself is an observation rather than a guess about `NODE_ENV`:
|
|
248
|
+
|
|
249
|
+
```text
|
|
250
|
+
search: replica "<id>" joined, but this app declared `singleProcessMemoryIndex: true`.
|
|
251
|
+
That declaration is now false: each replica has its own in-memory index, so search
|
|
252
|
+
results depend on which one serves the request. Configure a durable backend
|
|
253
|
+
(typesense / meilisearch / algolia) or run one process.
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Without the declaration, a peer joining while search is served from a heap
|
|
257
|
+
index warns too — same observation, different wording.
|
|
258
|
+
|
|
108
259
|
## Dashboard panel
|
|
109
260
|
|
|
110
261
|
Both dashboards ship a **Search** panel (api apps): the configured indexes with per-index sync stats (docs synced/removed, last reindex) + the resolved backend, and a **Reindex** button per index that re-seeds it from the table's current rows (`backfillIndex`). Reindex gates on the `canReindexSearch` capability. Backed by `/_voltro/inspect/plugins/search/{indexes,reindex}`.
|
|
111
262
|
|
|
112
|
-
The sync stats behind this panel are **durable and aggregated across replicas**. They live in a framework-owned `_voltro_search_stats` table (contributed via `extendSchema.tables`; the plugin declares `store:write`), one row per `(index, replica)`, each bumped with an atomic compare-and-set. `/indexes`
|
|
263
|
+
The sync stats behind this panel are **durable and aggregated across replicas**. They live in a framework-owned `_voltro_search_stats` table (contributed via `extendSchema.tables`; the plugin declares `store:write`), one row per `(index, replica)`, each bumped with an atomic compare-and-set. `/indexes` aggregates every replica's row — summed under `local` change scope, maxed under `fleet` (see [Under multiple replicas](#under-multiple-replicas)) — and takes the most-recent reindex, so the counts are truthful under multiple instances and survive a restart. Zero-infra dev/tests use an in-process stats store; `bindDataStore` swaps in the durable one at boot, along with the drift ledger.
|
|
113
264
|
|
|
114
265
|
## Permissions
|
|
115
266
|
|
|
116
|
-
`store:changes:read` (the ChangeEvent tap) + `store:write` (the durable `_voltro_search_stats` counters) + `inspect:read` (dashboard panel) + `network:outbound:<host>` (for a remote backend).
|
|
267
|
+
`store:changes:read` (the ChangeEvent tap) + `store:write` (the durable `_voltro_search_stats` counters and the `_voltro_search_drift` ledger) + `inspect:read` / `inspect:write` (dashboard panel, incl. reindex + resync) + `network:outbound:<host>` (for a remote backend).
|
|
@@ -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.
|
|
@@ -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
|