@voltro/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1968 -0
- package/bin/nodeEnvironment.d.mts +30 -0
- package/bin/nodeEnvironment.mjs +158 -0
- package/bin/voltro.mjs +69 -5
- package/dist/addCommand-BNeoeSxe.js +124 -0
- package/dist/addCommand-aXSQveak.js +2 -0
- package/dist/agentsMd-BTchIZku.js +2 -0
- package/dist/agentsMd-mhQMF1bx.js +254 -0
- package/dist/apiBuild-B8aoJvuw.js +2 -0
- package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
- package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
- package/dist/appGraph-zuMGKVYX.js +2 -0
- package/dist/appPort-B_HpJ_ck.js +48 -0
- package/dist/baselineCommand-C2ClWZN3.js +2 -0
- package/dist/baselineCommand-DIttzO8A.js +227 -0
- package/dist/bin.js +71 -28
- package/dist/build-CD8K4XOr.js +711 -0
- package/dist/cacheCommand-DA4OH9xt.js +42 -0
- package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
- package/dist/checkCommand-Ct9xkTrS.js +232 -0
- package/dist/checkCommand-DKpDLlqu.js +2 -0
- package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
- package/dist/cliError-BmdYnghb.js +10 -0
- package/dist/cliOutput-D1tSBoRM.js +15 -0
- package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
- package/dist/cloudClient-DWL-Hw_T.js +67 -0
- package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
- package/dist/clusterCmd-D5wsCmA_.js +54 -0
- package/dist/codegen-CYM3Zqrf.js +605 -0
- package/dist/codegen-ChBi_hVa.js +2 -0
- package/dist/codegenCommand-C4YoQIc2.js +30 -0
- package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
- package/dist/commandRunner-BLAEFLjp.js +47 -0
- package/dist/commands-BE8E7zF3.js +816 -0
- package/dist/connectionConfig-UFlIEiys.js +66 -0
- package/dist/dashboardCommand-D7SgZGaN.js +25 -0
- package/dist/dataCommand-BhYwDgg-.js +537 -0
- package/dist/dataProfile-dW-PsfLB.js +15 -0
- package/dist/dbCommand-DS4b97Is.js +2 -0
- package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
- package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
- package/dist/dev-D2BikO7a.js +3 -0
- package/dist/devActivity-Dx_3nnGv.js +100 -0
- package/dist/devActivity.js +1 -1
- package/dist/dialectDriver-CgXnDfec.js +39 -0
- package/dist/discover-C9XKJDco.js +25 -0
- package/dist/doctorCommand-CM4Ch9C7.js +2 -0
- package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
- package/dist/dormancyCommand-QewYug_s.js +69 -0
- package/dist/e2eCmd-BRabZww-.js +147 -0
- package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
- package/dist/envCommand-CCGPRQY1.js +60 -0
- package/dist/evalCommand-6RUfPen4.js +118 -0
- package/dist/evolveCommand-CHsLCtDf.js +281 -0
- package/dist/fileTaxonomy-CJfgOllU.js +457 -0
- package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
- package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
- package/dist/generateCommand-DbgcUpGw.js +147 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4 -3
- package/dist/infoCommand-DwOgK1t6.js +60 -0
- package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
- package/dist/inspect-P4pxoMaV.js +2 -0
- package/dist/inspectCmd-EHFZ9yYu.js +224 -0
- package/dist/inspectFetch-EMuhTG_9.js +151 -0
- package/dist/inspectMetrics-CfdKLh6t.js +72 -0
- package/dist/loadEnv-D9nEOClM.js +44 -0
- package/dist/logFileSink-C_D2wRN1.js +105 -0
- package/dist/logsCmd-D36xK7Zu.js +260 -0
- package/dist/manifestBuild-COkJoyAr.js +2 -0
- package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
- package/dist/metaCommands-7MJfZ5cf.js +196 -0
- package/dist/migrate-BV7I-ZHZ.js +83 -0
- package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
- package/dist/newCommand-COWOJ1_E.js +156 -0
- package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
- package/dist/packageCommand-Cug_3Ogl.js +271 -0
- package/dist/pageConvention-cEiRxdab.js +5 -0
- package/dist/privacyCommand-C-Df56U_.js +146 -0
- package/dist/probeCommand-CZfaaUOZ.js +122 -0
- package/dist/projectScaffold-DmzEKHib.js +2 -0
- package/dist/projectScaffold-LMMtaavR.js +814 -0
- package/dist/renderModeScan-D7J1B7Kw.js +105 -0
- package/dist/renderProfile-1OWWAAtx.js +81 -0
- package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
- package/dist/runtimeTrace-CH3eUiMw.js +91 -0
- package/dist/scheduleCmd-DQRu6BZC.js +149 -0
- package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
- package/dist/schemaIr-UJybUUZW.js +103 -0
- package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
- package/dist/seedRunner-ZmLSqNe2.js +333 -0
- package/dist/serveCommand-CbDHU6l-.js +2 -0
- package/dist/serveCommand-iwlUBNS1.js +1766 -0
- package/dist/serveEntry.js +5 -5
- package/dist/serverlessCommand-CfJZy6dS.js +482 -0
- package/dist/start-BgN62boB.js +3 -0
- package/dist/start-T4VesWiM.js +1087 -0
- package/dist/startEntry.js +2 -2
- package/dist/staticCommand-Dr2M6tpU.js +304 -0
- package/dist/storageCommand-Co6NfLqN.js +42 -0
- package/dist/templates-De8IR5-c.js +102 -0
- package/dist/test-CI6iDsYc.js +115 -0
- package/dist/tracesCmd-DStmCJPi.js +232 -0
- package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
- package/dist/tsxLoader-EuXmSJ1K.js +51 -0
- package/dist/typecheckCommand-BlsWiCNq.js +61 -0
- package/dist/updateCommand-BlMZhWgO.js +2 -0
- package/dist/updateCommand-x0pI_x-B.js +585 -0
- package/dist/webDev-BcykISYQ2.js +2 -0
- package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
- package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
- package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
- package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
- package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
- package/package.json +209 -17
- package/templates/AGENTS.core.md +58 -3
- package/templates/AGENTS.md +64 -7
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +23 -6
- package/templates/agent-docs/ai.md +370 -0
- package/templates/agent-docs/authentication.md +313 -31
- package/templates/agent-docs/caching.md +6 -0
- package/templates/agent-docs/cli.md +853 -50
- package/templates/agent-docs/data.md +608 -12
- package/templates/agent-docs/database/migrations.md +223 -25
- package/templates/agent-docs/database/misc.md +156 -40
- package/templates/agent-docs/database/querying.md +19 -1
- package/templates/agent-docs/database/scaling.md +60 -0
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/database/seedsdialects.md +208 -19
- package/templates/agent-docs/database/transactions.md +68 -0
- package/templates/agent-docs/deployment.md +284 -25
- package/templates/agent-docs/introduction.md +88 -17
- package/templates/agent-docs/local-first-mobile.md +79 -4
- package/templates/agent-docs/multi-tenancy.md +188 -42
- package/templates/agent-docs/observability.md +58 -3
- package/templates/agent-docs/plugins/ai-flows.md +247 -2
- package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
- package/templates/agent-docs/plugins/audit.md +37 -1
- package/templates/agent-docs/plugins/auth-social.md +143 -0
- package/templates/agent-docs/plugins/auth-workos.md +4 -2
- package/templates/agent-docs/plugins/auth.md +131 -6
- package/templates/agent-docs/plugins/billing.md +132 -15
- package/templates/agent-docs/plugins/cdc-out.md +46 -7
- package/templates/agent-docs/plugins/clickhouse.md +32 -1
- package/templates/agent-docs/plugins/duckdb.md +1 -1
- package/templates/agent-docs/plugins/flags.md +132 -0
- package/templates/agent-docs/plugins/governance.md +105 -7
- package/templates/agent-docs/plugins/multitenancy.md +9 -4
- package/templates/agent-docs/plugins/presence.md +13 -2
- package/templates/agent-docs/plugins/ratelimit.md +9 -0
- package/templates/agent-docs/plugins/search.md +162 -8
- package/templates/agent-docs/plugins/sso-saml.md +47 -8
- package/templates/agent-docs/plugins/storage.md +11 -0
- package/templates/agent-docs/plugins/webhooks.md +105 -0
- package/templates/agent-docs/plugins.md +152 -18
- package/templates/agent-docs/reference.md +60 -3
- package/templates/agent-docs/releases.md +1117 -0
- package/templates/agent-docs/routing.md +43 -25
- package/templates/agent-docs/scheduling.md +27 -0
- package/templates/agent-docs/schema-driven-ui.md +92 -12
- package/templates/agent-docs/security.md +426 -0
- package/templates/agent-docs/templates/apibackends.md +87 -18
- package/templates/agent-docs/templates/appshells.md +32 -14
- package/templates/agent-docs/templates/overview.md +13 -8
- package/templates/agent-docs/testing.md +211 -14
- package/templates/agent-docs/whats-new.md +98 -136
- package/templates/agent-docs/workflows.md +231 -14
- package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
- package/templates/apps/api-ai/package.json +8 -7
- package/templates/apps/api-auth/actions/me.action.ts +13 -0
- package/templates/apps/api-auth/package.json +9 -8
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend/package.json +12 -8
- package/templates/apps/api-backend/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
- package/templates/apps/api-backend-deactivation/package.json +8 -7
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mail/package.json +9 -8
- package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-mariadb/.env.example +14 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-mariadb/package.json +10 -9
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-sqlite/.env.example +14 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-sqlite/package.json +9 -8
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
- package/templates/apps/api-backend-storage/package.json +9 -8
- package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
- package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
- package/templates/apps/api-cms/actions/me.action.ts +13 -0
- package/templates/apps/api-cms/app.config.ts +19 -0
- package/templates/apps/api-cms/authz.ts +63 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
- package/templates/apps/api-cms/package.json +11 -10
- package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
- package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
- package/templates/apps/api-collab/package.json +9 -8
- package/templates/apps/api-collab/queries/documents.query.ts +19 -7
- package/templates/apps/api-data-advanced/package.json +9 -8
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
- package/templates/apps/api-durable/package.json +9 -8
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
- package/templates/apps/api-feature-flags/package.json +10 -9
- package/templates/apps/api-governance/README.md +30 -8
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
- package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
- package/templates/apps/api-governance/database/schema.ts +16 -4
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
- package/templates/apps/api-governance/package.json +9 -8
- package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
- package/templates/apps/api-kv/package.json +9 -8
- package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
- package/templates/apps/api-moderation/package.json +9 -8
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
- package/templates/apps/api-observability/package.json +9 -8
- package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
- package/templates/apps/api-ratelimit/package.json +9 -8
- package/templates/apps/api-rbac/package.json +9 -8
- package/templates/apps/api-rest/package.json +8 -7
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
- package/templates/apps/api-saas/package.json +12 -11
- package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
- package/templates/apps/api-saas-starter/app.config.ts +19 -0
- package/templates/apps/api-saas-starter/authz.ts +75 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
- package/templates/apps/api-saas-starter/package.json +15 -11
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
- package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
- package/templates/apps/api-search/package.json +9 -8
- package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
- package/templates/apps/api-status/README.md +10 -3
- package/templates/apps/api-status/app.config.ts +8 -3
- package/templates/apps/api-status/authz.ts +5 -3
- package/templates/apps/api-status/package.json +9 -8
- package/templates/apps/api-status/queries/components.list.query.ts +14 -6
- package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
- package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
- package/templates/apps/api-status/tests/status.test.ts +9 -1
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
- package/templates/apps/api-versioning/package.json +9 -8
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
- package/templates/apps/api-webhooks/package.json +10 -9
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
- package/templates/apps/changelog/package.json +8 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +10 -8
- package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
- package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
- package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
- package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
- package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
- package/templates/apps/frontend-app/app.config.ts +4 -3
- package/templates/apps/frontend-app/package.json +11 -8
- package/templates/apps/frontend-app/src/lib/api.ts +25 -0
- package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
- package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
- package/templates/apps/frontend-auth/package.json +10 -8
- package/templates/apps/frontend-blank/package.json +9 -7
- package/templates/apps/frontend-cms/package.json +11 -9
- package/templates/apps/frontend-collab/package.json +12 -9
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
- package/templates/apps/frontend-contact/package.json +9 -7
- package/templates/apps/frontend-dashboard/package.json +9 -7
- package/templates/apps/frontend-docs/package.json +9 -7
- package/templates/apps/frontend-i18n/package.json +8 -6
- package/templates/apps/frontend-landing/package.json +9 -7
- package/templates/apps/frontend-portal/package.json +10 -8
- package/templates/apps/frontend-saas/app.config.ts +10 -6
- package/templates/apps/frontend-saas/package.json +10 -8
- package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
- package/templates/apps/frontend-spa/package.json +9 -7
- package/templates/apps/frontend-ssr/package.json +9 -7
- package/templates/apps/frontend-ssr-api/package.json +10 -8
- package/templates/apps/frontend-static-blog/package.json +8 -6
- package/templates/apps/frontend-status/package.json +10 -8
- package/templates/apps/mobile-app/README.md +1 -0
- package/templates/apps/mobile-app/package.json +4 -2
- package/templates/apps/mobile-app/src/app/index.tsx +22 -12
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
- package/templates/apps/mobile-app/src/lib/api.ts +34 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
- package/templates/baselines/bare/.env.example +14 -0
- package/templates/baselines/bare/baseline.json +4 -4
- package/templates/baselines/compose/.env.example +14 -0
- package/templates/baselines/compose/README.md +1 -1
- package/templates/baselines/compose/baseline.json +5 -5
- package/templates/baselines/compose-mariadb/.env.example +14 -0
- package/templates/baselines/compose-mariadb/README.md +1 -1
- package/templates/baselines/compose-mariadb/baseline.json +5 -5
- package/templates/baselines/helm/.env.example +14 -0
- package/templates/baselines/helm/baseline.json +4 -4
- package/dist/apiBuild-C-x9YacA.js +0 -2
- package/dist/appGraph-CvQCte0z.js +0 -2
- package/dist/checkCommand-DRovTKza.js +0 -2
- package/dist/commands-CJfepbm4.js +0 -11541
- package/dist/dbCommand-b1gum4td.js +0 -2
- package/dist/dev-iiMtlkfs.js +0 -3
- package/dist/devActivity-BhIu6ncs.js +0 -159
- package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
- package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
- package/dist/inspect-mmBuRXmy.js +0 -2
- package/dist/manifestBuild-Dj8Jjoto.js +0 -2
- package/dist/seedRunner-Bqxgp7HZ.js +0 -230
- package/dist/serveCommand-DdaM4Hup.js +0 -1608
- package/dist/start-C0koT0UO.js +0 -1084
- /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
|
@@ -95,7 +95,7 @@ import { hashPassword } from '@voltro/plugin-auth'
|
|
|
95
95
|
import { Effect } from 'effect'
|
|
96
96
|
|
|
97
97
|
const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
|
|
98
|
-
// → 'scrypt$
|
|
98
|
+
// → 'scrypt$32768$8$1$<saltB64>$<derivedB64>'
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
Store `hash` in the `passwordHash` column of your users table. **Never** store the plaintext.
|
|
@@ -133,11 +133,29 @@ The framework uses scrypt with these defaults:
|
|
|
133
133
|
|
|
134
134
|
| Param | Value | Effect |
|
|
135
135
|
|---|---|---|
|
|
136
|
-
| `N` | `2^
|
|
136
|
+
| `N` | `2^15` = 32768 | CPU + memory cost |
|
|
137
137
|
| `r` | 8 | block size |
|
|
138
138
|
| `p` | 1 | parallelism |
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Measured — node v26.3.0, Apple M2 Pro, median of 5 runs at `r=8 p=1 keyLen=32`. Memory is `128 · N · r`, held for the whole derivation:
|
|
141
|
+
|
|
142
|
+
| `N` | time | memory |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `2^14` | 39 ms | 16 MiB |
|
|
145
|
+
| **`2^15`** (this) | **73 ms** | **32 MiB** |
|
|
146
|
+
| `2^16` | 156 ms | 64 MiB |
|
|
147
|
+
| `2^17` (OWASP's floor) | 271 ms | 128 MiB |
|
|
148
|
+
|
|
149
|
+
### Why not OWASP's `2^17`
|
|
150
|
+
|
|
151
|
+
Every row of that table is a cost **your server** pays, per attempt, on an endpoint an anonymous caller controls. Two facts decide it:
|
|
152
|
+
|
|
153
|
+
- The brute-force lockout that is on by default is keyed by **email** — deliberately, so an unknown address locks exactly like a real one and the lock is not an existence oracle. An attacker who rotates the email field is therefore not rate-limited, and every attempt buys a full derivation. General per-IP rate limiting is opt-in ([`@voltro/plugin-ratelimit`](/docs/plugins/ratelimit)).
|
|
154
|
+
- The common deployment target is a small container. At 0.25 vCPU, `2^17` is over a second of CPU and 128 MiB **per anonymous attempt** — one laptop can hold that box down, and ten concurrent logins is an OOM.
|
|
155
|
+
|
|
156
|
+
Availability is part of security, so the ceiling here is set by what an anonymous caller can make the server spend, not by the offline-cracking table alone. Put a per-IP limiter in front of `/auth` and raising `N` becomes cheap.
|
|
157
|
+
|
|
158
|
+
The parameters are encoded inline in the hash string (`scrypt$32768$8$1$…`), so a cost bump decodes older hashes and re-encodes them on the next sign-in — see [Rehash-on-verify](/docs/plugins/auth). Hashes minted at `2^14` keep verifying; nothing to migrate.
|
|
141
159
|
|
|
142
160
|
For high-throughput service-to-service flows that need many auths per second, use API keys instead — `apiKeyStrategy` from `@voltro/protocol/apikey`. Passwords are for humans.
|
|
143
161
|
|
|
@@ -227,13 +245,40 @@ voltro:session=<base64url(payload)>.<base64url(signature)>
|
|
|
227
245
|
|
|
228
246
|
Where:
|
|
229
247
|
|
|
230
|
-
- `payload` is JSON `{ subject, exp, iat, kid? }
|
|
248
|
+
- `payload` is JSON `{ v, subject, exp, iat, kid? }`, e.g. `{ "v": 2, "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }`. `v` is the payload format version; `iat` drives the sliding-window renewal below; `kid` is the (non-secret) label of the signing key, stamped when signing with a keyed secret set.
|
|
231
249
|
- `signature` is HMAC-SHA256(payload, AUTH_SECRET)
|
|
232
250
|
|
|
233
251
|
**Why HMAC, not JWT?**
|
|
234
252
|
|
|
235
253
|
JWTs come with the `alg: 'none'` attack and a long history of header confusion. The framework's format is intentionally simpler: HMAC-SHA256, fixed algorithm, no header.
|
|
236
254
|
|
|
255
|
+
## The cookie carries identity, never authority
|
|
256
|
+
|
|
257
|
+
`subject` in the payload is a **`SubjectIdentity`** — the `Subject` union with `scopes` omitted at the schema level. A session cookie says *who* the caller is. What they may *do* is resolved on every request by [`auth.resolveScopes`](/docs/authentication/strategies).
|
|
258
|
+
|
|
259
|
+
This is not a style choice, it is the whole lifetime argument. Identity is settled once at sign-in and never changes. Authority changes the moment an admin removes a role — and a value signed into a 7-day cookie is frozen for 7 days, longer if the sliding renewal keeps re-signing it. Embedding scopes meant that narrowing someone's role had no effect on the session they already held: you could sign them out entirely, but you could not take one permission away.
|
|
260
|
+
|
|
261
|
+
Two consequences to know:
|
|
262
|
+
|
|
263
|
+
- **`issueSession` / `signSession` throw** when handed a subject that carries `scopes`. They do not strip them. A silently dropped scope is an authorization change with no error, no log line and no diff — you find it later, as "permissions randomly stopped working".
|
|
264
|
+
- **`readSession` / `verifySession` return a `SubjectIdentity`.** The absent field is the guarantee: nothing downstream can read authority out of a cookie, because the value it gets back has nowhere to keep it.
|
|
265
|
+
|
|
266
|
+
If you build a Subject yourself at login — a custom sign-in route, an SSO `onLogin` — mint the session from the identity and move the permissions into the resolver:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
// before: authority frozen into the cookie for a week
|
|
270
|
+
issueSession({ ...identity, scopes: permissionsFor(user) }, secret)
|
|
271
|
+
|
|
272
|
+
// after: identity in the cookie, authority resolved per request
|
|
273
|
+
issueSession(identity, secret)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Payload versioning — what happens to live sessions
|
|
277
|
+
|
|
278
|
+
`v` is required and pinned to the version this build mints. A payload without it, or with an older one, **fails to decode** — it does not fall back to a lenient read. A cookie from the previous format asserts authority, and honouring that assertion is exactly the defect being removed; authenticating someone under a contract the server no longer holds is worse than asking them to sign in.
|
|
279
|
+
|
|
280
|
+
So a framework upgrade that bumps the payload version ends every live session. Plan it like a secret rotation: users see the sign-in screen once. Their `sessions` rows are untouched.
|
|
281
|
+
|
|
237
282
|
## Issuing
|
|
238
283
|
|
|
239
284
|
```ts
|
|
@@ -241,7 +286,7 @@ import { issueSession } from '@voltro/plugin-auth'
|
|
|
241
286
|
|
|
242
287
|
// Positional args: (subject, secret, options?). Returns { value, setCookie }.
|
|
243
288
|
const { value, setCookie } = issueSession(
|
|
244
|
-
subject, //
|
|
289
|
+
subject, // an identity, e.g. subjectFromUser(user) — no scopes
|
|
245
290
|
AUTH_CONFIG.secret,
|
|
246
291
|
{
|
|
247
292
|
ttlSeconds: 60 * 60 * 24 * 7, // 7 days (default if omitted)
|
|
@@ -262,7 +307,7 @@ res.setHeader('set-cookie', setCookie)
|
|
|
262
307
|
import { readSession } from '@voltro/plugin-auth'
|
|
263
308
|
|
|
264
309
|
const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
|
|
265
|
-
// → { type: 'user', id, tenantId } | null
|
|
310
|
+
// → { type: 'user', id, tenantId } | null — a SubjectIdentity: no `scopes`
|
|
266
311
|
```
|
|
267
312
|
|
|
268
313
|
Returns `null` for:
|
|
@@ -270,6 +315,7 @@ Returns `null` for:
|
|
|
270
315
|
- Missing cookie
|
|
271
316
|
- Tampered payload (signature mismatch)
|
|
272
317
|
- Expired session (`exp < now`)
|
|
318
|
+
- A payload from a superseded format version (see above)
|
|
273
319
|
|
|
274
320
|
## Clearing
|
|
275
321
|
|
|
@@ -304,6 +350,8 @@ For centralised revocation, the plugin writes a `sessions` row on every sign-in
|
|
|
304
350
|
|
|
305
351
|
Revocation is enforced **at request time**: on every verify, the cookie's server-side session id (`metadata.sessionId`) is checked against the `sessions` table through a small in-process TTL cache (default **30 seconds**, tune via the plugin's `sessionRevocation.ttlMs`). Be honest about the window: a revocation is instant on the process that performed it (its cache entry is invalidated inline — sign-out kills the cookie immediately there) and takes effect within the cache window on every other replica. `POST /auth/sign-out` deletes the caller's session row, and a password-reset confirm revokes **all** of the user's sessions — in both cases a retained copy of the cookie stops authenticating within that window, days before its `exp`. The framework's rpc auth chain enforces the same check: `authRoutesPlugin` carries a pre-wired session strategy (sharing the same cache) that `voltro dev` / `voltro serve` slot into the chain automatically. One deliberate gap: a cookie minted by hand via `issueSession` without a `sessions` row carries no `sessionId` and stays purely stateless.
|
|
306
352
|
|
|
353
|
+
**Revoking a session and revoking a permission are two different operations, and both now take effect on a live session.** This section is the first: kill the row and the holder is signed out. The second is [`auth.resolveScopes`](/docs/authentication/strategies) — narrow the role and the caller keeps their session but loses the permission. The two windows are deliberately the same 30 seconds and both invalidate to zero the same way, so there is one number to reason about rather than a second one you discover later.
|
|
354
|
+
|
|
307
355
|
## When to use a longer / shorter lifetime
|
|
308
356
|
|
|
309
357
|
| Use case | Lifetime |
|
|
@@ -322,9 +370,9 @@ Sliding-window auto-renewal ships: the session payload carries an `iat`, and `ve
|
|
|
322
370
|
// 1. Parse cookie → { payloadB64, sigB64 }
|
|
323
371
|
// 2. Compute expected = hmacSha256(payloadB64, secret)
|
|
324
372
|
// 3. Constant-time compare expected vs. sigB64
|
|
325
|
-
// 4. Decode payload
|
|
373
|
+
// 4. Decode payload (rejects a superseded payload version)
|
|
326
374
|
// 5. Check exp (reject if exp < now)
|
|
327
|
-
// 6. Return the decoded
|
|
375
|
+
// 6. Return the decoded SubjectIdentity
|
|
328
376
|
```
|
|
329
377
|
|
|
330
378
|
All steps use constant-time comparisons to defeat timing attacks. Implementation lives in `@voltro/plugin-auth/session.ts` — read it for the full story.
|
|
@@ -674,21 +722,26 @@ Order matters: put the cheapest / most-common strategy first. When no strategy m
|
|
|
674
722
|
|
|
675
723
|
If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. `voltro check`'s `rbac/unguarded-mutation` reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
|
|
676
724
|
|
|
677
|
-
`resolveScopes` closes that. It runs after a strategy matches
|
|
725
|
+
`resolveScopes` closes that. It runs after a strategy matches, on every request, and resolves the caller's authority from whatever source you like:
|
|
678
726
|
|
|
679
727
|
```ts
|
|
680
728
|
// app.config.ts
|
|
681
729
|
export default defineApiConfig({
|
|
682
730
|
auth: {
|
|
683
731
|
resolveScopes: async (subject, { store }) => {
|
|
684
|
-
if (store === undefined) return
|
|
685
|
-
const role = await
|
|
686
|
-
return
|
|
732
|
+
if (store === undefined) return { kind: 'unavailable', reason: 'store not ready' }
|
|
733
|
+
const role = await readRole(store, subject.id)
|
|
734
|
+
return {
|
|
735
|
+
kind: 'authoritative',
|
|
736
|
+
scopes: role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read'],
|
|
737
|
+
}
|
|
687
738
|
},
|
|
688
739
|
},
|
|
689
740
|
})
|
|
690
741
|
```
|
|
691
742
|
|
|
743
|
+
**For a cookie-authenticated caller this is not a supplement — it is the only place authority comes from.** The session cookie carries a [`SubjectIdentity`](/docs/authentication/sessions) with no `scopes` field, so the strategy establishes nothing to add to. An app that gates on scopes and wires no resolver has callers with no scopes, which is the fail-closed direction.
|
|
744
|
+
|
|
692
745
|
The same authorization is now declarable on the descriptor:
|
|
693
746
|
|
|
694
747
|
```ts
|
|
@@ -699,16 +752,86 @@ export const payrollList = defineQuery({
|
|
|
699
752
|
})
|
|
700
753
|
```
|
|
701
754
|
|
|
702
|
-
|
|
755
|
+
### Three answers, and picking the right one is the point
|
|
756
|
+
|
|
757
|
+
| Return | Meaning | Effect |
|
|
758
|
+
|---|---|---|
|
|
759
|
+
| `['a', 'b']` — a bare array | **grant** (the shorthand; identical to `{ kind: 'grant', scopes }`) | unioned onto whatever the strategy established |
|
|
760
|
+
| `{ kind: 'authoritative', scopes }` | this resolver is the **complete** answer | replaces — anything not listed is removed |
|
|
761
|
+
| `{ kind: 'unavailable', reason }` | the authority source could not be reached | the request fails closed with `Unauthenticated`, and `reason` reaches `onStrategyFailed` |
|
|
762
|
+
|
|
763
|
+
An already-written resolver returning an array keeps its exact meaning, with no compiler error suggesting otherwise.
|
|
703
764
|
|
|
704
|
-
**
|
|
765
|
+
**Why this is three tags and not a boolean.** The hook used to be union-only, on the reasoning that a resolver which can silently subtract is a resolver whose bad day is indistinguishable from a policy decision — a DB blip that returns no rows would read as "this user has no permissions" and be applied as such. That hazard is real. But union-only also made narrowing impossible: removing a permission from a role had no effect on anyone already signed in, because the only hook that could have observed it was structurally forbidden from removing anything.
|
|
766
|
+
|
|
767
|
+
The empty array is what forced the split. Under a grant shape `[]` has to mean "no extra scopes"; under a replace shape it has to mean "no scopes at all"; and a failed lookup produces it under both. Three meanings, one value — so each got its own tag, and none of them is what you get by accident. **Return `unavailable`, not `[]`, when a lookup fails.**
|
|
768
|
+
|
|
769
|
+
**You get the app's DataStore.** A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is `undefined` while the store is still being built. Return `{ kind: 'unavailable', … }` then rather than guessing — under the old contract `[]` was the safe answer there, and it no longer is.
|
|
770
|
+
|
|
771
|
+
**Scopes only — never a Subject.** The hook cannot change `id` or `tenantId`: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface.
|
|
705
772
|
|
|
706
773
|
**It does not run for anonymous callers** — there is no identity to look a role up for.
|
|
707
774
|
|
|
708
|
-
|
|
775
|
+
### It also answers for durable workflows — read `ctx.origin` first
|
|
776
|
+
|
|
777
|
+
A workflow's start context persists the caller's **identity**, never their scopes: a `json()` column read back by another cluster runner days later is authority frozen and made durable, which is the session cookie's old defect one layer down. So a resumed run asks this resolver what its caller may do, on every execution attempt:
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
resolveScopes: async (subject, ctx) => {
|
|
781
|
+
if (ctx.origin === 'workflow') return rolesFromDb(subject, ctx.store)
|
|
782
|
+
return rolesFromHeader(ctx.headers) // the request path, unchanged
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
`ctx.origin` is `'request' | 'workflow'`. For `'workflow'` there **is** no request: `ctx.headers` is `{}` and `ctx.clientId` is `undefined` (its type is `number | undefined`, which is where a resolver reading it sees the compile error). Empty rather than fabricated — a resolver that needs headers has to be able to branch instead of silently receiving a bag that is always empty.
|
|
787
|
+
|
|
788
|
+
Three consequences worth stating plainly:
|
|
789
|
+
|
|
790
|
+
- **An app that wires no resolver gets workflow runs with no scopes.** Fail-closed, and the same default a cookie-authenticated request already has.
|
|
791
|
+
- **`{ kind: 'unavailable' }` fails the execution attempt**, rather than downgrading it. A run that quietly skips the branch it was not allowed to take is indistinguishable from one whose business logic said no. Fix the source and `voltro workflows redrive`.
|
|
792
|
+
- **A run with no recorded caller at all** — a bootstrap, or one whose start-context row aged out — runs as the framework's `SYSTEM_SUBJECT` and is *not* put through your resolver. It already states its own authority, and it carries `tenantId: null`, which the tenant scope reads as "every tenant". That is why the framework does not promote a *caller-owned* workflow to it: that would trade frozen authority for cross-tenant visibility.
|
|
793
|
+
|
|
794
|
+
`scopeCache` applies to the request path only. One resolution per run attempt is not a hot path, and a run that lasts days must not inherit a window sized for a burst of requests.
|
|
795
|
+
|
|
796
|
+
**Narrowing is audited.** `auth.onScopesNarrowed` is called whenever an authoritative resolution removed scopes the strategy had established, with `{ strategyId, subjectType, subjectId, removed, granted }`. It fires on a fresh resolution rather than on a cache replay, so a narrowed caller logs once per window instead of once per request.
|
|
709
797
|
|
|
710
798
|
Wired identically under `voltro dev` and `voltro serve`.
|
|
711
799
|
|
|
800
|
+
## The staleness window, and how to make it zero
|
|
801
|
+
|
|
802
|
+
The framework caches the resolution for you. The window is `auth.scopeCache`, and its default is **30 seconds** — deliberately the same window the [session-revocation check](/docs/authentication/sessions) already used, so the two per-request store reads miss together and there is *one* number to reason about rather than a second one you discover later.
|
|
803
|
+
|
|
804
|
+
That number is the lag between "an admin removes a role" and "every replica enforces it". Three ways to shorten it:
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
// app.config.ts
|
|
808
|
+
import { makeScopeCache, scopeCacheKey } from '@voltro/protocol'
|
|
809
|
+
|
|
810
|
+
// 1. Keep the default: a role change lands within 30s, everywhere. Nothing to write.
|
|
811
|
+
|
|
812
|
+
// 2. Resolve on every request. Staleness zero, one store read per request.
|
|
813
|
+
export default defineApiConfig({
|
|
814
|
+
auth: { resolveScopes, scopeCache: { ttlMs: 0 } },
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
// 3. Keep the cache AND get zero where it matters: hold the handle and drop the
|
|
818
|
+
// entry from whatever changes a role.
|
|
819
|
+
export const scopeCache = makeScopeCache({ ttlMs: 30_000 })
|
|
820
|
+
export default defineApiConfig({
|
|
821
|
+
auth: { resolveScopes, scopeCache },
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
// …in the mutation that grants or removes a role:
|
|
825
|
+
scopeCache.invalidate(scopeCacheKey(subject)) // instant on this process, ttlMs elsewhere
|
|
826
|
+
scopeCache.invalidateAll() // when a ROLE's definition changed, not one membership
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`VOLTRO_AUTH_SCOPE_CACHE_TTL_MS` overrides the default per deployment; an explicit `ttlMs` in code wins over the variable. Set `scopeCache: false` when your resolver reads anything beyond the subject's identity (a header, a request path) — the cache key is `type + tenantId + id` and nothing else, so a resolver that varies on something outside that key must not be cached.
|
|
830
|
+
|
|
831
|
+
`tenantId` is in the key on purpose: a user keeps their `id` across a tenant switch and their authority does not.
|
|
832
|
+
|
|
833
|
+
**The honest cost.** This is one extra store read per subject per window, on a path that was already doing one of exactly this shape for session revocation. An `unavailable` verdict is never cached — caching it would stretch one blip into a window of denials and hide the recovery.
|
|
834
|
+
|
|
712
835
|
## Wiring it into the app
|
|
713
836
|
|
|
714
837
|
The composed resolver becomes the runtime's `AuthMiddleware` — the per-request middleware that populates `SubjectService` so every handler can `yield* SubjectService` (or read `ctx.subject`). On a single-strategy password app you never touch this; the plugin wires `voltroPasswordStrategy` for you. You only assemble the chain explicitly when you add a second strategy:
|
|
@@ -723,7 +846,11 @@ export const AuthLayer = Layer.succeed(
|
|
|
723
846
|
)
|
|
724
847
|
```
|
|
725
848
|
|
|
726
|
-
|
|
849
|
+
Nothing runs *ahead* of the chain. A soft re-auth — `auth.signin` over the live WebSocket, a tenant switch — calls `bindConnectionCredential(clientId, { cookies })` from `@voltro/runtime`, which patches the connection's headers; the chain then runs on those headers exactly as it would for a fresh request. That's why `AuthStrategyInput` carries `clientId`.
|
|
850
|
+
|
|
851
|
+
This used to be a fast path that returned a stored `Subject` and skipped the chain, and the cost was everything downstream of the strategy: session revocation (it lives inside the strategy), `resolveScopes`, the scope cache, and the credential-expiry bound — for the whole life of the connection, with a 24-hour idle sweep as the only backstop. Patching the credential means a rebound connection has no property a reconnecting one lacks, because it is the same code path.
|
|
852
|
+
|
|
853
|
+
If you have no credential to present, that is the finding rather than a limitation: a caller that cannot authenticate a fresh request was holding authority no request could obtain.
|
|
727
854
|
|
|
728
855
|
## The built-in: `voltroPasswordStrategy`
|
|
729
856
|
|
|
@@ -826,6 +953,8 @@ Voltro ships first-party [strategy](/docs/authentication/strategies) plugins for
|
|
|
826
953
|
| `@voltro/plugin-auth-supabase` | Supabase Auth (GoTrue) | `supabase` |
|
|
827
954
|
| `@voltro/plugin-auth-oidc` | Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) | configurable via `id:` |
|
|
828
955
|
|
|
956
|
+
> **Looking for "Sign in with Google"?** That is a different plugin. Everything on this page is a *verifier* — it checks a JWT an enterprise IdP already issued. For consumer social login (Google / GitHub / Apple) where Voltro runs the whole redirect flow and mints your own session, use [`@voltro/plugin-auth-social`](/docs/plugins/auth-social). No identity vendor required.
|
|
957
|
+
|
|
829
958
|
Every plugin is a thin wrapper — ~70–110 lines each — over one shared engine: **`jwtBearerStrategy`**. They add nothing but provider-specific defaults (JWKS URL, cookie name, tenant-claim mapping). If your IdP isn't in the list AND doesn't expose a standard OIDC discovery document, point `jwtBearerStrategy` at its JWKS endpoint directly.
|
|
830
959
|
|
|
831
960
|
## The model: verify, don't redirect
|
|
@@ -911,21 +1040,25 @@ Tenant maps from `claims.org_id`. Verified claims arrive as `subject.metadata.cl
|
|
|
911
1040
|
|
|
912
1041
|
The strategy above is the *verify* model — WorkOS' JWT is the session. `@voltro/plugin-auth-workos` also exports two primitives for the **other** model, where WorkOS runs the login and your app mints its **own** session — an alternate front door *alongside* password sign-in, not a replacement session authority. This is how **Voltro Cloud** offers a "Sign in with WorkOS SSO" button next to email/password.
|
|
913
1042
|
|
|
914
|
-
- **`
|
|
915
|
-
- **`workosAuthenticateWithCode({ clientId, apiKey, code })`** — exchanges the `?code=` from the callback for the authenticated `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). **Server-only** — it carries the WorkOS **API key** (the OAuth client secret).
|
|
1043
|
+
- **`workosBeginLogin({ clientId, redirectUri })`** — returns `{ url, state, codeVerifier }`. `url` is the WorkOS hosted-login (AuthKit) URL to redirect the browser to; it always carries a freshly-minted CSRF `state` and a PKCE `code_challenge` (S256). Stash `state` and `codeVerifier` — the callback needs both.
|
|
1044
|
+
- **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** — verifies the state in constant time, then exchanges the `?code=` from the callback for the authenticated `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). **Server-only** — it carries the WorkOS **API key** (the OAuth client secret).
|
|
916
1045
|
|
|
917
1046
|
Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency), so they drop into any app's own routing + provisioning.
|
|
918
1047
|
|
|
1048
|
+
> **`state` and PKCE are not optional, and the check is not yours to remember.** `state` used to be a parameter you could pass — which meant the default flow had no CSRF token at all. Without it, an attacker who gets a victim's browser to hit your callback with an authorization code *they* obtained logs the victim into the **attacker's** account. It is now minted for you on every call, and the state comparison lives *inside* `workosAuthenticateWithCode`: a generated `state` that nothing verifies is worse than none, because a code review and a screenshot of the authorize URL then both read as "CSRF is handled". PKCE (S256) rides along for the same reason — the authorization code travels through the address bar and the referrer chain, and without a verifier whoever captures it can redeem it first.
|
|
1049
|
+
|
|
919
1050
|
### The flow
|
|
920
1051
|
|
|
921
1052
|
```text
|
|
922
1053
|
GET /auth/workos/login
|
|
923
|
-
─►
|
|
924
|
-
─►
|
|
1054
|
+
─► const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
|
|
1055
|
+
─► stash BOTH in short-lived HttpOnly cookies (CSRF nonce + PKCE verifier)
|
|
1056
|
+
─► 302 → url
|
|
925
1057
|
|
|
926
1058
|
GET /auth/workos/callback?code=…&state=…
|
|
927
|
-
─►
|
|
928
|
-
|
|
1059
|
+
─► workosAuthenticateWithCode({ clientId, apiKey, code,
|
|
1060
|
+
state, expectedState: <cookie>, codeVerifier: <cookie> })
|
|
1061
|
+
→ WorkosProfile (throws WorkosStateMismatchError → 400 on a mismatch)
|
|
929
1062
|
─► find-or-provision the local user + org (SHARED with password signup)
|
|
930
1063
|
─► issueSession(subject) → Set-Cookie: voltro:session=…
|
|
931
1064
|
─► 302 → dashboard
|
|
@@ -938,9 +1071,8 @@ The callback mints the **same** `voltro:session` as password sign-in, so everyth
|
|
|
938
1071
|
Wrap the two routes in a small **server-only** plugin and add it to `plugins` in your api's `app.config`:
|
|
939
1072
|
|
|
940
1073
|
```ts
|
|
941
|
-
import { randomUUID } from 'node:crypto'
|
|
942
1074
|
import { definePlugin } from '@voltro/protocol'
|
|
943
|
-
import {
|
|
1075
|
+
import { workosBeginLogin, workosAuthenticateWithCode, WorkosStateMismatchError } from '@voltro/plugin-auth-workos'
|
|
944
1076
|
import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'
|
|
945
1077
|
|
|
946
1078
|
export const workosAuthPlugin = () =>
|
|
@@ -956,11 +1088,20 @@ export const workosAuthPlugin = () =>
|
|
|
956
1088
|
if (!clientId || !redirectUri) {
|
|
957
1089
|
return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
|
|
958
1090
|
}
|
|
959
|
-
const state =
|
|
960
|
-
|
|
1091
|
+
const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
|
|
1092
|
+
// SameSite=Lax, not Strict: the return from WorkOS is a top-level GET,
|
|
1093
|
+
// which Lax allows and Strict would drop — and a dropped cookie now
|
|
1094
|
+
// FAILS the login instead of silently skipping the check.
|
|
1095
|
+
const attrs = 'Path=/; Max-Age=600; HttpOnly; SameSite=Lax'
|
|
961
1096
|
return {
|
|
962
1097
|
status: 302,
|
|
963
|
-
headers: {
|
|
1098
|
+
headers: {
|
|
1099
|
+
location: url,
|
|
1100
|
+
'set-cookie': [
|
|
1101
|
+
`workos-oauth-state=${state}; ${attrs}`,
|
|
1102
|
+
`workos-oauth-verifier=${codeVerifier}; ${attrs}`,
|
|
1103
|
+
].join('\n'),
|
|
1104
|
+
},
|
|
964
1105
|
body: '',
|
|
965
1106
|
}
|
|
966
1107
|
},
|
|
@@ -974,10 +1115,24 @@ export const workosAuthPlugin = () =>
|
|
|
974
1115
|
if (!clientId || !apiKey) {
|
|
975
1116
|
return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
|
|
976
1117
|
}
|
|
977
|
-
const
|
|
1118
|
+
const params = new URLSearchParams(req.query)
|
|
1119
|
+
const code = params.get('code')
|
|
978
1120
|
if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
|
|
979
|
-
|
|
980
|
-
|
|
1121
|
+
let profile
|
|
1122
|
+
try {
|
|
1123
|
+
profile = await workosAuthenticateWithCode({
|
|
1124
|
+
clientId, apiKey, code,
|
|
1125
|
+
state: params.get('state') ?? '',
|
|
1126
|
+
expectedState: readCookie(req.headers['cookie'], 'workos-oauth-state') ?? '',
|
|
1127
|
+
codeVerifier: readCookie(req.headers['cookie'], 'workos-oauth-verifier') ?? '',
|
|
1128
|
+
})
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
// Distinct from "WorkOS said no" — worth alerting on separately.
|
|
1131
|
+
if (err instanceof WorkosStateMismatchError) {
|
|
1132
|
+
return { status: 400, contentType: 'text/plain', body: 'Invalid or missing OAuth state (possible CSRF).' }
|
|
1133
|
+
}
|
|
1134
|
+
return { status: 502, contentType: 'text/plain', body: 'WorkOS code exchange failed.' }
|
|
1135
|
+
}
|
|
981
1136
|
// find-or-provision runs the SAME path as password signup:
|
|
982
1137
|
const { userId, orgId } = await findOrProvisionUser(profile)
|
|
983
1138
|
const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
|
|
@@ -995,6 +1150,8 @@ export const workosAuthPlugin = () =>
|
|
|
995
1150
|
})
|
|
996
1151
|
```
|
|
997
1152
|
|
|
1153
|
+
Do **not** keep a hand-rolled `state !== cookieState` check beside this — two expressions of one rule, and the copy is the one that goes stale.
|
|
1154
|
+
|
|
998
1155
|
The **first** SSO login provisions the local user + org (the same routine password signup uses); later logins find the existing user by email. That's why the routes live in *your app*, not in the plugin — the provisioning and session model are yours; the plugin only supplies the reusable OAuth primitives. Match the `voltro:session` cookie's attributes (e.g. `Secure`, `HttpOnly`) to your app's existing session cookie.
|
|
999
1156
|
|
|
1000
1157
|
### Configuration
|
|
@@ -1738,7 +1895,9 @@ const TenantSwitcher = () => {
|
|
|
1738
1895
|
}
|
|
1739
1896
|
```
|
|
1740
1897
|
|
|
1741
|
-
`POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket —
|
|
1898
|
+
`POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket — presents that cookie on the connection, so calls made afterwards resolve against the new tenant without a reconnect. No re-auth.
|
|
1899
|
+
|
|
1900
|
+
Calls, not subscriptions: a subscription already open on that connection was authorized under the previous cookie and keeps running until the client re-subscribes. Re-mount the subscribing components (or reload) if the switch has to change what they show.
|
|
1742
1901
|
|
|
1743
1902
|
## Anti-patterns
|
|
1744
1903
|
|
|
@@ -2001,6 +2160,99 @@ A subscription is a long-lived grant. Its guards — scope and relationship alik
|
|
|
2001
2160
|
— are re-evaluated before each delivery, so revoking a relation mid-session ends
|
|
2002
2161
|
the stream with the typed error instead of continuing to push rows.
|
|
2003
2162
|
|
|
2163
|
+
## Every procedure decides — `guards:` or `openAccess:`
|
|
2164
|
+
|
|
2165
|
+
A procedure that declares neither is **refused at boot**. `guards:` used to
|
|
2166
|
+
default to "allowed", so a discovered `*.query.ts` / `*.mutation.ts` /
|
|
2167
|
+
`*.action.ts` / `*.stream.ts` with no guard was callable by **any authenticated
|
|
2168
|
+
session** — the door defaulted open, and nothing said so.
|
|
2169
|
+
|
|
2170
|
+
The same rule covers **events**: a `*.event.ts` declaration is a wire surface
|
|
2171
|
+
too, and one that declares neither `guards:` nor `openAccess:` was silently
|
|
2172
|
+
subscribable by anyone who could open the socket. `defineEvent` takes the same
|
|
2173
|
+
two answers — see [Events](/docs/data/events).
|
|
2174
|
+
|
|
2175
|
+
There are exactly two answers, and they are not the same claim:
|
|
2176
|
+
|
|
2177
|
+
```ts
|
|
2178
|
+
export const invoiceList = defineQuery({
|
|
2179
|
+
name: 'invoices.list',
|
|
2180
|
+
guards: [{ scope: 'invoices:read' }], // the caller must hold a scope
|
|
2181
|
+
…
|
|
2182
|
+
})
|
|
2183
|
+
|
|
2184
|
+
export const pricing = defineQuery({
|
|
2185
|
+
name: 'pricing.current',
|
|
2186
|
+
openAccess: 'public pricing page — reads no caller data', // anyone may call it, and why
|
|
2187
|
+
…
|
|
2188
|
+
})
|
|
2189
|
+
```
|
|
2190
|
+
|
|
2191
|
+
`openAccess` takes a **reason, not a boolean**. That is the point of it: the
|
|
2192
|
+
reason is what a reviewer reads later, and it is what makes *"we decided this is
|
|
2193
|
+
open"* distinguishable from *"nobody looked"*. Without such a marker, the only
|
|
2194
|
+
way to satisfy a default-deny gate is to add a guard — so every genuinely open
|
|
2195
|
+
endpoint grows a scope every caller already holds. That rubber stamp reads as
|
|
2196
|
+
protection and enforces nothing, which is a worse state than the hole it
|
|
2197
|
+
replaces.
|
|
2198
|
+
|
|
2199
|
+
A procedure that only other **server** code calls wants neither: mark it
|
|
2200
|
+
`internal: true` and it leaves the wire entirely (no client-group entry, no
|
|
2201
|
+
route). `openAccess` on an internal procedure is refused — there is no wire
|
|
2202
|
+
surface to make a decision about.
|
|
2203
|
+
|
|
2204
|
+
### The gate
|
|
2205
|
+
|
|
2206
|
+
`voltro dev` and `voltro serve` run the same check at boot, and `voltro doctor`
|
|
2207
|
+
runs it as a preflight (non-zero exit; `accessDecisions` in `--json`). The
|
|
2208
|
+
refusal names **every** offending procedure with its file, because the fix is one
|
|
2209
|
+
pass over the whole list:
|
|
2210
|
+
|
|
2211
|
+
```
|
|
2212
|
+
[access] 3 wire-exposed procedures or events declare no access decision, and
|
|
2213
|
+
this app runs with `security.defaultDeny`:
|
|
2214
|
+
|
|
2215
|
+
invoices.list (query)
|
|
2216
|
+
src/api/invoices.query.ts
|
|
2217
|
+
…
|
|
2218
|
+
```
|
|
2219
|
+
|
|
2220
|
+
`voltro doctor` is the fastest way to get the list without a failed boot.
|
|
2221
|
+
|
|
2222
|
+
### Turning it off
|
|
2223
|
+
|
|
2224
|
+
One field, in `app.config.ts`, for the whole app:
|
|
2225
|
+
|
|
2226
|
+
```ts
|
|
2227
|
+
export default defineApiConfig({
|
|
2228
|
+
security: { defaultDeny: false },
|
|
2229
|
+
})
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
There is deliberately **no environment variable** for this. The only direction
|
|
2233
|
+
anyone reaches for is off, and an env var is how a security default becomes
|
|
2234
|
+
permanently off in one CI job with no diff to review. `voltro doctor` keeps
|
|
2235
|
+
listing the undecided procedures while it is off, marked advisory.
|
|
2236
|
+
|
|
2237
|
+
### What it does NOT cover — and what covers the rest
|
|
2238
|
+
|
|
2239
|
+
The **boot** gate reads **your app's own** discovered procedures and events. The
|
|
2240
|
+
procedures a plugin declares are the plugin author's decision and are not judged
|
|
2241
|
+
at boot — adopting this does not turn into a bug report against a plugin you
|
|
2242
|
+
installed.
|
|
2243
|
+
|
|
2244
|
+
They are not unpoliced, though: the same `security.defaultDeny` is also enforced
|
|
2245
|
+
**per request in the dispatch spine**, as defense in depth. A descriptor that
|
|
2246
|
+
reaches the wire with no access decision — a plugin route, a hand-bound
|
|
2247
|
+
descriptor — is refused with the same typed `ScopeError` before the transaction
|
|
2248
|
+
opens or any external I/O runs. Every first-party plugin route declares its own
|
|
2249
|
+
decision (a scope where a real authority exists — e.g. `billing:manage`,
|
|
2250
|
+
`storage:browse` — or `openAccess` with the reason on the routes that are
|
|
2251
|
+
self-scoped or anonymous-capable by design; each plugin's page lists them). A
|
|
2252
|
+
third-party plugin that declares neither on a route will see that route refused
|
|
2253
|
+
per-request under default-deny — the fix is one field on the route, exactly as
|
|
2254
|
+
for your own procedures.
|
|
2255
|
+
|
|
2004
2256
|
## Decide — `can` / `assertCan`
|
|
2005
2257
|
|
|
2006
2258
|
`can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
|
|
@@ -2252,6 +2504,36 @@ error frame, rather than delivering an empty snapshot — an empty snapshot on a
|
|
|
2252
2504
|
live subscription reads to a client as "every row you could see was just
|
|
2253
2505
|
deleted". Make sure your subscription error handling surfaces it.
|
|
2254
2506
|
|
|
2507
|
+
## A path that cannot apply the filter refuses, rather than serving rows
|
|
2508
|
+
|
|
2509
|
+
If a filter is registered and a scoped store is built without a resolved scope,
|
|
2510
|
+
the store **throws**. It does not fall back to unfiltered reads.
|
|
2511
|
+
|
|
2512
|
+
That fallback used to exist, and it is the reason this section does. A team
|
|
2513
|
+
measured four read paths returning every row of the tenant to every employee,
|
|
2514
|
+
on both transports, with `row filter registered` in the boot log and a green
|
|
2515
|
+
test suite. The registration lived in a module-local variable, so an app's
|
|
2516
|
+
`*.startup.tsx` and the framework's request pipeline could hold two different
|
|
2517
|
+
copies of it — the serve bundle inlines the framework while app modules stay
|
|
2518
|
+
external, and a strict pnpm tree can resolve one version into two directories.
|
|
2519
|
+
The pipeline read "no filter registered", which was indistinguishable from an
|
|
2520
|
+
app that has none, and served everything.
|
|
2521
|
+
|
|
2522
|
+
The registration is process-global for real now (`globalThis`, so every copy
|
|
2523
|
+
shares one cell), and the ambiguity that made the failure silent is gone: those
|
|
2524
|
+
two readings are different claims and only one of them is a decision.
|
|
2525
|
+
|
|
2526
|
+
If a code path is deliberately unfiltered — a system sweep, a migration, a
|
|
2527
|
+
seeding helper in a test — say so:
|
|
2528
|
+
|
|
2529
|
+
```ts
|
|
2530
|
+
wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_FILTER })
|
|
2531
|
+
```
|
|
2532
|
+
|
|
2533
|
+
`runAsSystem`, change-stream subscribers and the webhook trigger context already
|
|
2534
|
+
do this; a system subject bypasses row filters by design, and it is now written
|
|
2535
|
+
down rather than inferred from an absence.
|
|
2536
|
+
|
|
2255
2537
|
## What does *not* bypass it
|
|
2256
2538
|
|
|
2257
2539
|
| | Bypasses the row filter? |
|
|
@@ -276,6 +276,7 @@ Add a `cache` field to a `defineQuery` and the framework caches the query's **se
|
|
|
276
276
|
export const listTodos = defineQuery({
|
|
277
277
|
name: 'todos.listByTenant',
|
|
278
278
|
source: 'todos',
|
|
279
|
+
guards: [{ scope: 'todos:read' }], // who may call it
|
|
279
280
|
input: Schema.Struct({ done: Schema.optional(Schema.Boolean) }),
|
|
280
281
|
output: Todo,
|
|
281
282
|
cache: { ttl: '30s', swr: '5m', scope: 'subject' }, // tenant-filtered → subject
|
|
@@ -299,6 +300,8 @@ The matching `.<primitive>.server.ts` is **unchanged** — caching is a descript
|
|
|
299
300
|
export const listCountries = defineQuery({
|
|
300
301
|
name: 'reference.countries',
|
|
301
302
|
source: 'countries',
|
|
303
|
+
openAccess: 'a static country reference list — the same rows for every caller, '
|
|
304
|
+
+ 'no tenant rows and nothing caller-derived',
|
|
302
305
|
input: Schema.Void,
|
|
303
306
|
output: Country,
|
|
304
307
|
cache: { ttl: '1h', scope: 'global' },
|
|
@@ -308,12 +311,15 @@ export const listCountries = defineQuery({
|
|
|
308
311
|
export const last12Months = defineQuery({
|
|
309
312
|
name: 'globalStatistics.last12Months',
|
|
310
313
|
source: ['invoices', 'employees'],
|
|
314
|
+
guards: [{ scope: 'analytics:read' }],
|
|
311
315
|
input: Schema.Struct({}),
|
|
312
316
|
output: Stats,
|
|
313
317
|
cache: { ttl: '5m', scope: 'tenant' },
|
|
314
318
|
})
|
|
315
319
|
```
|
|
316
320
|
|
|
321
|
+
**`cache.scope` and the access decision are two questions, and they line up here by coincidence rather than by rule.** Every wire-exposed query must also declare `guards:`, `openAccess: '<reason>'` or `internal: true` or the boot refuses it — and the reasoning that made `scope: 'global'` correct for `reference.countries` (the same rows for everyone, nothing caller-derived) is the same reasoning that makes `openAccess` honest there. It does not generalise: a query can be perfectly cacheable per subject *and* need a scope to call, which is `todos.listByTenant` above. Decide them separately; see [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).
|
|
322
|
+
|
|
317
323
|
`'tenant'` exists because the other two were the only options and neither fit an org-wide figure: `'subject'` recomputes it per person — eighteen identical computations of the same nine-table statistic for an eighteen-person org — and `'global'` shares one entry across tenant boundaries, which for data derived from `subject.tenantId` is not a cache but a leak.
|
|
318
324
|
|
|
319
325
|
A caller with no `tenantId` (an anonymous or system subject) **bypasses** a `'tenant'` cache rather than sharing a null-keyed entry.
|