@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
|
@@ -41,6 +41,32 @@ import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
|
|
|
41
41
|
export default { plugins: [aiFlowsPlugin()] } // contributes _voltro_ai_flows + _voltro_ai_flow_runs
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Three tunables, all with defaults — set them when the defaults don't match your
|
|
45
|
+
process:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// app.config.ts
|
|
49
|
+
import { aiFlowsPlugin } from '@voltro/plugin-ai-flows'
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
plugins: [
|
|
53
|
+
aiFlowsPlugin({
|
|
54
|
+
// How long a `human` step parks before the run fails. Default 7 days;
|
|
55
|
+
// `0` = wait forever. A flow's `humanTimeoutMs` and a step's own
|
|
56
|
+
// `timeoutMs` both outrank this.
|
|
57
|
+
humanReviewTimeoutMs: 14 * 24 * 60 * 60 * 1000,
|
|
58
|
+
// How long a FINISHED run is kept. Default 90 days.
|
|
59
|
+
runsTtlMs: 180 * 24 * 60 * 60 * 1000,
|
|
60
|
+
// Ceiling on rows one `/flows` or `/runs` inspect call returns.
|
|
61
|
+
inspectPageMax: 200,
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Each also has a deploy-time env override, for an operator who can't edit source:
|
|
68
|
+
`VOLTRO_AI_FLOW_HUMAN_REVIEW_TIMEOUT_HOURS` and `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`.
|
|
69
|
+
|
|
44
70
|
## Author a flow (code-first)
|
|
45
71
|
|
|
46
72
|
```ts
|
|
@@ -65,10 +91,39 @@ Each step writes `context[outputKey]`; later steps reference it as `{{outputKey}
|
|
|
65
91
|
|
|
66
92
|
- **`agent`** — delegate to a sub-agent (its system prompt + model), text-only.
|
|
67
93
|
- **`generate`** — `text` / `image` / `video` / `audio` via `@voltro/ai`.
|
|
68
|
-
- **`structured`** — a JSON object.
|
|
94
|
+
- **`structured`** — a JSON object, validated against the step's JSON Schema.
|
|
69
95
|
- **`note`** — the interpolated prompt itself is the output (no model call).
|
|
70
96
|
- **`human`** — pause for review (`approve` / `choice` / `text`).
|
|
71
97
|
|
|
98
|
+
### `structured` — the schema is real
|
|
99
|
+
|
|
100
|
+
A `structured` step's `schema` is a JSON Schema, and it is **both** what the model
|
|
101
|
+
is told to emit and what the result is validated against:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { flowStep } from '@voltro/plugin-ai-flows'
|
|
105
|
+
|
|
106
|
+
flowStep.structured({
|
|
107
|
+
prompt: 'Extract the invoice fields from: {{document}}',
|
|
108
|
+
outputKey: 'invoice',
|
|
109
|
+
schema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
required: ['total', 'currency'],
|
|
112
|
+
properties: {
|
|
113
|
+
total: { type: 'number', description: 'Gross total' },
|
|
114
|
+
currency: { type: 'string', enum: ['EUR', 'USD'] },
|
|
115
|
+
lines: { type: 'array', items: { type: 'string' } },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Objects (with `required`), arrays, the four scalars, `enum`, `const`, both
|
|
122
|
+
nullability spellings and `anyOf` / `oneOf` unions are modelled;
|
|
123
|
+
`description` / `title` ride along as annotations. A construct the adapter does
|
|
124
|
+
not model (`$ref`, `allOf`, …) degrades to "unknown" for **that node** rather
|
|
125
|
+
than failing the step, and a step with no `schema` behaves as it always did.
|
|
126
|
+
|
|
72
127
|
## Human-in-the-loop
|
|
73
128
|
|
|
74
129
|
A `human` step sets the run to `waiting` and **parks the workflow without holding
|
|
@@ -76,6 +131,40 @@ a worker** (the durable suspend). The user answers via `respondToFlow`; a reject
|
|
|
76
131
|
ends the run cancelled, otherwise the answer lands in `context` and the run
|
|
77
132
|
resumes. Thousands of runs can wait on review for days at no runtime cost.
|
|
78
133
|
|
|
134
|
+
**Several reviews in one flow are independent.** Each human step parks on its own
|
|
135
|
+
durable signal, derived from that step's position in the plan the run's journal
|
|
136
|
+
pinned at step 0 — so the identity is the same on every replay, and answering the
|
|
137
|
+
first review cannot resolve the second. `respondToFlow` reads the parked step off
|
|
138
|
+
the run row's live timeline and addresses that step, which is why the UI never
|
|
139
|
+
has to track a step index; it returns the `stepIndex` it answered.
|
|
140
|
+
|
|
141
|
+
### The park is bounded, and the bound is yours
|
|
142
|
+
|
|
143
|
+
An approval left over a weekend used to fail the whole flow: the park inherited
|
|
144
|
+
`@voltro/workflow`'s 24-hour default, which no flow author chose or could change.
|
|
145
|
+
It resolves most-specific-first now — **step → flow → plugin option → env →
|
|
146
|
+
7 days** — and `0` at any level means *wait forever* (the park is slot-free, so
|
|
147
|
+
an unbounded wait costs no worker):
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { defineFlow, flowStep } from '@voltro/plugin-ai-flows'
|
|
151
|
+
|
|
152
|
+
defineFlow({
|
|
153
|
+
name: 'contract-review',
|
|
154
|
+
// Flow-wide default for every human step that sets none.
|
|
155
|
+
humanTimeoutMs: 30 * 24 * 60 * 60 * 1000,
|
|
156
|
+
steps: [
|
|
157
|
+
flowStep.human({ reviewMode: 'approve', prompt: 'Legal sign-off?' }),
|
|
158
|
+
// This one is the CFO's, and it waits as long as it takes.
|
|
159
|
+
flowStep.human({ reviewMode: 'approve', prompt: 'Budget sign-off?', timeoutMs: 0 }),
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A flow authored as DATA carries the same setting in `_voltro_ai_flows.humanTimeoutMs`.
|
|
165
|
+
When the bound does expire, the run row is written `status: 'failed'` naming the
|
|
166
|
+
bound that elapsed — it no longer dies with the row still reading `waiting`.
|
|
167
|
+
|
|
79
168
|
## Chaining & cadence
|
|
80
169
|
|
|
81
170
|
- **`chainTo`** launches a follow-up flow on success (result ⊕ input → the child's
|
|
@@ -90,6 +179,58 @@ resumes. Thousands of runs can wait on review for days at no runtime cost.
|
|
|
90
179
|
`costMicroUsd`, …) as it runs, and the client's subscription streams the timeline
|
|
91
180
|
over CDC — no polling, no sockets.
|
|
92
181
|
|
|
182
|
+
## Driving a flow from the UI
|
|
183
|
+
|
|
184
|
+
`@voltro/plugin-ai-flows/web` is the browser-safe half: launch → observe →
|
|
185
|
+
respond, without hand-rolling a subscription.
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import { useFlowReview, useLaunchFlow } from '@voltro/plugin-ai-flows/web'
|
|
189
|
+
|
|
190
|
+
export function ArticleFlow({ runId }: { runId?: string }) {
|
|
191
|
+
const { launch, pending } = useLaunchFlow()
|
|
192
|
+
const { steps, status, awaitingReview, prompt, approve, reject, done } = useFlowReview(runId)
|
|
193
|
+
|
|
194
|
+
if (runId === undefined) {
|
|
195
|
+
return (
|
|
196
|
+
<button disabled={pending} onClick={() => launch({ flowRef: 'blog-article', input: { topic: 'Effect' } })}>
|
|
197
|
+
Start
|
|
198
|
+
</button>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
<div>
|
|
203
|
+
<ol>{steps.map((s) => <li key={s.id}>{s.title ?? s.type} — {s.status}</li>)}</ol>
|
|
204
|
+
{awaitingReview && (
|
|
205
|
+
<div>
|
|
206
|
+
<p>{prompt}</p>
|
|
207
|
+
<button onClick={() => approve(runId)}>Approve</button>
|
|
208
|
+
<button onClick={() => reject(runId)}>Reject</button>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
{done && <p>Finished: {status}</p>}
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The full set: `useLaunchFlow`, `useFlowRun` (the reactive run row projected into
|
|
218
|
+
a timeline — steps, status, the pending review, `done`), `useFlowRuns`,
|
|
219
|
+
`useFlows`, `useRetryFlow`, `useCancelFlow`, `useRespondToFlow`
|
|
220
|
+
(`approve` / `reject` / `choose` / `submitText`) and `useFlowReview`, which is
|
|
221
|
+
the whole review widget in one call.
|
|
222
|
+
|
|
223
|
+
**Every hook takes the tag set.** This plugin ships no fixed RPC routes — its
|
|
224
|
+
procedures are helpers you wire into your own thin rpc files (below) — so the
|
|
225
|
+
hooks default to `aiFlows.launch` / `.run` / `.respond` / … and accept an
|
|
226
|
+
override plus an `apiName`:
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
import { useFlowRun } from '@voltro/plugin-ai-flows/web'
|
|
230
|
+
|
|
231
|
+
const run = useFlowRun(runId, { apiName: 'admin', tags: { run: 'flows.oneRun' } })
|
|
232
|
+
```
|
|
233
|
+
|
|
93
234
|
## Wiring (once per app)
|
|
94
235
|
|
|
95
236
|
The engine runs as one durable workflow; the operations are server helpers (a
|
|
@@ -138,8 +279,26 @@ Adopt `@voltro/plugin-versioning` on `_voltro_ai_flows` for automatic edit histo
|
|
|
138
279
|
planner), so a mid-run crash re-plans (`retry` = fresh run).
|
|
139
280
|
- **Cadence + scale-to-zero**: `dormancy: 'sleep'` won't fire an in-process tick —
|
|
140
281
|
use an external waker or `trigger: 'external'`.
|
|
282
|
+
- **Run retention**: `_voltro_ai_flow_runs` is bounded by the framework sweep at
|
|
283
|
+
**90 days** (`runsTtlMs` / `VOLTRO_AI_FLOW_RUNS_TTL_HOURS`). Only **terminal**
|
|
284
|
+
runs are swept (`succeeded | failed | cancelled`) — a run parked on a human
|
|
285
|
+
review is live state, not history, and a plain time-TTL would delete pending
|
|
286
|
+
approvals. `_voltro_ai_flows` (the DEFINITIONS) is deliberately unbounded: its
|
|
287
|
+
size tracks how many flows a team writes, not traffic.
|
|
288
|
+
|
|
289
|
+
**Upgrading a live app:** the first sweep runs ~30 s after boot and deletes
|
|
290
|
+
every terminal run older than the TTL, in batches, until the backlog drains. An
|
|
291
|
+
app that has been running flows for more than 90 days loses that history at
|
|
292
|
+
once — set the env var (or `runsTtlMs`) BEFORE deploying if you need it. An
|
|
293
|
+
app's own `registerRetention` for the table also outranks the plugin's.
|
|
141
294
|
- **Media retention**: not automatic — add a `*.cron.tsx` deleting old artifacts
|
|
142
|
-
via `storage.delete(id)` (a plain retention sweep would orphan the blobs).
|
|
295
|
+
via `storage.delete(id)` (a plain retention sweep would orphan the blobs). Note
|
|
296
|
+
this interacts with the run TTL: a run's steps carry hosted URLs whose blobs
|
|
297
|
+
belong to the storage plugin, so deleting the row orphans them. Keep the run
|
|
298
|
+
TTL at or above your media-purge window, or purge by run id first.
|
|
299
|
+
- **Inspect page size**: `/flows` and `/runs` take a `?take=`, clamped by
|
|
300
|
+
`inspectPageMax` (default 200). It matters here more than usual — a run row
|
|
301
|
+
carries every step's full text output.
|
|
143
302
|
- **Media tenant on resume**: `generateMedia` receives `run.tenantId`, read from
|
|
144
303
|
the durable run row. Pin persistence to it (as above) rather than the caller
|
|
145
304
|
subject — a resumed run executes under a tenant-less system subject, so reading
|
|
@@ -53,7 +53,7 @@ plugin's `_voltro_events` table is auto-merged into your schema, so
|
|
|
53
53
|
| Option | Type | Default | Notes |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| `sqlClient` | `SqlClient.SqlClient` | the main DataStore's client | Override to land events in a SEPARATE database (e.g. a dedicated analytics Postgres while the app runs MySQL). You must create the events table there yourself. |
|
|
56
|
-
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data }`) so analytical queries can JOIN events against live user data. |
|
|
56
|
+
| `mirrorTables` | `ReadonlyArray<string>` | `[]` (events-only) | Reactive tables to CDC-mirror into `_voltro_mirror_<table>` (`{ id, data, version, is_deleted }`) so analytical queries can JOIN events against live user data. Deletes write a tombstone — filter `is_deleted = false`. |
|
|
57
57
|
| `mirrorPrimaryKey` | `string` | `'id'` | Primary-key column on the mirrored source rows. |
|
|
58
58
|
|
|
59
59
|
```ts
|
|
@@ -73,7 +73,7 @@ auditPlugin({
|
|
|
73
73
|
// custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns.
|
|
76
|
+
`'datastore'` is the production sink: it survives restarts, is shared across replicas, and is queryable via `ctx.store.select('_voltro_audit_log')`. Each row carries the flattened `tag` / `at` / `subjectId` / `tenantId` / `traceId` / `status` / `durationMs` (indexed by `tag` + `traceId`) plus the full `subject` / `input` / `outcome` as portable `json()` columns, plus the `chainId` / `seq` / `prevHash` / `hash` tamper-evidence columns (see [the hash chain](#tamper-evidence--the-hash-chain)).
|
|
77
77
|
|
|
78
78
|
The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an `Effect.Effect<void>` sink that writes rows into your own audit table on top of `@effect/sql`. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.
|
|
79
79
|
|
|
@@ -176,6 +176,42 @@ const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit
|
|
|
176
176
|
|
|
177
177
|
`auditBySubject` takes `status` as a real argument rather than leaving you to filter in JS: the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it.
|
|
178
178
|
|
|
179
|
+
### Tamper-evidence — the hash chain
|
|
180
|
+
|
|
181
|
+
Append-only is a convention, not a guarantee. An actor with `UPDATE` on the database could rewrite what a call did, or `DELETE` the row that recorded a refusal, and no read of the table would notice — an audit trail whose integrity rests on "nobody has database access" is exactly as trustworthy as the thing it exists to check.
|
|
182
|
+
|
|
183
|
+
Every row written by `sink: 'datastore'` therefore carries its position in a hash chain: `chainId`, `seq`, `prevHash` and `hash`, where `hash` covers the row's own content **and** the previous row's hash. Altering any row invalidates every row after it; removing one leaves a hole.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { verifyAuditChain } from '@voltro/plugin-audit'
|
|
187
|
+
|
|
188
|
+
const verdict = await verifyAuditChain(ctx.store)
|
|
189
|
+
// { ok, rowsChecked, unchainedRows, keyed, chains: [{ chainId, from, to, tip, prunedPrefix }], issues: [...] }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`issues` names what does not add up, and the kinds are not interchangeable:
|
|
193
|
+
|
|
194
|
+
| Kind | Meaning |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `tampered` | The row's content does not hash to its stored `hash`. A column was altered. |
|
|
197
|
+
| `broken-link` | The row's `prevHash` is not the previous row's `hash`. Reordering, or a row substituted for another. |
|
|
198
|
+
| `gap` | `seq` jumped. A row was deleted, or its write failed. |
|
|
199
|
+
|
|
200
|
+
A gap at the **start** of a chain is not an issue — the retention sweep prunes oldest-first, so a pruned prefix is reported as `prunedPrefix: true`. Rows written before chaining shipped carry no `hash` at all and are counted as `unchainedRows` rather than passed over silently.
|
|
201
|
+
|
|
202
|
+
#### The chain is per WRITER, and that is the concurrency answer
|
|
203
|
+
|
|
204
|
+
One global chain would need every insert to know the current tip — a serialization point across every process writing audit rows. Two replicas racing on one chain **fork**, and a fork is indistinguishable from tampering. A chain that breaks under normal operation is worse than no chain at all, because the first false positive is what teaches everyone to ignore the verifier. A per-tenant chain has the identical problem one level down.
|
|
205
|
+
|
|
206
|
+
So each process mints its own `chainId` at boot and allocates `seq`/`prevHash`/`hash` in a synchronous, `await`-free step — atomic against any number of concurrent events. What you get in exchange is stated plainly: N replicas produce N chains, so verification attests *"every chain is intact"*, not *"the log is complete"*.
|
|
207
|
+
|
|
208
|
+
#### Read the guarantee before you quote it
|
|
209
|
+
|
|
210
|
+
Unkeyed (the default), the chain detects any change that does **not** recompute it: a hand-run `UPDATE`, a botched migration, storage corruption, a script that scrubs one row. It does **not** stop an adversary who knows the scheme and rewrites the chain forward — SHA-256 is public, so with write access they can. Two things close that, both available:
|
|
211
|
+
|
|
212
|
+
- **`VOLTRO_AUDIT_CHAIN_SECRET`** — set it and the chain is HMAC-SHA256. An actor with the database but not the key cannot forge a link. There is no default value and nothing is minted for you; keep the key where the database is not.
|
|
213
|
+
- **Anchor the tips.** `verifyAuditChain` returns each chain's `tip`. Publish it on a schedule to somewhere append-only you do not control (an object-lock bucket, a log shipper, a compliance mailbox). This is also the *only* defence against tail truncation — deleting the newest N rows of a chain is undetectable from the table alone, for any hash chain.
|
|
214
|
+
|
|
179
215
|
### Retention, and the GDPR interaction
|
|
180
216
|
|
|
181
217
|
`sink: 'datastore'` registers its own retention: **365 days by default**, tunable with `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. An append-only trail with no ceiling is the one that eventually takes the database down.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Social login (Google / GitHub / Apple)
|
|
2
|
+
|
|
3
|
+
> First-party Sign in with Google / GitHub / Apple — mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, no identity vendor.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/auth-social.md -->
|
|
10
|
+
## Social login (Google / GitHub / Apple)
|
|
11
|
+
|
|
12
|
+
_First-party Sign in with Google / GitHub / Apple — mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, no identity vendor._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-auth-social` is "Sign in with Google" without adopting an identity vendor. The six sibling `@voltro/plugin-auth-*` adapters are enterprise-IdP **token verifiers** — they check a JWT an IdP already issued. This one runs the whole login: it builds the authorize URL, redeems the authorization code, verifies what came back, decides what the identity means for your `users` table, and issues the **same** session cookie password sign-in issues.
|
|
15
|
+
|
|
16
|
+
Three providers ship: **Google**, **GitHub** and **Apple**.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pnpm add @voltro/plugin-auth-social
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Wiring
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// app.config.ts
|
|
28
|
+
import { authRoutesPlugin, postgresUserStore } from '@voltro/plugin-auth'
|
|
29
|
+
import { socialAuthPlugin } from '@voltro/plugin-auth-social'
|
|
30
|
+
|
|
31
|
+
const auth = {
|
|
32
|
+
defaultTenantId: 'public',
|
|
33
|
+
appBaseUrl: 'https://app.example.com',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
type: 'api' as const,
|
|
38
|
+
name: 'myApi',
|
|
39
|
+
store: 'postgres' as const,
|
|
40
|
+
plugins: [
|
|
41
|
+
authRoutesPlugin({ store: users, ...auth }),
|
|
42
|
+
socialAuthPlugin({
|
|
43
|
+
providers: { google: {}, github: {} },
|
|
44
|
+
users,
|
|
45
|
+
auth,
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An empty `{}` for a provider means "take the credentials from the environment". Then link to the start route:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<a href="/auth/social/google">Sign in with Google</a>
|
|
55
|
+
<a href="/auth/social/github">Sign in with GitHub</a>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Two routes are mounted per provider, under `/auth/social` by default:
|
|
59
|
+
|
|
60
|
+
| Route | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `GET /auth/social/<provider>` | Mints `state` + a PKCE verifier + an OIDC nonce, stores them in a short-lived `HttpOnly` cookie, redirects to the provider |
|
|
63
|
+
| `GET` or `POST /auth/social/<provider>/callback` | Verifies `state`, redeems the code, verifies the ID token, applies the link policy, issues the session cookie |
|
|
64
|
+
|
|
65
|
+
The session is issued by `issueUserSession` from `@voltro/plugin-auth` — the same function password sign-in, magic-link, MFA and passkeys use. So a social login gets the `sessions` row (device list + server-side revocation), keyed-secret rotation, sliding-window renewal and the membership-carrying Subject automatically.
|
|
66
|
+
|
|
67
|
+
## Credentials
|
|
68
|
+
|
|
69
|
+
Nothing is ever defaulted. A missing credential fails the boot; it never falls back.
|
|
70
|
+
|
|
71
|
+
| Provider | Environment |
|
|
72
|
+
|---|---|
|
|
73
|
+
| Google | `VOLTRO_GOOGLE_CLIENT_ID`, `VOLTRO_GOOGLE_CLIENT_SECRET` |
|
|
74
|
+
| GitHub | `VOLTRO_GITHUB_CLIENT_ID`, `VOLTRO_GITHUB_CLIENT_SECRET` |
|
|
75
|
+
| Apple | `VOLTRO_APPLE_CLIENT_ID`, `VOLTRO_APPLE_TEAM_ID`, `VOLTRO_APPLE_KEY_ID`, `VOLTRO_APPLE_PRIVATE_KEY` |
|
|
76
|
+
|
|
77
|
+
Register `<appBaseUrl>/auth/social/<provider>/callback` as the redirect URI with each provider, byte for byte.
|
|
78
|
+
|
|
79
|
+
## Account linking — read this before shipping
|
|
80
|
+
|
|
81
|
+
The security decision at the heart of social login is one sentence: *Google says the person in front of you owns `ada@example.com`, and your `users` table already has a row for `ada@example.com`. Do you log them into it?*
|
|
82
|
+
|
|
83
|
+
Answering "yes, the emails match" is the classic pre-authentication account-takeover vector. A provider that does not verify an address lets an attacker register the victim's email, never confirm it, click "Sign in with X" and land inside the victim's account. So there are two policies, and the default is the strict one:
|
|
84
|
+
|
|
85
|
+
| `linkPolicy` | Behaviour |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `'never'` (default) | A social identity never attaches to a pre-existing account. Unknown email ⇒ a new user. Known email ⇒ refused, with a message telling the user to sign in the way they already can and connect the provider from account settings |
|
|
88
|
+
| `'verified-email'` | Links when the provider **asserted** the address is verified and it is not an Apple private relay. A real, bounded risk, taken deliberately |
|
|
89
|
+
|
|
90
|
+
There is deliberately no policy that links on an *unverified* email.
|
|
91
|
+
|
|
92
|
+
The linking that is always sound is not a policy at all: attaching a provider to an account whose **session you already hold**. Call `linkSocialIdentity` from an authenticated route — the proof of ownership is the session, which is the only proof that is actually sound. That is the escape hatch every app on `'never'` needs.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { linkSocialIdentity, socialCompleteLogin } from '@voltro/plugin-auth-social'
|
|
96
|
+
|
|
97
|
+
// inside an authenticated route, after socialCompleteLogin returned a profile
|
|
98
|
+
yield* linkSocialIdentity(identities, subject.id, {
|
|
99
|
+
provider: profile.provider,
|
|
100
|
+
providerAccountId: profile.providerAccountId,
|
|
101
|
+
email: profile.email,
|
|
102
|
+
emailVerified: profile.emailVerified,
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## What is verified, and what is not
|
|
107
|
+
|
|
108
|
+
- **`state` is mandatory and is never caller-supplied.** It is compared in constant time *before anything leaves the process* — a forged callback never reaches a token endpoint.
|
|
109
|
+
- **PKCE (S256) rides on every provider**, including GitHub, whose OAuth app flow ignores it. The parameter is unconditional so a future provider cannot silently land on the no-PKCE path.
|
|
110
|
+
- **ID tokens are verified against the provider's JWKS** — signature (ES256/RS256 only; HMAC algorithms are rejected), `iss`, `aud`, `exp`/`iat` — through the same verifier every IdP adapter here uses, plus a `nonce` check that makes an ID token from another login fail.
|
|
111
|
+
- **GitHub has nothing signed to verify.** Identity comes from `GET /user` plus `GET /user/emails`, and only the entry that is both `primary` and `verified` is trusted. The self-declared profile email is never used.
|
|
112
|
+
|
|
113
|
+
## Apple: three things that break naive implementations
|
|
114
|
+
|
|
115
|
+
1. **The name arrives exactly once.** Apple returns it in no token — it posts a `user` form field on the *first* authorization and never again. The profile flags this as `nameIsFirstAuthorizationOnly`; persist it then or lose it.
|
|
116
|
+
2. **The client secret is a JWT you sign yourself** (ES256, from a `.p8` key, capped at six months). This plugin does not store one at all: it mints a 15-minute secret per exchange, so there is nothing to rotate and nothing to expire in production half a year later. A TTL above Apple's cap is rejected at call time.
|
|
117
|
+
3. **The email may be a per-app private relay** (`…@privaterelay.appleid.com`, flagged by `is_private_email`). It is verified but it is not the user's address, so linking on it is refused even under `'verified-email'`.
|
|
118
|
+
|
|
119
|
+
One more Apple-specific trap: requesting the `name`/`email` scopes makes Apple **POST the callback cross-site**, and a `SameSite=Lax` cookie is not sent on a cross-site POST. The plugin writes the login-state cookie `SameSite=None; Secure` for Apple, which means **Apple needs HTTPS even in development**.
|
|
120
|
+
|
|
121
|
+
## Options
|
|
122
|
+
|
|
123
|
+
| Option | Default | Notes |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `providers` | — | Which providers to offer; `{}` takes credentials from the environment |
|
|
126
|
+
| `users` | — | The same `UserStore` `authRoutesPlugin` runs on |
|
|
127
|
+
| `auth` | — | The same `AuthConfig`: session secret, cookie flags, default tenant, subject guards |
|
|
128
|
+
| `identities` | DataStore-backed | Where identities are recorded |
|
|
129
|
+
| `linkPolicy` | `'never'` | See above |
|
|
130
|
+
| `prefix` | `/auth/social` | Route prefix |
|
|
131
|
+
| `appBaseUrl` | `auth.appBaseUrl` | Origin the default redirect URIs are built from |
|
|
132
|
+
| `successRedirect` | `auth.successRedirect` ?? `/` | Where to send the browser after a login |
|
|
133
|
+
| `failureRedirect` | — | When set, a refusal redirects with `?social_error=<code>` instead of answering JSON |
|
|
134
|
+
| `stateTtlSeconds` | `600` | How long a started login may take to come back |
|
|
135
|
+
| `stateCookieName` | `voltro:oauth` | Name of the login-state cookie |
|
|
136
|
+
|
|
137
|
+
## Schema
|
|
138
|
+
|
|
139
|
+
The plugin contributes `_voltro_oauth_identities` (one row per user per provider account, unique on `[provider, providerAccountId]`) via `extendSchema`. It rides the declarative differ — `voltro db apply` and a `voltro dev` boot both reconcile it, on every dialect. It is deliberately **not** swept by retention: these rows *are* the credential, and a TTL that deleted them would silently un-enrol users.
|
|
140
|
+
|
|
141
|
+
## Post-authentication guards
|
|
142
|
+
|
|
143
|
+
`auth.subjectGuards` run here exactly as on every other login path, so an account blocked by `@voltro/plugin-deactivation` cannot get in through the newest door. Sign-*up* is exempt — a brand-new user no guard could yet have an opinion about.
|
|
@@ -85,8 +85,10 @@ Server-only; fails closed — a forged, expired, or wrong-audience token, or one
|
|
|
85
85
|
|
|
86
86
|
Beyond the verify-only `workosStrategy`, the package exports two primitives for the **hosted-login** flow — WorkOS logs the user in and your app mints its *own* session (an alternate front door alongside password login, not a replacement session authority):
|
|
87
87
|
|
|
88
|
-
- **`
|
|
89
|
-
- **`workosAuthenticateWithCode({ clientId, apiKey, code })`** → exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).
|
|
88
|
+
- **`workosBeginLogin({ clientId, redirectUri })`** → `{ url, state, codeVerifier }`. The AuthKit login URL always carries a minted CSRF `state` **and** a PKCE `code_challenge` (S256); stash the other two for the callback.
|
|
89
|
+
- **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** → verifies the state in constant time (throwing `WorkosStateMismatchError` on a mismatch, before any network call), then exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).
|
|
90
|
+
|
|
91
|
+
Neither `state` nor PKCE is optional: `state` used to be a parameter you could pass, so the default flow had no CSRF token, and the exchange verified nothing. The check now lives inside the only function that can redeem a code — a generated `state` that nothing verifies is worse than none.
|
|
90
92
|
|
|
91
93
|
Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency). Wire them into a `GET /auth/workos/login` + `GET /auth/workos/callback` route pair, find-or-provision your user in the callback, then `issueSession(...)`. Full worked example: [WorkOS SSO login](/docs/authentication/external-idp#workos-sso-login).
|
|
92
94
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
_Password + session-cookie auth — overview. Full docs in the Authentication section._
|
|
13
13
|
|
|
14
|
-
`@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.
|
|
14
|
+
`@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, email verification, tenant invitations, user impersonation, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.
|
|
15
15
|
|
|
16
16
|
The single `authRoutesPlugin()` mounts **every** auth HTTP route under `/auth` — you don't hand-wire endpoints.
|
|
17
17
|
|
|
@@ -37,7 +37,7 @@ Add `authRoutesPlugin()` to your api's `plugins` array. It mounts the entire aut
|
|
|
37
37
|
// app.config.ts
|
|
38
38
|
import { authRoutesPlugin, postgresUserStore, mailSender } from '@voltro/plugin-auth'
|
|
39
39
|
import { mailPlugin, MailService } from '@voltro/plugin-mail'
|
|
40
|
-
import {
|
|
40
|
+
import { bindConnectionCredential } from '@voltro/runtime'
|
|
41
41
|
import { Effect } from 'effect'
|
|
42
42
|
|
|
43
43
|
// `mail` is the yielded MailService from the mail plugin's services layer.
|
|
@@ -49,7 +49,7 @@ const auth = (mail: MailService) =>
|
|
|
49
49
|
cookieSecure: process.env.NODE_ENV === 'production',
|
|
50
50
|
appBaseUrl: 'https://app.example.com',
|
|
51
51
|
sendEmail: mailSender(mail), // ← plugin-mail synergy (the default wiring)
|
|
52
|
-
rebind:
|
|
52
|
+
rebind: bindConnectionCredential, // ← switch-tenant presents the new cookie on the live connection
|
|
53
53
|
passkey: {
|
|
54
54
|
rpId: 'example.com',
|
|
55
55
|
rpName: 'Acme',
|
|
@@ -83,7 +83,7 @@ The plugin:
|
|
|
83
83
|
- Mounts every auth route under `/auth` via `authRoutesPlugin()` (also re-exports the underlying Effect-typed handlers — `handleSignIn`, `handleSignUp`, `handleMagicLinkRequest`, `handleSwitchTenant`, … — if you'd rather mount a subset yourself).
|
|
84
84
|
- Resolves `ctx.subject` via `AuthMiddleware` running the strategy chain — the built-in `voltroPasswordStrategy` reads the session cookie.
|
|
85
85
|
- Exposes the typed user store via `postgresUserStore(sql)` (synchronous; the caller owns the `SqlClient`) or `memoryUserStore()` for dev/tests.
|
|
86
|
-
- Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`) via `authTables`.
|
|
86
|
+
- Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`, `invitationsTable`, `impersonationGrantsTable`, …) via `authTables`.
|
|
87
87
|
|
|
88
88
|
## Routes mounted under `/auth`
|
|
89
89
|
|
|
@@ -97,25 +97,150 @@ The plugin:
|
|
|
97
97
|
| `POST /auth/password-reset` · `/password-reset/confirm` | — | reset flow (confirm revokes all sessions) |
|
|
98
98
|
| `GET /auth/sessions` · `POST /auth/sessions/revoke` · `/sessions/revoke-others` | cookie | device list + revocation |
|
|
99
99
|
| `GET /auth/memberships` · `POST /auth/switch-tenant` | cookie | multi-tenant membership + active-tenant switch |
|
|
100
|
+
| `POST /auth/verify-email` · `/verify-email/callback` | — | request/resend a confirmation link (uniform `202`); redeem one (issues **no** session) |
|
|
101
|
+
| `POST /auth/invitations` · `GET /auth/invitations` · `POST /auth/invitations/revoke` | cookie | create / list / withdraw tenant invitations (needs `invitations` config) |
|
|
102
|
+
| `POST /auth/invitations/preview` · `/invitations/accept` · `/invitations/sign-up` | — / cookie | inspect a token, redeem it as the signed-in user, or redeem it by creating the invited account |
|
|
103
|
+
| `POST /auth/impersonate/start` · `/impersonate/stop` | cookie | act as another user and end it (needs `impersonation` config) |
|
|
100
104
|
| `POST /auth/mfa/enroll/start` · `/enroll/verify` · `/unenroll` · `/recovery-codes/regenerate` | cookie | TOTP enrolment (needs `mfa: { issuer }` config); `enroll/verify` returns one-time recovery codes |
|
|
101
105
|
| `POST /auth/passkey/register/options` · `/register/verify` | cookie | passkey enrolment |
|
|
102
106
|
| `POST /auth/passkey/assert/options` · `/assert/verify` | — | passkey sign-in |
|
|
103
107
|
|
|
104
108
|
State-changing authenticated routes require a valid `x-csrf-token` header matching the `voltro:csrf` cookie.
|
|
105
109
|
|
|
110
|
+
While a session is **impersonated**, the account-security routes are refused with `403 forbidden_while_impersonating` — see [User impersonation](#user-impersonation-log-in-as).
|
|
111
|
+
|
|
106
112
|
## Schema
|
|
107
113
|
|
|
108
114
|
```ts
|
|
109
115
|
import { authTables } from '@voltro/plugin-auth/schema'
|
|
110
116
|
// authTables = [usersTable, sessionsTable, membershipsTable, authTokensTable,
|
|
111
|
-
// passkeysTable, recoveryCodesTable, passkeyChallengesTable
|
|
117
|
+
// passkeysTable, recoveryCodesTable, passkeyChallengesTable,
|
|
118
|
+
// loginAttemptsTable, invitationsTable, impersonationGrantsTable]
|
|
112
119
|
```
|
|
113
120
|
|
|
114
121
|
Spread `authTables` into your `database/index.ts` handle + `voltro migrate` creates the tables.
|
|
115
122
|
|
|
116
123
|
## Multi-tenant memberships + switch-tenant
|
|
117
124
|
|
|
118
|
-
A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` —
|
|
125
|
+
A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` — **presents that same cookie on the connection** via `bindConnectionCredential`.
|
|
126
|
+
|
|
127
|
+
It presents a CREDENTIAL, not a Subject, and that is the whole design. The rebound connection is resolved by the full auth chain on its next call — session revocation, `auth.resolveScopes`, the scope cache, the credential-expiry bound — exactly as a reconnecting browser would be. It used to hand the middleware a resolved Subject, which the middleware returned verbatim: the connection then kept that authority for its whole life, immune to a revocation performed anywhere else. (And it only worked under `voltro dev`; `voltro serve` had no such path at all, so the rebind silently did nothing in production.)
|
|
128
|
+
|
|
129
|
+
What a rebind does NOT do: re-scope subscriptions already open on that connection. They were authorized under the previous cookie and run until the client re-subscribes. Calls made after the rebind see the new tenant.
|
|
130
|
+
|
|
131
|
+
## Email verification
|
|
132
|
+
|
|
133
|
+
`users` carries a nullable `emailVerifiedAt`, and `POST /auth/verify-email` + `/verify-email/callback` mint, send and redeem a confirmation link over the same single-use hashed-token table the magic-link and reset flows use.
|
|
134
|
+
|
|
135
|
+
**What an unverified account may do is a product decision, so it is a config field with three values** rather than a behaviour the framework picks for every app:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
authRoutesPlugin({
|
|
139
|
+
// …store, secret, defaultTenantId, sendEmail…
|
|
140
|
+
emailVerification: {
|
|
141
|
+
policy: 'strict', // 'off' (default) · 'soft' · 'strict'
|
|
142
|
+
exemptAccountsCreatedBefore: new Date('2026-08-11'), // your deploy instant
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| policy | login | the session |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `'off'` (default) | proceeds | carries **no** mark |
|
|
150
|
+
| `'soft'` | proceeds | marked — `isEmailVerified(subject)` is `false` |
|
|
151
|
+
| `'strict'` | refused, `403 email_not_verified` | never issued |
|
|
152
|
+
|
|
153
|
+
**Why `'off'` is the default.** The column arrives `NULL` on every existing row. Under `'strict'` as a default, the first boot after upgrading would refuse the next login of every user you already have — a total authentication outage caused by missing data rather than by anything a user did. `exemptAccountsCreatedBefore` is the adoption seam: accounts created before that instant count as verified, so you can turn `'strict'` on without a backfill.
|
|
154
|
+
|
|
155
|
+
**`isEmailVerified(subject)` is tri-state, and `null` is not `false`.** A session minted while the policy was `'off'` says nothing about verification. Reading "no mark" as "unverified" would refuse every live session at the moment you switch the policy on — the same outage one layer up. Gate on `=== false`.
|
|
156
|
+
|
|
157
|
+
**Three links prove an address, not one.** A magic link and a completed password reset are inbox round-trips exactly as a verification link is, so both stamp `emailVerifiedAt` (keeping the first timestamp — the column answers "since when", not "last clicked"). Without that, `'strict'` deadlocks a magic-link-only user: they can prove their address by signing in, and are refused the sign-in for not having proved it.
|
|
158
|
+
|
|
159
|
+
**A verification link is not a credential.** Redeeming one marks the address and issues **no** session. Treating it as a sign-in would turn a link that sits 24 hours in a mailbox — and in every relay along the way — into a day-long credential.
|
|
160
|
+
|
|
161
|
+
`'strict'` is enforced through the [subject-guard seam](#post-authentication-subject-guards), so it covers password, MFA verify, magic-link and passkey sign-in from one wiring. `authRoutesPlugin` installs the guard when you set the policy; if you mount the handlers yourself, add `emailVerificationGuard(config)` to `subjectGuards` — setting the policy alone changes only what the session is *marked* with.
|
|
162
|
+
|
|
163
|
+
The request endpoint answers a uniform `202` (unknown address, already-verified address and cooled-down resend are indistinguishable) and sends at most one mail per `resendCooldownSeconds` (default 60). Without that bound, "resend" is a mail-bomb primitive aimed at any address known to have an account.
|
|
164
|
+
|
|
165
|
+
## Tenant invitations
|
|
166
|
+
|
|
167
|
+
`invitations` joins `authTables`, and six routes mount when you pass an `invitations` config:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
authRoutesPlugin({
|
|
171
|
+
// …store, secret, defaultTenantId, sendEmail, appBaseUrl…
|
|
172
|
+
invitations: {
|
|
173
|
+
ttlSeconds: 7 * 24 * 60 * 60, // default
|
|
174
|
+
inviterRoles: ['owner', 'admin'], // default
|
|
175
|
+
roleRank: ['owner', 'admin', 'member', 'viewer'], // default, most privileged first
|
|
176
|
+
maxPending: 500, // default
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**An invitation is a credential that grants access to someone else's data**, so it gets the token discipline of one — 32 CSPRNG bytes, only the SHA-256 stored, single-use via one conditional `UPDATE … RETURNING`, an expiry — plus three properties a login token has no need for:
|
|
182
|
+
|
|
183
|
+
- **It is addressed.** The invited address is compared against the accepting user's; a mismatch is `403 invitation_email_mismatch`. A link forwarded, leaked into a channel or intercepted is refused rather than silently granting whoever opens it first.
|
|
184
|
+
- **It carries its own authority, chosen by the inviter.** The accept request is `{ token }` and nothing else — there is no field an invitee could use to name their own role.
|
|
185
|
+
- **It is revocable**, tenant-scoped in the SQL predicate, so an admin of one tenant cannot withdraw another's by id.
|
|
186
|
+
|
|
187
|
+
**An inviter can never grant a role above their own.** That direction is not configurable: an `admin` who can mint an `owner` invitation and accept it from a second address has promoted themselves, which makes every role boundary in the product advisory. `canGrantRole` replaces the whole rule for a model that is not a line (a matrix, a per-tenant plan) — keep it a refusal by default. A role the ranking does not know is grantable only by the top role; treating an unfamiliar `superadmin` as probably-harmless is how it gets handed out by an `admin`.
|
|
188
|
+
|
|
189
|
+
Both "the invitee already has an account" cases are covered. Signed in as the invited address, `POST /auth/invitations/accept` writes the membership. Signed out with no account, `POST /auth/invitations/sign-up` creates one — with the address taken from the **invitation**, never from the request, and marked already verified, since the invitation arrived in that mailbox and came back. An existing address is answered `409 account_exists` and the invitation is not burned.
|
|
190
|
+
|
|
191
|
+
Re-inviting **supersedes**: every pending invitation for the same (tenant, address) is revoked before the new one is issued, so a resend cannot leave live tokens behind. `maxPending` bounds a tenant so a compromised admin account is not a mail cannon. The admin list never returns `tokenHash` — a hash verifies a guessed plaintext offline, and an admin list is not a place to publish a verifier.
|
|
192
|
+
|
|
193
|
+
`invitations` is registered with the retention sweep at 90 days (`VOLTRO_INVITATIONS_TTL_HOURS`), armed only when the feature is configured.
|
|
194
|
+
|
|
195
|
+
## User impersonation ("log in as")
|
|
196
|
+
|
|
197
|
+
An impersonated session that is indistinguishable from a real one does not merely lack a feature — it retroactively destroys the audit trail of the whole product. Every row an agent touches is attributed to the user, so afterwards nobody can answer *"did the customer delete this, or did we?"* — including for the incident where it matters. Everything below follows from that.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { authRoutesPlugin, impersonationAuditRedactor } from '@voltro/plugin-auth'
|
|
201
|
+
import { auditPlugin } from '@voltro/plugin-audit'
|
|
202
|
+
import { Effect } from 'effect'
|
|
203
|
+
|
|
204
|
+
authRoutesPlugin({
|
|
205
|
+
// …store, secret, defaultTenantId…
|
|
206
|
+
impersonation: {
|
|
207
|
+
// REQUIRED. A function, never a role — see below.
|
|
208
|
+
authority: ({ actorUser }) => Effect.succeed(supportAgentIds.has(actorUser.id)),
|
|
209
|
+
// REQUIRED. Fires for 'started', 'stopped' AND 'refused'.
|
|
210
|
+
audit: (event) => { void recordSupportEvent(event) },
|
|
211
|
+
maxDurationSeconds: 900, // default — 15 minutes
|
|
212
|
+
requireReason: true, // default
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// So the mark reaches the durable audit trail too — read the note below.
|
|
217
|
+
auditPlugin({ sink: 'datastore', redactSubject: impersonationAuditRedactor() })
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**`authority` is a function, never a role, and has no default.** Two separate reasons. "A role that happens to be admin-ish" is how this becomes a privilege-escalation path in a product where `admin` means "can edit the pricing page". And mechanically: the subject these routes resolve comes from the session cookie, which [carries identity only](/docs/authentication/sessions#the-cookie-carries-identity-never-authority) — it has no `scopes` at all, so a scope-based default would be unsatisfiable by every caller, i.e. a feature that refuses everyone.
|
|
221
|
+
|
|
222
|
+
**`audit` is required** because impersonation's whole risk is an unrecorded action. A config that let you enable it while leaving the destination unset would make the dangerous half optional and the safe half opt-in.
|
|
223
|
+
|
|
224
|
+
**The mark lives in two places with different failure modes.** `subject.metadata.impersonation` travels with the cookie and reaches the client, so a banner needs no extra endpoint (`impersonationOf(subject)` reads it). An `impersonationGrants` **row** is written *before* the session exists — so a session can never be reachable without the record naming who is behind it — and no redaction policy can drop it.
|
|
225
|
+
|
|
226
|
+
> **Wire `impersonationAuditRedactor()` or your audit rows will not say who was behind an impersonated action.** `auditPlugin`'s default is `redactSubject: 'metadata'`, which replaces the whole metadata bag — correct in general (that bag is where a per-user provider credential lands) and it takes this mark with it. The redactor keeps the mark and redacts everything else: strictly safer than `'none'`, strictly more informative than the default.
|
|
227
|
+
|
|
228
|
+
**Time-bounded means the cookie expires**, not that a row says it should. The grant duration is the cookie's `Max-Age`, the session row's `expiresAt` and the grant's `expiresAt`, minted from one number. A caller may request *less*; a request for more is clamped, never obeyed.
|
|
229
|
+
|
|
230
|
+
**Stopping** closes the grant, deletes the impersonated session row and drops it from the revocation cache — so a copy of that cookie taken during the grant dies immediately, not at its own expiry — then re-issues the impersonator's own, never-revoked session for its **remaining** lifetime. Restoring does not extend their login. An actor whose own session died meanwhile is signed out rather than left as somebody else.
|
|
231
|
+
|
|
232
|
+
**Four escalation refusals:**
|
|
233
|
+
|
|
234
|
+
| refusal | what it stops |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `self_impersonation` | acting as yourself — every action marked impersonated with no second identity behind it, i.e. noise in the field an investigator reads |
|
|
237
|
+
| `nested_impersonation` | A→B, then as B→C. The mark carries one actor, so a chain attributes C's session to B — reaching any account with a **forged** attribution |
|
|
238
|
+
| `target_may_impersonate` | acting as someone who can themselves impersonate. The probe is `authority` evaluated with the identities **swapped** ("could the target impersonate me?"), so there is no second policy to keep in step |
|
|
239
|
+
| `forbidden_while_impersonating` | MFA enrolment/removal, recovery-code regeneration, passkey registration, switch-tenant, revoke-other-sessions, and starting another impersonation. Without this a 15-minute grant converts to permanent access in one request: enrol a passkey as the user and the time bound is decoration |
|
|
240
|
+
|
|
241
|
+
The impersonator also gains no authority the target lacks, by construction: the cookie **is** the target's identity, carries no scopes, and authority is re-resolved per request from that identity. Being impersonated does not clear the target's brute-force lockout either — a support action must not undo the protection on the account someone is hammering.
|
|
242
|
+
|
|
243
|
+
`impersonationGrants` is registered with the retention sweep at 365 days (`VOLTRO_IMPERSONATION_GRANTS_TTL_HOURS`) — matching the audit log, because it answers the same class of question — armed only when the feature is configured.
|
|
119
244
|
|
|
120
245
|
## MFA / TOTP sign-in enforcement
|
|
121
246
|
|