@voltro/cli 0.1.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 +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20236 -0
- package/bin/voltro.mjs +43 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +9 -0
- package/dist/commands-CfPH2Wf4.js +18061 -0
- package/dist/frameworkInspectState-CX2250XB.js +86 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +5 -0
- package/dist/inspectState.d.ts +116 -0
- package/dist/inspectState.js +2 -0
- package/dist/startup.d.ts +26 -0
- package/dist/startup.js +2 -0
- package/dist/startupRunner-CRhuUl91.js +71 -0
- package/package.json +88 -0
- package/templates/AGENTS.core.md +258 -0
- package/templates/AGENTS.md +351 -0
- package/templates/agent-docs/_index.md +93 -0
- package/templates/agent-docs/_manifest.json +655 -0
- package/templates/agent-docs/ai.md +1845 -0
- package/templates/agent-docs/authentication.md +1788 -0
- package/templates/agent-docs/caching.md +624 -0
- package/templates/agent-docs/cli.md +1650 -0
- package/templates/agent-docs/configuration.md +295 -0
- package/templates/agent-docs/data.md +2172 -0
- package/templates/agent-docs/database/advancedqueries.md +1583 -0
- package/templates/agent-docs/database/columntypes.md +1200 -0
- package/templates/agent-docs/database/hosting.md +881 -0
- package/templates/agent-docs/database/migrations.md +2938 -0
- package/templates/agent-docs/database/misc.md +270 -0
- package/templates/agent-docs/database/overview.md +108 -0
- package/templates/agent-docs/database/querying.md +1622 -0
- package/templates/agent-docs/database/scaling.md +331 -0
- package/templates/agent-docs/database/schema.md +1458 -0
- package/templates/agent-docs/database/seedsdialects.md +1235 -0
- package/templates/agent-docs/database/transactions.md +285 -0
- package/templates/agent-docs/deployment.md +999 -0
- package/templates/agent-docs/internationalization.md +359 -0
- package/templates/agent-docs/introduction.md +438 -0
- package/templates/agent-docs/multi-tenancy.md +610 -0
- package/templates/agent-docs/observability.md +350 -0
- package/templates/agent-docs/plugins.md +1273 -0
- package/templates/agent-docs/reference.md +978 -0
- package/templates/agent-docs/routing.md +1553 -0
- package/templates/agent-docs/scheduling.md +666 -0
- package/templates/agent-docs/schema-driven-ui.md +607 -0
- package/templates/agent-docs/security.md +42 -0
- package/templates/agent-docs/templates/apibackends.md +3214 -0
- package/templates/agent-docs/templates/appshells.md +2062 -0
- package/templates/agent-docs/templates/custom.md +128 -0
- package/templates/agent-docs/templates/overview.md +122 -0
- package/templates/agent-docs/templates/serverless.md +315 -0
- package/templates/agent-docs/testing.md +376 -0
- package/templates/agent-docs/workflows.md +1351 -0
- package/templates/apps/api-ai/README.md +105 -0
- package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
- package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
- package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
- package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
- package/templates/apps/api-ai/app.config.ts +57 -0
- package/templates/apps/api-ai/database/schema.ts +67 -0
- package/templates/apps/api-ai/package.json +27 -0
- package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
- package/templates/apps/api-ai/template.json +6 -0
- package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
- package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
- package/templates/apps/api-ai/tsconfig.json +5 -0
- package/templates/apps/api-auth/.env +17 -0
- package/templates/apps/api-auth/README.md +109 -0
- package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
- package/templates/apps/api-auth/actions/me.action.ts +19 -0
- package/templates/apps/api-auth/app.config.ts +59 -0
- package/templates/apps/api-auth/database/schema.ts +26 -0
- package/templates/apps/api-auth/package.json +28 -0
- package/templates/apps/api-auth/template.json +6 -0
- package/templates/apps/api-auth/tests/me.test.ts +37 -0
- package/templates/apps/api-auth/tsconfig.json +5 -0
- package/templates/apps/api-backend/README.md +25 -0
- package/templates/apps/api-backend/app.config.ts +39 -0
- package/templates/apps/api-backend/database/schema.ts +57 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend/package.json +28 -0
- package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend/template.json +6 -0
- package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend/tsconfig.json +5 -0
- package/templates/apps/api-backend-deactivation/README.md +49 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
- package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
- package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
- package/templates/apps/api-backend-deactivation/package.json +28 -0
- package/templates/apps/api-backend-deactivation/template.json +6 -0
- package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
- package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
- package/templates/apps/api-backend-mail/README.md +39 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
- package/templates/apps/api-backend-mail/app.config.ts +34 -0
- package/templates/apps/api-backend-mail/database/schema.ts +57 -0
- package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mail/package.json +30 -0
- package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mail/template.json +6 -0
- package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mail/tsconfig.json +5 -0
- package/templates/apps/api-backend-mariadb/.env.example +35 -0
- package/templates/apps/api-backend-mariadb/README.md +31 -0
- package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
- package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mariadb/package.json +30 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mariadb/template.json +6 -0
- package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/README.md +86 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
- package/templates/apps/api-backend-storage/app.config.ts +38 -0
- package/templates/apps/api-backend-storage/database/schema.ts +57 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-storage/package.json +27 -0
- package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-storage/template.json +6 -0
- package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-storage/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/.env +17 -0
- package/templates/apps/api-data-advanced/README.md +88 -0
- package/templates/apps/api-data-advanced/app.config.ts +56 -0
- package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
- package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
- package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
- package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
- package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
- package/templates/apps/api-data-advanced/database/index.ts +23 -0
- package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
- package/templates/apps/api-data-advanced/package.json +28 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
- package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
- package/templates/apps/api-data-advanced/template.json +6 -0
- package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
- package/templates/apps/api-data-advanced/tsconfig.json +5 -0
- package/templates/apps/api-durable/README.md +81 -0
- package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
- package/templates/apps/api-durable/app.config.ts +41 -0
- package/templates/apps/api-durable/database/schema.ts +65 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
- package/templates/apps/api-durable/package.json +28 -0
- package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
- package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
- package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
- package/templates/apps/api-durable/template.json +6 -0
- package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
- package/templates/apps/api-durable/tsconfig.json +5 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
- package/templates/apps/api-feature-flags/README.md +63 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
- package/templates/apps/api-feature-flags/app.config.ts +41 -0
- package/templates/apps/api-feature-flags/database/schema.ts +37 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
- package/templates/apps/api-feature-flags/package.json +29 -0
- package/templates/apps/api-feature-flags/template.json +6 -0
- package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
- package/templates/apps/api-feature-flags/tsconfig.json +5 -0
- package/templates/apps/api-governance/.env +4 -0
- package/templates/apps/api-governance/README.md +61 -0
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
- package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
- package/templates/apps/api-governance/app.config.ts +35 -0
- package/templates/apps/api-governance/database/schema.ts +41 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
- package/templates/apps/api-governance/package.json +29 -0
- package/templates/apps/api-governance/template.json +6 -0
- package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
- package/templates/apps/api-governance/tsconfig.json +5 -0
- package/templates/apps/api-kv/README.md +100 -0
- package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
- package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
- package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
- package/templates/apps/api-kv/app.config.ts +48 -0
- package/templates/apps/api-kv/database/schema.ts +62 -0
- package/templates/apps/api-kv/package.json +28 -0
- package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
- package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
- package/templates/apps/api-kv/template.json +6 -0
- package/templates/apps/api-kv/tests/sync.test.ts +103 -0
- package/templates/apps/api-kv/tsconfig.json +5 -0
- package/templates/apps/api-moderation/README.md +46 -0
- package/templates/apps/api-moderation/app.config.ts +33 -0
- package/templates/apps/api-moderation/database/schema.ts +45 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
- package/templates/apps/api-moderation/package.json +29 -0
- package/templates/apps/api-moderation/template.json +6 -0
- package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
- package/templates/apps/api-moderation/tsconfig.json +5 -0
- package/templates/apps/api-observability/README.md +106 -0
- package/templates/apps/api-observability/app.config.ts +32 -0
- package/templates/apps/api-observability/database/schema.ts +38 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
- package/templates/apps/api-observability/package.json +29 -0
- package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
- package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
- package/templates/apps/api-observability/template.json +6 -0
- package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
- package/templates/apps/api-observability/tsconfig.json +5 -0
- package/templates/apps/api-ratelimit/README.md +49 -0
- package/templates/apps/api-ratelimit/app.config.ts +42 -0
- package/templates/apps/api-ratelimit/database/schema.ts +37 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
- package/templates/apps/api-ratelimit/package.json +29 -0
- package/templates/apps/api-ratelimit/template.json +6 -0
- package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
- package/templates/apps/api-ratelimit/tsconfig.json +5 -0
- package/templates/apps/api-rbac/README.md +59 -0
- package/templates/apps/api-rbac/app.config.ts +48 -0
- package/templates/apps/api-rbac/database/schema.ts +37 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
- package/templates/apps/api-rbac/package.json +29 -0
- package/templates/apps/api-rbac/template.json +6 -0
- package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
- package/templates/apps/api-rbac/tsconfig.json +5 -0
- package/templates/apps/api-rest/README.md +85 -0
- package/templates/apps/api-rest/app.config.ts +70 -0
- package/templates/apps/api-rest/database/schema.ts +52 -0
- package/templates/apps/api-rest/lib/product.ts +30 -0
- package/templates/apps/api-rest/package.json +27 -0
- package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
- package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
- package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
- package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
- package/templates/apps/api-rest/template.json +6 -0
- package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
- package/templates/apps/api-rest/tsconfig.json +5 -0
- package/templates/apps/api-saas/README.md +106 -0
- package/templates/apps/api-saas/app.config.ts +51 -0
- package/templates/apps/api-saas/database/schema.ts +50 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas/package.json +31 -0
- package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
- package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas/template.json +6 -0
- package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
- package/templates/apps/api-saas/tsconfig.json +5 -0
- package/templates/apps/api-search/README.md +68 -0
- package/templates/apps/api-search/app.config.ts +31 -0
- package/templates/apps/api-search/database/schema.ts +47 -0
- package/templates/apps/api-search/lib/search.ts +27 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
- package/templates/apps/api-search/package.json +29 -0
- package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
- package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
- package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
- package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
- package/templates/apps/api-search/template.json +6 -0
- package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
- package/templates/apps/api-search/tsconfig.json +5 -0
- package/templates/apps/api-versioning/README.md +51 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
- package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
- package/templates/apps/api-versioning/app.config.ts +24 -0
- package/templates/apps/api-versioning/database/schema.ts +38 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
- package/templates/apps/api-versioning/package.json +29 -0
- package/templates/apps/api-versioning/template.json +6 -0
- package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
- package/templates/apps/api-versioning/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/.env +6 -0
- package/templates/apps/api-webhooks/README.md +106 -0
- package/templates/apps/api-webhooks/app.config.ts +16 -0
- package/templates/apps/api-webhooks/database/schema.ts +49 -0
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
- package/templates/apps/api-webhooks/package.json +28 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
- package/templates/apps/api-webhooks/template.json +6 -0
- package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
- package/templates/apps/api-webhooks/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
- package/templates/apps/changelog/README.md +77 -0
- package/templates/apps/changelog/app.config.ts +27 -0
- package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
- package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
- package/templates/apps/changelog/package.json +30 -0
- package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
- package/templates/apps/changelog/src/globals.css +66 -0
- package/templates/apps/changelog/src/globals.d.ts +16 -0
- package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
- package/templates/apps/changelog/src/lib/locale.ts +55 -0
- package/templates/apps/changelog/src/lib/releases.ts +21 -0
- package/templates/apps/changelog/src/locales/de.ts +22 -0
- package/templates/apps/changelog/src/locales/en.ts +30 -0
- package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
- package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
- package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
- package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
- package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
- package/templates/apps/changelog/src/pages/index.tsx +55 -0
- package/templates/apps/changelog/src/pages/layout.tsx +84 -0
- package/templates/apps/changelog/template.json +6 -0
- package/templates/apps/changelog/tsconfig.json +11 -0
- package/templates/apps/edge-functions/README.md +55 -0
- package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
- package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
- package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
- package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
- package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
- package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
- package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
- package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
- package/templates/apps/edge-functions/package.json +21 -0
- package/templates/apps/edge-functions/template.json +6 -0
- package/templates/apps/edge-functions/tsconfig.json +10 -0
- package/templates/apps/frontend-admin/README.md +40 -0
- package/templates/apps/frontend-admin/app.config.ts +37 -0
- package/templates/apps/frontend-admin/package.json +30 -0
- package/templates/apps/frontend-admin/src/config.ts +8 -0
- package/templates/apps/frontend-admin/src/globals.css +76 -0
- package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
- package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
- package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
- package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
- package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
- package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
- package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
- package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
- package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
- package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-admin/template.json +6 -0
- package/templates/apps/frontend-admin/tsconfig.json +11 -0
- package/templates/apps/frontend-app/README.md +98 -0
- package/templates/apps/frontend-app/app.config.ts +43 -0
- package/templates/apps/frontend-app/package.json +30 -0
- package/templates/apps/frontend-app/src/locales/de.ts +36 -0
- package/templates/apps/frontend-app/src/locales/en.ts +43 -0
- package/templates/apps/frontend-app/src/locales/index.ts +15 -0
- package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
- package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
- package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
- package/templates/apps/frontend-app/template.json +6 -0
- package/templates/apps/frontend-app/tsconfig.json +11 -0
- package/templates/apps/frontend-blank/README.md +18 -0
- package/templates/apps/frontend-blank/app.config.ts +29 -0
- package/templates/apps/frontend-blank/package.json +29 -0
- package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
- package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
- package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
- package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
- package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
- package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
- package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
- package/templates/apps/frontend-blank/template.json +6 -0
- package/templates/apps/frontend-blank/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/README.md +65 -0
- package/templates/apps/frontend-contact/app.config.ts +25 -0
- package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
- package/templates/apps/frontend-contact/package.json +33 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
- package/templates/apps/frontend-contact/src/config.ts +12 -0
- package/templates/apps/frontend-contact/src/globals.css +84 -0
- package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
- package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
- package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
- package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
- package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
- package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
- package/templates/apps/frontend-contact/template.json +6 -0
- package/templates/apps/frontend-contact/tsconfig.json +11 -0
- package/templates/apps/frontend-dashboard/README.md +54 -0
- package/templates/apps/frontend-dashboard/app.config.ts +39 -0
- package/templates/apps/frontend-dashboard/package.json +29 -0
- package/templates/apps/frontend-dashboard/src/config.ts +8 -0
- package/templates/apps/frontend-dashboard/src/globals.css +72 -0
- package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
- package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
- package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
- package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
- package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-dashboard/template.json +6 -0
- package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
- package/templates/apps/frontend-docs/README.md +19 -0
- package/templates/apps/frontend-docs/app.config.ts +25 -0
- package/templates/apps/frontend-docs/package.json +29 -0
- package/templates/apps/frontend-docs/src/globals.css +32 -0
- package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
- package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
- package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
- package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
- package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-docs/template.json +6 -0
- package/templates/apps/frontend-docs/tsconfig.json +11 -0
- package/templates/apps/frontend-i18n/README.md +61 -0
- package/templates/apps/frontend-i18n/app.config.ts +33 -0
- package/templates/apps/frontend-i18n/package.json +28 -0
- package/templates/apps/frontend-i18n/src/globals.css +46 -0
- package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
- package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
- package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
- package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
- package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
- package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
- package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
- package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
- package/templates/apps/frontend-i18n/template.json +6 -0
- package/templates/apps/frontend-i18n/tsconfig.json +11 -0
- package/templates/apps/frontend-landing/README.md +17 -0
- package/templates/apps/frontend-landing/app.config.ts +25 -0
- package/templates/apps/frontend-landing/package.json +29 -0
- package/templates/apps/frontend-landing/src/globals.css +23 -0
- package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
- package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
- package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
- package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
- package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-landing/template.json +6 -0
- package/templates/apps/frontend-landing/tsconfig.json +11 -0
- package/templates/apps/frontend-spa/README.md +45 -0
- package/templates/apps/frontend-spa/app.config.ts +27 -0
- package/templates/apps/frontend-spa/package.json +29 -0
- package/templates/apps/frontend-spa/src/globals.css +84 -0
- package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
- package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
- package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
- package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
- package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
- package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-spa/template.json +6 -0
- package/templates/apps/frontend-spa/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr/README.md +68 -0
- package/templates/apps/frontend-ssr/app.config.ts +32 -0
- package/templates/apps/frontend-ssr/package.json +29 -0
- package/templates/apps/frontend-ssr/src/globals.css +67 -0
- package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
- package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
- package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
- package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
- package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
- package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
- package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
- package/templates/apps/frontend-ssr/template.json +6 -0
- package/templates/apps/frontend-ssr/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr-api/README.md +50 -0
- package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
- package/templates/apps/frontend-ssr-api/package.json +30 -0
- package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
- package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
- package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
- package/templates/apps/frontend-ssr-api/template.json +6 -0
- package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
- package/templates/apps/frontend-static-blog/README.md +49 -0
- package/templates/apps/frontend-static-blog/app.config.ts +34 -0
- package/templates/apps/frontend-static-blog/package.json +28 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
- package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
- package/templates/apps/frontend-static-blog/src/globals.css +75 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
- package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
- package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
- package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
- package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
- package/templates/apps/frontend-static-blog/template.json +6 -0
- package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
- package/templates/baselines/bare/.env.example +37 -0
- package/templates/baselines/bare/README.md +47 -0
- package/templates/baselines/bare/baseline.json +31 -0
- package/templates/baselines/bare/deploy/README.md +43 -0
- package/templates/baselines/bare/deploy/voltro.service.example +36 -0
- package/templates/baselines/compose/.env.example +46 -0
- package/templates/baselines/compose/README.md +69 -0
- package/templates/baselines/compose/baseline.json +51 -0
- package/templates/baselines/compose/docker/.dockerignore +38 -0
- package/templates/baselines/compose/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose/docker-compose.dev.yml +89 -0
- package/templates/baselines/compose/docker-compose.prod.yml +87 -0
- package/templates/baselines/compose/docker-compose.yml +41 -0
- package/templates/baselines/compose-mariadb/.env.example +57 -0
- package/templates/baselines/compose-mariadb/README.md +78 -0
- package/templates/baselines/compose-mariadb/baseline.json +51 -0
- package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
- package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
- package/templates/baselines/helm/.env.example +39 -0
- package/templates/baselines/helm/README.md +98 -0
- package/templates/baselines/helm/baseline.json +53 -0
- package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
- package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
- package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
- package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
- package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
- package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
- package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
- package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
- package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
- package/templates/baselines/helm/deploy/README.md +94 -0
- package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
|
@@ -0,0 +1,1788 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
> How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/authentication/overview.md -->
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
_How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol._
|
|
13
|
+
|
|
14
|
+
Voltro's auth story ships as a plugin: `@voltro/plugin-auth`. It's server-side primitives + client-side React glue, no third-party redirect dance, no managed-service dependency.
|
|
15
|
+
|
|
16
|
+
**Built-in (password):** password sign-up / sign-in, cookie-based sessions (multi-key rotation + sliding-window auto-renewal), rehash-on-verify, magic-link + password-reset flows, passkeys (WebAuthn), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, the typed `Subject`, TOTP/MFA enrolment, and a `SubjectProvider` React context + passkey ceremony helper (from the browser-safe `@voltro/plugin-auth/web` subpath). The single `authRoutesPlugin()` mounts every route under `/auth`.
|
|
17
|
+
|
|
18
|
+
**Pluggable identity:** auth is a [strategy](/docs/authentication/strategies) protocol, not just the password flow. First-party plugins ship for [WorkOS, Kinde, Clerk, Auth0, Supabase, and generic OIDC](/docs/authentication/external-idp); the shared `jwtBearerStrategy` covers any other OIDC provider; API-key callers use `apiKeyStrategy` from `@voltro/protocol/apikey`; you can write your own. Strategies stack, so you can run password + an external IdP side by side.
|
|
19
|
+
|
|
20
|
+
## The pieces
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
┌───────────────────────────────────────────────────────────────┐
|
|
24
|
+
│ Browser │
|
|
25
|
+
│ POST /auth/signin ────────┐ │
|
|
26
|
+
│ Cookie: voltro:session=... │ │
|
|
27
|
+
└───────────────────────────────┬─┘ │
|
|
28
|
+
│ Set-Cookie (HttpOnly, Secure)│
|
|
29
|
+
▼ │
|
|
30
|
+
┌───────────────────────────────────────────────────────────────┐
|
|
31
|
+
│ api app │
|
|
32
|
+
│ handleSignIn → verifyPassword → issueSession │
|
|
33
|
+
│ readSession → HMAC verify → Subject │
|
|
34
|
+
└───────────────────────────────────────────────────────────────┘
|
|
35
|
+
▲
|
|
36
|
+
│ ctx.subject (typed)
|
|
37
|
+
│
|
|
38
|
+
┌───────────────────────────────────────────────────────────────┐
|
|
39
|
+
│ Every query / mutation / workflow │
|
|
40
|
+
│ ctx.subject.id (string on a user; null on anonymous) │
|
|
41
|
+
│ ctx.subject.tenantId (string on a user; string|null anon) │
|
|
42
|
+
└───────────────────────────────────────────────────────────────┘
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What's in this section
|
|
46
|
+
|
|
47
|
+
- [Passwords](/docs/authentication/passwords) — hashing (scrypt), verification, why not bcrypt/argon2
|
|
48
|
+
- [Sessions](/docs/authentication/sessions) — issuing, verifying, the signed-cookie payload
|
|
49
|
+
- [The Subject](/docs/authentication/subject) — what's in `ctx.subject`, anonymous fallback
|
|
50
|
+
- [Auth strategies](/docs/authentication/strategies) — the `AuthStrategy` protocol, the resolver chain, writing your own
|
|
51
|
+
- [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`
|
|
52
|
+
- [HTTP handlers](/docs/authentication/handlers) — `handleSignIn`, `handleSignUp`, `handleSignOut`
|
|
53
|
+
- [User stores](/docs/authentication/user-stores) — `memoryUserStore`, `postgresUserStore`, custom backends
|
|
54
|
+
- [React on the web side](/docs/authentication/react) — `SubjectProvider`, `useSubject`, `RequireAuth`
|
|
55
|
+
- [Cookie security](/docs/authentication/cookies) — the production checklist
|
|
56
|
+
|
|
57
|
+
## Schema tables
|
|
58
|
+
|
|
59
|
+
Two tables ship from `@voltro/plugin-auth/schema`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { usersTable, sessionsTable } from '@voltro/plugin-auth/schema'
|
|
63
|
+
|
|
64
|
+
export const users = usersTable
|
|
65
|
+
export const sessions = sessionsTable
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`postgresUserStore` reads + writes the `users` table only — sessions are stateless signed cookies, so the default flow never touches `sessionsTable`. It ships for apps that opt into DB-backed session enumeration / "sign out other devices"; the stateless default leaves it empty. Don't redeclare these yourself — extend via additional columns in a sibling table linked by `userId`, not by modifying these.
|
|
69
|
+
|
|
70
|
+
## Why password is the default (and IdPs are strategies, not the base)
|
|
71
|
+
|
|
72
|
+
You *can* run Clerk / WorkOS / Kinde — they're [first-party strategies](/docs/authentication/external-idp). But the built-in password flow is the default, and external IdPs plug in *underneath* the framework's own `Subject`, because:
|
|
73
|
+
|
|
74
|
+
- **Cookie sovereignty.** With the built-in flow your app owns the session — no redirect to a third-party SSO domain, no managed-service dependency for a feature every B2B SaaS needs.
|
|
75
|
+
- **Multi-tenant model is yours.** Even when an external IdP authenticates the user, `tenantId` and the typed `Subject` stay the framework's, not the vendor's. The IdP's claims ride along under `metadata.claims`; they don't replace the model. See [external IdPs](/docs/authentication/external-idp).
|
|
76
|
+
- **Self-host friendly.** Password auth needs nothing external. Reach for an IdP when you want SSO/SCIM/enterprise federation, not because the framework forces a service on you.
|
|
77
|
+
|
|
78
|
+
Strategies [stack](/docs/authentication/strategies), so adopting an IdP is additive — keep password sessions working while new sign-ups flow through the IdP, no big-bang cutover.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
<!-- source: en/authentication/passwords.md -->
|
|
85
|
+
## Passwords
|
|
86
|
+
|
|
87
|
+
_scrypt-based password hashing, verification, timing-oracle defence, and why not bcrypt or argon2._
|
|
88
|
+
|
|
89
|
+
`@voltro/plugin-auth/password` exposes two functions: `hashPassword(plaintext)` and `verifyPassword(plaintext, hash)`. Both return **Effects**, not Promises — `hashPassword` fails with a typed `PasswordEmptyError | PasswordHashError`; `verifyPassword` returns `Effect<boolean>` (never fails — see below). Compose them in `Effect.gen`, or `Effect.runPromise` them at the edge.
|
|
90
|
+
|
|
91
|
+
## Hashing on sign-up
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { hashPassword } from '@voltro/plugin-auth'
|
|
95
|
+
import { Effect } from 'effect'
|
|
96
|
+
|
|
97
|
+
const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
|
|
98
|
+
// → 'scrypt$16384$8$1$<saltB64>$<derivedB64>'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Store `hash` in the `passwordHash` column of your users table. **Never** store the plaintext.
|
|
102
|
+
|
|
103
|
+
## Verifying on sign-in
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { verifyPassword } from '@voltro/plugin-auth'
|
|
107
|
+
import { Effect } from 'effect'
|
|
108
|
+
|
|
109
|
+
const ok = await Effect.runPromise(verifyPassword(input.password, user.passwordHash))
|
|
110
|
+
if (!ok) throw new Unauthorised({})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`verifyPassword` compares the derived key with `timingSafeEqual`, so timing-based guessing of a correct prefix is mitigated. It also returns `Effect<boolean>` with **no failure channel**: a malformed hash, a parse error, or a scrypt error all collapse to `false` (via `Effect.catchAll`). That's deliberate — surfacing "malformed" vs "mismatched" would let an attacker fingerprint stored-hash structure. You only ever branch on the boolean.
|
|
114
|
+
|
|
115
|
+
## Why scrypt, not bcrypt / argon2
|
|
116
|
+
|
|
117
|
+
We picked scrypt deliberately. It's:
|
|
118
|
+
|
|
119
|
+
| Function | Native dep? | Memory-hard? | OWASP-recommended? |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| **scrypt** | ✗ (in `node:crypto`) | ✓ | ✓ |
|
|
122
|
+
| bcrypt | ✓ (`bcrypt` npm pkg) | ✗ | partially |
|
|
123
|
+
| argon2 | ✓ (`argon2` npm pkg) | ✓ | ✓ (preferred) |
|
|
124
|
+
| pbkdf2 | ✗ | ✗ | only with high iteration count |
|
|
125
|
+
|
|
126
|
+
- **No native dep** — `node:crypto.scrypt` is built into Node. argon2 needs a C addon that breaks on Alpine / Bun / serverless runtimes regularly.
|
|
127
|
+
- **Memory-hard** — defeats GPU brute-forcing the way bcrypt + pbkdf2 don't.
|
|
128
|
+
- **OWASP-acceptable** — not their top pick (argon2id is) but explicitly listed as safe.
|
|
129
|
+
|
|
130
|
+
## Cost parameters
|
|
131
|
+
|
|
132
|
+
The framework uses scrypt with these defaults:
|
|
133
|
+
|
|
134
|
+
| Param | Value | Effect |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `N` | `2^14` = 16384 | CPU + memory cost |
|
|
137
|
+
| `r` | 8 | block size |
|
|
138
|
+
| `p` | 1 | parallelism |
|
|
139
|
+
|
|
140
|
+
At those parameters a single hash takes roughly ~50ms on a modern laptop — slow enough to make offline brute force expensive, fast enough that sign-in feels instant. The cost is roughly equivalent to bcrypt's `$2y$10$`. The parameters are encoded inline in the hash string (`scrypt$16384$8$1$…`), so a future cost bump can decode older hashes and re-encode on the next sign-in.
|
|
141
|
+
|
|
142
|
+
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
|
+
|
|
144
|
+
## Timing-oracle defence
|
|
145
|
+
|
|
146
|
+
A naive sign-in implementation leaks "is this email registered?" via response time:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// BAD — fast 401 for unknown email, slow 401 for wrong password
|
|
150
|
+
const user = await store.findByEmail(input.email)
|
|
151
|
+
if (!user) return error(401)
|
|
152
|
+
if (!await verifyPassword(input.password, user.passwordHash)) return error(401)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The framework's `handleSignIn` always runs `verifyPassword` (with a dummy hash for the unknown-email case) so response times are uniform.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { handleSignIn } from '@voltro/plugin-auth'
|
|
159
|
+
// handleSignIn internally (Effect-gen):
|
|
160
|
+
// const user = yield* store.findByEmail(email)
|
|
161
|
+
// const ok = user
|
|
162
|
+
// ? yield* verifyPassword(password, user.passwordHash)
|
|
163
|
+
// : (yield* verifyPassword(password, freshDecoyHash), false)
|
|
164
|
+
// if (!user || !ok) return json(401, { error: 'invalid credentials' })
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Use `handleSignIn` instead of rolling your own — the timing-oracle gap is the kind of subtle bug that hides for years. Note it returns a `401 HandlerResult`, it does not throw a domain error.
|
|
168
|
+
|
|
169
|
+
## Rehashing on parameter bump
|
|
170
|
+
|
|
171
|
+
When the framework updates the default cost parameters, existing hashes stay valid — `verifyPassword` reads `N`/`r`/`p` from the stored hash string itself (they're encoded inline as `scrypt$<N>$<r>$<p>$…`). Rehash-on-verify ships: `needsRehash(stored)` reports whether a hash is below the current cost, and `verifyPasswordWithRehash(plaintext, stored)` returns `{ valid, rehash? }` — when the password matches an under-cost hash, `rehash` is a freshly-minted replacement. `handleSignIn` wires this through `UserStore.updatePassword`, so a user's stored hash silently strengthens on their next login, no forced reset and no backfill.
|
|
172
|
+
|
|
173
|
+
## Password policy
|
|
174
|
+
|
|
175
|
+
The framework doesn't enforce a policy at the hash layer — that's a UX decision. Enforce at the sign-up handler:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { Schema } from 'effect'
|
|
179
|
+
|
|
180
|
+
const PasswordSchema = Schema.String.pipe(
|
|
181
|
+
Schema.minLength(12), // OWASP minimum for non-2FA
|
|
182
|
+
Schema.pattern(/[a-z]/),
|
|
183
|
+
Schema.pattern(/[A-Z]/),
|
|
184
|
+
Schema.pattern(/[0-9]/),
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
OWASP's current guidance: minimum length 8 (12 preferred), no upper-cap below 64, no required character classes if length ≥ 12. `handleSignUp` itself only enforces the 8-character floor; richer policy is yours to add at the route. Checking passwords against the [HIBP breach corpus](https://haveibeenpwned.com/API/v3) is a good idea — wire the k-anonymity range API into your sign-up route yourself; the plugin ships no HIBP helper.
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
<!-- source: en/authentication/sessions.md -->
|
|
195
|
+
## Sessions
|
|
196
|
+
|
|
197
|
+
_How sessions get issued, signed, and verified — the HMAC-SHA256 cookie payload format. For cookie attributes see Cookie security._
|
|
198
|
+
|
|
199
|
+
A Voltro session is a signed cookie. No server-side session store, no Redis dependency. The cookie *is* the session.
|
|
200
|
+
|
|
201
|
+
## Format
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
voltro:session=<base64url(payload)>.<base64url(signature)>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Where:
|
|
208
|
+
|
|
209
|
+
- `payload` is JSON `{ subject, exp, iat, kid? }` — the **whole** typed `Subject` round-trips inside the cookie, e.g. `{ "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }`. The subject's own `id` is the identity; `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.
|
|
210
|
+
- `signature` is HMAC-SHA256(payload, AUTH_SECRET)
|
|
211
|
+
|
|
212
|
+
**Why HMAC, not JWT?**
|
|
213
|
+
|
|
214
|
+
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.
|
|
215
|
+
|
|
216
|
+
## Issuing
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { issueSession } from '@voltro/plugin-auth'
|
|
220
|
+
|
|
221
|
+
// Positional args: (subject, secret, options?). Returns { value, setCookie }.
|
|
222
|
+
const { value, setCookie } = issueSession(
|
|
223
|
+
subject, // a full Subject, e.g. subjectFromUser(user)
|
|
224
|
+
AUTH_CONFIG.secret,
|
|
225
|
+
{
|
|
226
|
+
ttlSeconds: 60 * 60 * 24 * 7, // 7 days (default if omitted)
|
|
227
|
+
domain: '.your-product.com', // flat on options, not nested
|
|
228
|
+
secure: true, // defaults to true
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Set on the response
|
|
233
|
+
res.setHeader('set-cookie', setCookie)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`setCookie` is the full `Set-Cookie` string — `voltro:session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`. `value` is the raw signed cookie value (`<payload>.<sig>`) if you need it directly. `IssueSessionOptions` carries only `ttlSeconds`, `domain`, and `secure` — `HttpOnly` and `SameSite=lax` are hardcoded in the builder and not configurable.
|
|
237
|
+
|
|
238
|
+
## Reading
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { readSession } from '@voltro/plugin-auth'
|
|
242
|
+
|
|
243
|
+
const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
|
|
244
|
+
// → { type: 'user', id, tenantId } | null
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Returns `null` for:
|
|
248
|
+
|
|
249
|
+
- Missing cookie
|
|
250
|
+
- Tampered payload (signature mismatch)
|
|
251
|
+
- Expired session (`exp < now`)
|
|
252
|
+
|
|
253
|
+
## Clearing
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { clearSessionCookie } from '@voltro/plugin-auth'
|
|
257
|
+
|
|
258
|
+
res.setHeader('set-cookie', clearSessionCookie({ domain: '.your-product.com', secure: true }))
|
|
259
|
+
// voltro:session=; HttpOnly; Secure; …; Max-Age=0
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`clearSessionCookie` takes the same `IssueSessionOptions` shape (`ttlSeconds` ignored; `domain` / `secure` honoured).
|
|
263
|
+
|
|
264
|
+
## Secret rotation
|
|
265
|
+
|
|
266
|
+
Zero-downtime rotation is env-driven and applies to **every** verify path — the framework's rpc auth chain, the plugin's `/auth/*` routes, and `readSession` itself:
|
|
267
|
+
|
|
268
|
+
1. Set the NEW secret as `VOLTRO_SESSION_SECRET` (current).
|
|
269
|
+
2. Move the OLD secret to `VOLTRO_SESSION_SECRET_PREVIOUS`. Verification now tries current first, then previous — no live session is invalidated. (`VOLTRO_SESSION_KID` / `VOLTRO_SESSION_KID_PREVIOUS` are optional non-secret labels; they default to `k0` / `k-previous`.)
|
|
270
|
+
3. Leave the window open for one max session lifetime, then drop the `_PREVIOUS` var. Cookies still signed with the old key stop verifying at that point — but they rarely exist by then, because any previous-key cookie that hits an authenticated `/auth/*` route (or `GET /auth/session`) is **re-issued under the current key** in the response.
|
|
271
|
+
|
|
272
|
+
Under the hood: `resolveSessionSecrets()` builds the `{ current, previous? }` keyed set from those env vars; `signSession` always signs with `current` (stamping its `kid`); `verifySessionKeyed` tries `current` then `previous`. The plugin's `AuthConfig` also accepts an explicit `secrets: SessionSecrets` when you'd rather not use env vars. The single-secret `resolveSessionSecret()` still exists for apps that don't rotate.
|
|
273
|
+
|
|
274
|
+
## Cookie attributes
|
|
275
|
+
|
|
276
|
+
The session cookie ships `HttpOnly`, `SameSite=lax`, and `Path=/` hardcoded; `Secure`, `Domain`, and `Max-Age` (from `ttlSeconds`) come from the options you pass `issueSession`. The full attribute checklist — and why `SameSite=lax` is the right default for cross-origin sign-in — lives on the [Cookie security](/docs/authentication/cookies) page.
|
|
277
|
+
|
|
278
|
+
## Multi-instance — no shared store needed
|
|
279
|
+
|
|
280
|
+
Because the session is the cookie + signature is deterministic from `(payload, secret)`, every api instance verifies independently. Scale to N replicas without a Redis cache; rolling deploys don't invalidate sessions.
|
|
281
|
+
|
|
282
|
+
For centralised revocation, the plugin writes a `sessions` row on every sign-in and exposes `handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` (mounted at `GET /auth/sessions` + `POST /auth/sessions/revoke` + `/sessions/revoke-others`). This is the device-management + "sign out other devices" surface — a stolen session can be killed without rotating the secret.
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
286
|
+
## When to use a longer / shorter lifetime
|
|
287
|
+
|
|
288
|
+
| Use case | Lifetime |
|
|
289
|
+
|---|---|
|
|
290
|
+
| Consumer SaaS, low-stakes | 30 days |
|
|
291
|
+
| B2B SaaS, sensitive data | 7 days *(default)* |
|
|
292
|
+
| Admin / billing dashboards | 1 day + idle timeout |
|
|
293
|
+
| Banking / health / compliance | 30 minutes + sliding window |
|
|
294
|
+
|
|
295
|
+
Sliding-window auto-renewal ships: the session payload carries an `iat`, and `verifySessionKeyed` returns a `renew` flag once the session crosses the renewal threshold (default 70% of its lifetime). The plugin acts on that signal on **authenticated `/auth/*` responses** (and whenever the cookie verified under the previous rotation key): the response carries a fresh `Set-Cookie` signed with the current key and the session's *original* lifetime, and the `sessions` row's `expiresAt` slides forward with it. `GET /auth/session` is the probe built for this — it returns the current subject plus `renewed: true|false`, so a client that pings it periodically keeps an active session alive while an idle one still expires on schedule. (Renewal is an HTTP-response mechanism — rpc frames over the WebSocket can't set cookies.)
|
|
296
|
+
|
|
297
|
+
## Verification details
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// What readSession does:
|
|
301
|
+
// 1. Parse cookie → { payloadB64, sigB64 }
|
|
302
|
+
// 2. Compute expected = hmacSha256(payloadB64, secret)
|
|
303
|
+
// 3. Constant-time compare expected vs. sigB64
|
|
304
|
+
// 4. Decode payload
|
|
305
|
+
// 5. Check exp (reject if exp < now)
|
|
306
|
+
// 6. Return the decoded Subject
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
All steps use constant-time comparisons to defeat timing attacks. Implementation lives in `@voltro/plugin-auth/session.ts` — read it for the full story.
|
|
310
|
+
|
|
311
|
+
## Browser-side considerations
|
|
312
|
+
|
|
313
|
+
The session cookie is `HttpOnly` — **the browser cannot read it from JS**. This is deliberate.
|
|
314
|
+
|
|
315
|
+
In React components on the SSR pass, you can decode the subject from the request via the layout (see [React on the web side](/docs/authentication/react)). On the client (post-hydration), there's no fresh cookie read — the layout's subject is the one you have until next page load.
|
|
316
|
+
|
|
317
|
+
That's enough for UI gating. For actions, the server verifies the cookie on every request — the client never needs to "have" the session, it just needs to send it (which the browser does automatically).
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
<!-- source: en/authentication/subject.md -->
|
|
324
|
+
## The Subject
|
|
325
|
+
|
|
326
|
+
_ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator._
|
|
327
|
+
|
|
328
|
+
Every server-side executor receives `ctx.subject` — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit `anonymous` subject), so app code never has to null-check before reading basic fields.
|
|
329
|
+
|
|
330
|
+
## Shape
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
type Subject =
|
|
334
|
+
| { type: 'user'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
|
|
335
|
+
| { type: 'apiKey'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
|
|
336
|
+
| { type: 'serviceAccount'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
|
|
337
|
+
| { type: 'system'; id: string; tenantId: null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
|
|
338
|
+
| { type: 'anonymous'; id: null; tenantId: string | null }
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`scopes` is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and `@voltro/plugin-rbac` compiles a caller's roles onto the same set. There is no `roles` field on the wire; roles are an authoring convenience that resolve to scopes.
|
|
342
|
+
|
|
343
|
+
The `type` discriminator narrows downstream fields:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
if (ctx.subject.type === 'user') {
|
|
347
|
+
// ctx.subject.id, tenantId are strings
|
|
348
|
+
}
|
|
349
|
+
if (ctx.subject.type === 'anonymous') {
|
|
350
|
+
// ctx.subject.tenantId is string | null; id is null
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Resolution
|
|
355
|
+
|
|
356
|
+
`AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:
|
|
357
|
+
|
|
358
|
+
1. **Built-in password cookie** — `voltroPasswordStrategy` reads `voltro:session` and HMAC-verifies it. → `type: 'user'`.
|
|
359
|
+
2. **API key** — `apiKeyStrategy` from `@voltro/protocol/apikey` maps `Authorization: Bearer <prefix>_<token>` → a scoped `apiKey` subject. This ships today; add it to the `auth.strategies` chain in `app.config.ts`. → `type: 'apiKey'`.
|
|
360
|
+
3. **In-process system calls** — code running under `runAsSystem` (cron, workflows, backfills) carries a `system` subject; it's never produced from an inbound request. → `type: 'system'`.
|
|
361
|
+
4. **Anonymous fallback** — nothing matched. The composer's fallback reads the `x-tenant` header and produces `anonymousSubject(tenant)`. → `type: 'anonymous'`.
|
|
362
|
+
|
|
363
|
+
An anonymous request with `x-tenant: <id>` therefore carries that tenant on the subject, scoping reads on public tables. The header is unauthenticated — never trust it for writes.
|
|
364
|
+
|
|
365
|
+
## Common patterns
|
|
366
|
+
|
|
367
|
+
### Require sign-in
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { Schema } from 'effect'
|
|
371
|
+
|
|
372
|
+
class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}
|
|
373
|
+
|
|
374
|
+
export default async (input, ctx) => {
|
|
375
|
+
if (ctx.subject.type !== 'user') throw new Unauthorised()
|
|
376
|
+
// ctx.subject.id is narrowed to string
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Require sign-in + specific tenant
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { assertOwnTenant } from '@voltro/plugin-multitenancy'
|
|
384
|
+
|
|
385
|
+
export default async (input, ctx) => {
|
|
386
|
+
if (ctx.subject.type !== 'user') throw new Unauthorised()
|
|
387
|
+
assertOwnTenant(input.tenantId, ctx.subject)
|
|
388
|
+
// …
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Allow system OR user
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
|
|
396
|
+
if (!canTrigger) throw new Unauthorised({})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### RBAC
|
|
400
|
+
|
|
401
|
+
`@voltro/plugin-rbac` builds on the subject's scopes. Roles **compile to scopes** — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the `permission()` handler guard checks it in one line:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { permission } from '@voltro/plugin-rbac'
|
|
405
|
+
|
|
406
|
+
yield* permission(ctx, 'admin:full') // Effect<void, Forbidden>
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
See [the RBAC plugin](/docs/plugins/rbac) for the full model.
|
|
410
|
+
|
|
411
|
+
## Anonymous tenant resolution
|
|
412
|
+
|
|
413
|
+
For public APIs where you want anonymous callers scoped to a tenant (multi-tenant marketing site, public listings), nothing extra to configure: when no strategy matches, `composeAuthStrategies`' default fallback reads the `x-tenant` header and yields `anonymousSubject(tenant)`:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { anonymousSubject } from '@voltro/protocol'
|
|
417
|
+
// fallback output for `x-tenant: foo`:
|
|
418
|
+
// { type: 'anonymous', id: null, tenantId: 'foo' }
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Tables with `tenant()` then scope anonymous reads to `foo`. To *require* a tenant on anonymous callers — so a tenant-less request can't read across the whole DB on tables that aren't `tenant()`-scoped — pass `anonymousTenantRequired: true` to `composeAuthStrategies`:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
const resolveSubject = composeAuthStrategies(strategies, {
|
|
425
|
+
anonymousTenantRequired: true, // no x-tenant + no matched strategy → throws Unauthenticated
|
|
426
|
+
})
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
When set, an unmatched call with no `x-tenant` header throws `Unauthenticated` instead of yielding a null-tenant anonymous Subject. (Ignored when you supply a custom `fallback` — that function owns the decision.)
|
|
430
|
+
|
|
431
|
+
## Custom resolvers
|
|
432
|
+
|
|
433
|
+
For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom `AuthStrategy` and add it to the `auth.strategies` chain in `app.config.ts` — there is no `runtime.resolveSubject` field:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
// app.config.ts
|
|
437
|
+
import { anonymousSubject } from '@voltro/protocol'
|
|
438
|
+
|
|
439
|
+
export default {
|
|
440
|
+
type: 'api' as const,
|
|
441
|
+
name: 'myApi',
|
|
442
|
+
auth: {
|
|
443
|
+
strategies: [
|
|
444
|
+
{
|
|
445
|
+
id: 'mtls',
|
|
446
|
+
resolve: (input) => {
|
|
447
|
+
const cn = input.headers['x-client-cert-cn']
|
|
448
|
+
return cn
|
|
449
|
+
? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
|
|
450
|
+
: { kind: 'skip' }
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.
|
|
459
|
+
|
|
460
|
+
## ctx.subject in subscriptions
|
|
461
|
+
|
|
462
|
+
`ctx.subject` is captured at subscribe-time. If the cookie expires mid-subscription:
|
|
463
|
+
|
|
464
|
+
- Subsequent mutations from the now-expired client fail with `Unauthorised`.
|
|
465
|
+
- The subscription itself **continues to stream** until the client reconnects or the server drops it.
|
|
466
|
+
- On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.
|
|
467
|
+
|
|
468
|
+
The open stream isn't force-killed on expiry; the next write fails with `Unauthenticated` and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.
|
|
469
|
+
|
|
470
|
+
## Anti-patterns
|
|
471
|
+
|
|
472
|
+
- **Reading `ctx.subject.id` without narrowing.** It's `string` on `user` / `apiKey` / `serviceAccount` / `system`, but `null` on `anonymous`. Narrow on `type` first.
|
|
473
|
+
- **Trusting `subject.tenantId` for writes.** It's read-only context. Always `assertOwnTenant(input.tenantId, ctx.subject)` for any write that takes a tenant.
|
|
474
|
+
- **Caching subjects across requests.** The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
<!-- source: en/authentication/strategies.md -->
|
|
481
|
+
## Auth strategies
|
|
482
|
+
|
|
483
|
+
_The AuthStrategy protocol — how Voltro resolves a Subject from a request, chains multiple identity providers, and lets you plug in your own._
|
|
484
|
+
|
|
485
|
+
Everything above this page describes the **built-in** password + session flow. This page describes the **protocol** underneath it: how the framework turns an incoming request into a [`Subject`](/docs/authentication/subject), and how you swap or stack the mechanism that does it — password cookies, an external IdP, an API key, or your own scheme — without touching handler code.
|
|
486
|
+
|
|
487
|
+
An **auth strategy** is the unit of pluggability. The built-in password auth is *one* strategy (`voltro-password`); [WorkOS / Kinde / Clerk](/docs/authentication/external-idp) are others; you can write your own. The framework evaluates them as a chain and produces a `Subject`.
|
|
488
|
+
|
|
489
|
+
## The contract
|
|
490
|
+
|
|
491
|
+
A strategy answers one question per request: *"is this my request, and if so, who is it?"*
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import type { AuthStrategy } from '@voltro/protocol'
|
|
495
|
+
|
|
496
|
+
interface AuthStrategy {
|
|
497
|
+
readonly id: string // 'voltro-password', 'workos', …
|
|
498
|
+
readonly resolve: (input: AuthStrategyInput) =>
|
|
499
|
+
StrategyResolution | Promise<StrategyResolution>
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface AuthStrategyInput {
|
|
503
|
+
readonly headers: Readonly<Record<string, string | undefined>>
|
|
504
|
+
readonly clientId: number // per-connection id (for soft re-auth)
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`resolve` returns one of three verdicts:
|
|
509
|
+
|
|
510
|
+
| Verdict | Meaning | Composer does |
|
|
511
|
+
|---|---|---|
|
|
512
|
+
| `{ kind: 'skip' }` | Not my request (e.g. my cookie is absent). | Try the next strategy. |
|
|
513
|
+
| `{ kind: 'matched', subject }` | Mine, and here's the verified `Subject`. | Use it. Stop. |
|
|
514
|
+
| `{ kind: 'failed', reason }` | Mine, but verification failed (bad signature, expired). | **Bail to anonymous + log. Do NOT try the next strategy.** |
|
|
515
|
+
|
|
516
|
+
The `failed`-stops-the-chain rule is a **security** decision, not an ergonomic one: a forged `workos` token must not get a second chance to be accepted by some other strategy. A validation failure is treated as a potential attack, not a "wrong door".
|
|
517
|
+
|
|
518
|
+
> Strategies must be **fast on the no-match path** — a cookie-name substring check, no IO — because every strategy runs on every request until one matches. Do JWKS fetches / DB lookups only *after* you've confirmed the request is yours, and cache them.
|
|
519
|
+
|
|
520
|
+
## Composing the chain
|
|
521
|
+
|
|
522
|
+
`composeAuthStrategies` turns an ordered list of strategies into a single resolver. First `matched` wins; first `failed` short-circuits to anonymous.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import { composeAuthStrategies } from '@voltro/protocol'
|
|
526
|
+
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
|
|
527
|
+
import { workosStrategy } from '@voltro/plugin-auth-workos'
|
|
528
|
+
|
|
529
|
+
const resolve = composeAuthStrategies(
|
|
530
|
+
[
|
|
531
|
+
voltroPasswordStrategy(), // try our own session cookie first
|
|
532
|
+
workosStrategy({ clientId: process.env.WORKOS_CLIENT_ID! }), // then WorkOS
|
|
533
|
+
],
|
|
534
|
+
{
|
|
535
|
+
onStrategyFailed: ({ strategyId, reason }) =>
|
|
536
|
+
log.warn('auth strategy failed', { strategyId, reason }),
|
|
537
|
+
},
|
|
538
|
+
)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an [anonymous Subject](/docs/authentication/subject) scoped to the `x-tenant` header (or a custom `fallback` you supply).
|
|
542
|
+
|
|
543
|
+
## Wiring it into the app
|
|
544
|
+
|
|
545
|
+
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:
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import { AuthMiddleware } from '@voltro/protocol'
|
|
549
|
+
import { Layer } from 'effect'
|
|
550
|
+
|
|
551
|
+
export const AuthLayer = Layer.succeed(
|
|
552
|
+
AuthMiddleware,
|
|
553
|
+
AuthMiddleware.of(({ headers, clientId }) => resolve({ headers, clientId })),
|
|
554
|
+
)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The runtime injects a connection-override fast-path *ahead* of the chain so a soft re-auth (`auth.signin` rebinding the WebSocket's subject after a credential check) is honoured without re-running strategies. That's why `AuthStrategyInput` carries `clientId`.
|
|
558
|
+
|
|
559
|
+
## The built-in: `voltroPasswordStrategy`
|
|
560
|
+
|
|
561
|
+
The reference implementation, and the proof the protocol isn't a special case for third parties — our own auth is just a strategy:
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
voltroPasswordStrategy({
|
|
565
|
+
// secret?: defaults to resolveSessionSecret() (VOLTRO_SESSION_SECRET)
|
|
566
|
+
// cookieName?: defaults to 'voltro:session'
|
|
567
|
+
})
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Its `resolve`:
|
|
571
|
+
|
|
572
|
+
1. Reads the `voltro:session` cookie. Absent → `skip`.
|
|
573
|
+
2. HMAC-verifies it. Bad signature or expired → `failed` (a forged cookie doesn't fall through to another IdP).
|
|
574
|
+
3. Valid → `matched`, stamping `metadata.provider = 'voltro-password'`.
|
|
575
|
+
|
|
576
|
+
Fully synchronous, zero IO on no-match. See [sessions](/docs/authentication/sessions) for how the cookie is minted.
|
|
577
|
+
|
|
578
|
+
## Writing your own strategy
|
|
579
|
+
|
|
580
|
+
Any object satisfying `AuthStrategy` works. As an illustration, a minimal header-keyed strategy:
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
import type { AuthStrategy } from '@voltro/protocol'
|
|
584
|
+
|
|
585
|
+
const myKeyStrategy = (lookup: (key: string) => Promise<{ id: string; tenantId: string } | null>): AuthStrategy => ({
|
|
586
|
+
id: 'my-key',
|
|
587
|
+
resolve: async ({ headers }) => {
|
|
588
|
+
const key = headers['x-api-key']
|
|
589
|
+
if (!key) return { kind: 'skip' } // not my request
|
|
590
|
+
const row = await lookup(key)
|
|
591
|
+
if (!row) return { kind: 'failed', reason: 'unknown api key' }
|
|
592
|
+
return {
|
|
593
|
+
kind: 'matched',
|
|
594
|
+
subject: {
|
|
595
|
+
type: 'apiKey',
|
|
596
|
+
id: row.id,
|
|
597
|
+
tenantId: row.tenantId,
|
|
598
|
+
metadata: { provider: 'my-key' },
|
|
599
|
+
},
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
})
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Drop it into the `composeAuthStrategies` array. The `metadata.provider` tag lets handler code pattern-match on *which* strategy authenticated the caller (see [the Subject](/docs/authentication/subject)).
|
|
606
|
+
|
|
607
|
+
> You don't need to hand-roll API-key auth — a production `apiKeyStrategy` already ships from `@voltro/protocol/apikey` (prefixed `Authorization: Bearer <prefix>_<token>`, sha256-hashed lookup, scoped `apiKey` subject). Use the example above only for genuinely custom schemes the shipped strategy can't express.
|
|
608
|
+
|
|
609
|
+
### Strategies that need a server-side callback
|
|
610
|
+
|
|
611
|
+
Pure token-verify strategies (the three IdP plugins, the example above) need no server queries — the credential already arrives on the request. A strategy that must run a **server-side OAuth code exchange** or land a magic link additionally implements `mountRoutes`:
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
interface AuthStrategyWithCallback extends AuthStrategy {
|
|
615
|
+
readonly mountRoutes: (router: AuthCallbackRouter) => void // router.get / router.post
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
The framework mounts those routes on the HTTP router when present (detected via the `hasCallbackRoutes` type guard). The first-party WorkOS/Kinde/Clerk plugins do **not** use this — their SDKs run the OAuth flow in the browser and set a cookie the strategy then verifies. `mountRoutes` exists for custom OIDC flows that can't.
|
|
620
|
+
|
|
621
|
+
## Requiring authentication
|
|
622
|
+
|
|
623
|
+
Independent of strategy: any handler can demand a real (non-anonymous) caller.
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
import { assertAuthenticated } from '@voltro/protocol'
|
|
627
|
+
|
|
628
|
+
const execute = async (input, ctx) => {
|
|
629
|
+
assertAuthenticated(ctx.subject) // throws Unauthenticated if anonymous
|
|
630
|
+
// …
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
`Unauthenticated` crosses the wire with its `_tag` intact, so `@voltro/client` can auto-redirect to sign-in. It's distinct from a tenant-mismatch ("you're signed in but touching the wrong tenant") — this means "no real identity resolved at all".
|
|
635
|
+
|
|
636
|
+
## Next
|
|
637
|
+
|
|
638
|
+
- [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`.
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
<!-- source: en/authentication/external-idp.md -->
|
|
645
|
+
## External identity providers
|
|
646
|
+
|
|
647
|
+
_WorkOS, Kinde, Clerk, Auth0, Supabase Auth, and generic OIDC as Voltro auth strategies — JWT/JWKS verification via the shared jwtBearerStrategy, claims-to-tenant mapping, and how they stack with password auth._
|
|
648
|
+
|
|
649
|
+
Voltro ships first-party [strategy](/docs/authentication/strategies) plugins for six identity providers, including a generic OIDC adapter that covers anything publishing an OpenID Connect discovery document:
|
|
650
|
+
|
|
651
|
+
| Package | Provider | Strategy id |
|
|
652
|
+
|---|---|---|
|
|
653
|
+
| `@voltro/plugin-auth-workos` | WorkOS AuthKit / SSO | `workos` |
|
|
654
|
+
| `@voltro/plugin-auth-kinde` | Kinde | `kinde` |
|
|
655
|
+
| `@voltro/plugin-auth-clerk` | Clerk | `clerk` |
|
|
656
|
+
| `@voltro/plugin-auth-auth0` | Auth0 | `auth0` |
|
|
657
|
+
| `@voltro/plugin-auth-supabase` | Supabase Auth (GoTrue) | `supabase` |
|
|
658
|
+
| `@voltro/plugin-auth-oidc` | Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) | configurable via `id:` |
|
|
659
|
+
|
|
660
|
+
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.
|
|
661
|
+
|
|
662
|
+
## The model: verify, don't redirect
|
|
663
|
+
|
|
664
|
+
These strategies do **not** run the OAuth redirect dance on the server. The provider's own SDK runs that in the browser and lands a signed **JWT** — either as a cookie (cookie mode) or an `Authorization: Bearer` header. The Voltro strategy's job is narrow and stateless:
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
request ─► extract token (Bearer or cookie)
|
|
668
|
+
─► verify signature against the provider's JWKS (cached)
|
|
669
|
+
─► map verified claims → Subject (+ tenantId)
|
|
670
|
+
─► attach raw claims under metadata.claims
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
This means **you keep your `Subject` model and multi-tenant scope** — the IdP authenticates, but `tenantId` and the typed `Subject` stay the framework's, not the vendor's. No session is stored server-side for these strategies; the JWT *is* the session, re-verified per request (JWKS is cached, so steady-state verification is CPU-local).
|
|
674
|
+
|
|
675
|
+
> Security: the JWKS verifier allows asymmetric algorithms only (`RS256` / `ES256`, optionally `PS256` / `EdDSA`). `HS256` is intentionally rejected on the JWKS path — a shared-secret HMAC over a public JWKS flow is a downgrade vector. (Legacy / self-hosted Supabase that signs symmetrically is the one exception: it verifies through a separate, explicit shared-secret path — `supabaseStrategy({ jwtSecret })` — never the JWKS verifier. See [Supabase Auth](/docs/plugins/auth-supabase).)
|
|
676
|
+
|
|
677
|
+
There are **two ways** to put an external IdP in front of a Voltro app; this page's per-provider sections cover the first. In the **verify** model above, the IdP's JWT *is* the session — every request re-verifies it and no framework session is minted. The **second** model uses the IdP only for the *login step* — redirect to its hosted UI, take the callback, then mint your **own** `voltro:session` — so the IdP is an alternate front door, not the session authority. See [WorkOS SSO login](#workos-sso-login) below for a full example.
|
|
678
|
+
|
|
679
|
+
## Client: getting the token to the api (HTTP **and** WebSocket)
|
|
680
|
+
|
|
681
|
+
The strategy above only *verifies* — your browser still has to *send* the token on every request, over **both** transports the framework uses:
|
|
682
|
+
|
|
683
|
+
- **HTTP** (`POST /rpc`, loaders, `/auth/*`): the provider SDK's `fetch` (or your own) sends `Authorization: Bearer <token>` normally — nothing framework-specific.
|
|
684
|
+
- **WebSocket** (live subscriptions): a browser **cannot** set headers on a WS upgrade, so the token can't ride the handshake. Pass it to `mount()` instead — the framework attaches it to every rpc **message frame** (not the upgrade), where the same strategy reads it per call:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
// .framework/main.tsx (or your mount entry)
|
|
688
|
+
mount(App, {
|
|
689
|
+
apis: {
|
|
690
|
+
api: {
|
|
691
|
+
// A thunk is resolved fresh on every (re)connect, so a rotating
|
|
692
|
+
// access token is pulled anew per connect, not frozen at first mount:
|
|
693
|
+
headers: async () => ({
|
|
694
|
+
authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
This is **required when the api is a separate origin** (the common case — `api.example.com` vs your web origin): there is no shared cookie and no upgrade header, so without `headers` every subscription connects **anonymous** (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.
|
|
702
|
+
|
|
703
|
+
## WorkOS
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
import { workosStrategy } from '@voltro/plugin-auth-workos'
|
|
707
|
+
|
|
708
|
+
workosStrategy({
|
|
709
|
+
clientId: process.env.WORKOS_CLIENT_ID!, // client_01H… — builds the JWKS URL + audience
|
|
710
|
+
// jwksUrl?: https://api.workos.com/sso/jwks/<clientId> (default)
|
|
711
|
+
// issuer?: https://api.workos.com (default)
|
|
712
|
+
// cookieName?: 'wos-session' (AuthKit cookie mode; null = header-only)
|
|
713
|
+
// defaultTenantId?: fallback when claims expose no org_id
|
|
714
|
+
})
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Tenant maps from `claims.org_id`. Verified claims arrive as `subject.metadata.claims` typed loosely as `WorkosClaims` (`sub`, `email`, `org_id`, `role`, `permissions`, …).
|
|
718
|
+
|
|
719
|
+
## WorkOS SSO login
|
|
720
|
+
|
|
721
|
+
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.
|
|
722
|
+
|
|
723
|
+
- **`workosAuthorizationUrl({ clientId, redirectUri, state? })`** — builds the WorkOS hosted-login (AuthKit) URL to redirect the browser to. Pass a random `state` for CSRF defence (and to carry a post-login return path).
|
|
724
|
+
- **`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).
|
|
725
|
+
|
|
726
|
+
Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency), so they drop into any app's own routing + provisioning.
|
|
727
|
+
|
|
728
|
+
### The flow
|
|
729
|
+
|
|
730
|
+
```text
|
|
731
|
+
GET /auth/workos/login
|
|
732
|
+
─► set a random `state` cookie (CSRF defence)
|
|
733
|
+
─► 302 → workosAuthorizationUrl({ clientId, redirectUri, state })
|
|
734
|
+
|
|
735
|
+
GET /auth/workos/callback?code=…&state=…
|
|
736
|
+
─► verify `state` matches the cookie (else 400 — possible CSRF)
|
|
737
|
+
─► workosAuthenticateWithCode({ clientId, apiKey, code }) → WorkosProfile
|
|
738
|
+
─► find-or-provision the local user + org (SHARED with password signup)
|
|
739
|
+
─► issueSession(subject) → Set-Cookie: voltro:session=…
|
|
740
|
+
─► 302 → dashboard
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
The callback mints the **same** `voltro:session` as password sign-in, so everything downstream — the session strategy, org switching, audit — behaves identically. **WorkOS handles _authentication_; your app keeps _authorization_.** The IdP never becomes the session authority; it is simply another way in.
|
|
744
|
+
|
|
745
|
+
### Wiring the routes
|
|
746
|
+
|
|
747
|
+
Wrap the two routes in a small **server-only** plugin and add it to `plugins` in your api's `app.config`:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import { randomUUID } from 'node:crypto'
|
|
751
|
+
import { definePlugin } from '@voltro/protocol'
|
|
752
|
+
import { workosAuthorizationUrl, workosAuthenticateWithCode } from '@voltro/plugin-auth-workos'
|
|
753
|
+
import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'
|
|
754
|
+
|
|
755
|
+
export const workosAuthPlugin = () =>
|
|
756
|
+
definePlugin({
|
|
757
|
+
name: 'workos-login',
|
|
758
|
+
httpRoutes: [
|
|
759
|
+
{
|
|
760
|
+
method: 'GET',
|
|
761
|
+
path: '/auth/workos/login',
|
|
762
|
+
handle: async () => {
|
|
763
|
+
const clientId = process.env.WORKOS_CLIENT_ID
|
|
764
|
+
const redirectUri = process.env.WORKOS_REDIRECT_URI
|
|
765
|
+
if (!clientId || !redirectUri) {
|
|
766
|
+
return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
|
|
767
|
+
}
|
|
768
|
+
const state = randomUUID()
|
|
769
|
+
const url = workosAuthorizationUrl({ clientId, redirectUri, state })
|
|
770
|
+
return {
|
|
771
|
+
status: 302,
|
|
772
|
+
headers: { location: url, 'set-cookie': `workos-oauth-state=${state}; Path=/; Max-Age=600; SameSite=Lax` },
|
|
773
|
+
body: '',
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
method: 'GET',
|
|
779
|
+
path: '/auth/workos/callback',
|
|
780
|
+
handle: async (req) => {
|
|
781
|
+
const clientId = process.env.WORKOS_CLIENT_ID
|
|
782
|
+
const apiKey = process.env.WORKOS_API_KEY
|
|
783
|
+
if (!clientId || !apiKey) {
|
|
784
|
+
return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
|
|
785
|
+
}
|
|
786
|
+
const code = new URLSearchParams(req.query).get('code')
|
|
787
|
+
if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
|
|
788
|
+
// …also validate `state` against the state cookie (CSRF) before exchanging.
|
|
789
|
+
const profile = await workosAuthenticateWithCode({ clientId, apiKey, code })
|
|
790
|
+
// find-or-provision runs the SAME path as password signup:
|
|
791
|
+
const { userId, orgId } = await findOrProvisionUser(profile)
|
|
792
|
+
const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
|
|
793
|
+
return {
|
|
794
|
+
status: 302,
|
|
795
|
+
headers: {
|
|
796
|
+
location: '/dashboard',
|
|
797
|
+
'set-cookie': `voltro:session=${issued.value}; Path=/; Max-Age=604800; SameSite=Lax`,
|
|
798
|
+
},
|
|
799
|
+
body: '',
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
})
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
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.
|
|
808
|
+
|
|
809
|
+
### Configuration
|
|
810
|
+
|
|
811
|
+
All three variables are **optional** — the routes return **`503`** until they are set, so the app boots without WorkOS and you enable SSO by supplying credentials:
|
|
812
|
+
|
|
813
|
+
| Env var | Secret | Purpose |
|
|
814
|
+
|---|---|---|
|
|
815
|
+
| `WORKOS_CLIENT_ID` | no | WorkOS Client ID (`client_…`) for the hosted login. |
|
|
816
|
+
| `WORKOS_API_KEY` | **yes** | WorkOS API key (`sk_…`) — the OAuth client secret used in the code exchange. |
|
|
817
|
+
| `WORKOS_REDIRECT_URI` | no | The callback URL, **registered in the WorkOS dashboard** — e.g. `https://app.voltro.cloud/auth/workos/callback`. |
|
|
818
|
+
|
|
819
|
+
Add a "Sign in with WorkOS SSO" link on your sign-in page pointing at `GET /auth/workos/login`, and the round trip runs end to end.
|
|
820
|
+
|
|
821
|
+
## Kinde
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import { kindeStrategy } from '@voltro/plugin-auth-kinde'
|
|
825
|
+
|
|
826
|
+
kindeStrategy({
|
|
827
|
+
issuer: 'https://yourcompany.kinde.com', // required
|
|
828
|
+
// jwksUrl?: <issuer>/.well-known/jwks (default)
|
|
829
|
+
// cookieName?: 'kinde_access_token' (null = header-only)
|
|
830
|
+
// defaultTenantId?: for single-tenant setups
|
|
831
|
+
})
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
Tenant maps from `claims.org_code`, falling back to `claims.org_codes[0]`.
|
|
835
|
+
|
|
836
|
+
## Clerk
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
import { clerkStrategy } from '@voltro/plugin-auth-clerk'
|
|
840
|
+
|
|
841
|
+
clerkStrategy({
|
|
842
|
+
frontendApi: 'https://clerk.yourapp.com', // Clerk Frontend API host
|
|
843
|
+
// jwksUrl?: <frontendApi>/.well-known/jwks.json (default)
|
|
844
|
+
// issuer?: <frontendApi> (default)
|
|
845
|
+
// cookieName?: '__session' (Clerk's hardcoded cookie name)
|
|
846
|
+
// defaultTenantId?: when no org_id claim is present
|
|
847
|
+
})
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
Tenant maps from `claims.org_id` (Clerk's "Organizations" feature). Clerk's default `__session` tokens carry no `aud` claim, so the audience check is off unless you configure one.
|
|
851
|
+
|
|
852
|
+
## Auth0
|
|
853
|
+
|
|
854
|
+
```ts
|
|
855
|
+
import { auth0Strategy } from '@voltro/plugin-auth-auth0'
|
|
856
|
+
|
|
857
|
+
auth0Strategy({
|
|
858
|
+
domain: 'acme.us.auth0.com', // required — builds JWKS URL + issuer
|
|
859
|
+
audience: 'https://api.acme.com', // your API identifier
|
|
860
|
+
// jwksUrl?: https://<domain>/.well-known/jwks.json (default)
|
|
861
|
+
// issuer?: https://<domain>/ (default — TRAILING SLASH MATTERS)
|
|
862
|
+
// tenantClaim?: 'https://voltro.dev/tenant' (default namespaced URN)
|
|
863
|
+
// cookieName?: null (header-only by default)
|
|
864
|
+
// defaultTenantId?: fallback for single-tenant Auth0 setups
|
|
865
|
+
})
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
Tenant maps from a namespaced custom claim (Auth0's rules require non-standard claims to live under a URN). Use a custom Auth0 Action to copy your app metadata into the namespaced claim, or configure `tenantClaim` to an unnamespaced field that already exists in your token.
|
|
869
|
+
|
|
870
|
+
## Supabase Auth
|
|
871
|
+
|
|
872
|
+
```ts
|
|
873
|
+
import { supabaseStrategy } from '@voltro/plugin-auth-supabase'
|
|
874
|
+
|
|
875
|
+
supabaseStrategy({
|
|
876
|
+
projectRef: 'abcdef', // builds the hosted JWKS URL
|
|
877
|
+
// OR self-hosted GoTrue:
|
|
878
|
+
// jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
|
|
879
|
+
// issuer: 'https://my-supabase.example.com/auth/v1',
|
|
880
|
+
|
|
881
|
+
// tenantClaim?: 'app_metadata.tenant_id' (default, dotted path)
|
|
882
|
+
// audience?: 'authenticated' (default — Supabase's role)
|
|
883
|
+
// defaultTenantId?: for single-tenant deployments
|
|
884
|
+
})
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
Tenant maps from `app_metadata.tenant_id` by default (a server-controlled blob; users can't forge it). Apps that query tenants via `user_metadata` set `tenantClaim: 'user_metadata.org'` — same syntax, dotted-path resolution. `cookieName` defaults to `null` (header-only); Supabase's JS SDK stores the access token at `sb-<projectRef>-auth-token` as a JSON payload, so most Voltro apps just consume the Bearer header and leave the cookie path off.
|
|
888
|
+
|
|
889
|
+
## Generic OIDC (Okta, Keycloak, Cognito, Azure AD, …)
|
|
890
|
+
|
|
891
|
+
For any IdP that publishes an OpenID Connect discovery document, `@voltro/plugin-auth-oidc` discovers the JWKS at boot — no per-provider package needed:
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
import { oidcStrategy } from '@voltro/plugin-auth-oidc'
|
|
895
|
+
|
|
896
|
+
oidcStrategy({
|
|
897
|
+
id: 'okta', // stable id for logs + metadata.provider
|
|
898
|
+
issuer: 'https://acme.okta.com',
|
|
899
|
+
audience: 'api://acme',
|
|
900
|
+
tenantClaim: 'acme/tenant_id', // your provider's tenant claim
|
|
901
|
+
})
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
The strategy fetches `${issuer}/.well-known/openid-configuration` on the first request, extracts `jwks_uri`, and caches the discovery document for the lifetime of the process. Pass `jwksUrl` explicitly to skip the discovery roundtrip entirely (slightly faster cold-start, no behavioural difference). Pass `discoveryUrl` if your provider hosts the document at a non-standard path.
|
|
905
|
+
|
|
906
|
+
Common config combinations:
|
|
907
|
+
|
|
908
|
+
| Provider | `issuer` | `tenantClaim` | Notes |
|
|
909
|
+
|---|---|---|---|
|
|
910
|
+
| Okta | `https://<org>.okta.com/oauth2/default` | `acme/tenant_id` (custom claim) | Use the "default" authorization server unless you've set up a custom one. |
|
|
911
|
+
| Keycloak | `https://<host>/realms/<realm>` | `realm_access.tenant` | Add a custom claim mapper to surface the tenant. |
|
|
912
|
+
| Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolId>` | `custom:tenant_id` | Cognito prefixes custom claims with `custom:`. |
|
|
913
|
+
| Azure AD | `https://login.microsoftonline.com/<tenantId>/v2.0` | `tid` | `tid` IS the Azure tenant — usually you map it 1:1 to Voltro's `tenantId`. |
|
|
914
|
+
| Google Workspace | `https://accounts.google.com` | `hd` | `hd` is the hosted-domain claim. |
|
|
915
|
+
|
|
916
|
+
## Stacking with password auth
|
|
917
|
+
|
|
918
|
+
Strategies compose — you can accept *both* your own password sessions and an external IdP during a migration, or per surface:
|
|
919
|
+
|
|
920
|
+
```ts
|
|
921
|
+
import { composeAuthStrategies } from '@voltro/protocol'
|
|
922
|
+
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
|
|
923
|
+
import { clerkStrategy } from '@voltro/plugin-auth-clerk'
|
|
924
|
+
|
|
925
|
+
const resolve = composeAuthStrategies([
|
|
926
|
+
voltroPasswordStrategy(), // existing users on our cookie
|
|
927
|
+
clerkStrategy({ frontendApi: process.env.CLERK_FRONTEND_API! }), // new users on Clerk
|
|
928
|
+
])
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
First match wins, so existing password sessions keep working while new sign-ups flow through Clerk — no big-bang cutover. See [strategies](/docs/authentication/strategies) for chain semantics (and why a `failed` verdict stops the chain rather than falling through).
|
|
932
|
+
|
|
933
|
+
## Reading provider claims in handlers
|
|
934
|
+
|
|
935
|
+
The verified JWT claims ride along on the Subject so handlers can use provider-specific data without the framework knowing the provider's shape:
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
const execute = async (input, ctx) => {
|
|
939
|
+
const s = ctx.subject
|
|
940
|
+
if (s.metadata?.provider === 'workos') {
|
|
941
|
+
const claims = s.metadata.claims as WorkosClaims
|
|
942
|
+
if (!claims.permissions?.includes('billing:write')) {
|
|
943
|
+
throw new Unauthenticated({ reason: 'missing billing:write' })
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// …
|
|
947
|
+
}
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
`metadata.provider` is the discriminator; `metadata.claims` is the raw verified payload. The framework never reads either — it's a passthrough slot, so adding a provider never changes the `Subject` type.
|
|
951
|
+
|
|
952
|
+
## Rolling your own provider
|
|
953
|
+
|
|
954
|
+
Three escape hatches, in order of effort:
|
|
955
|
+
|
|
956
|
+
1. **`oidcStrategy`** from `@voltro/plugin-auth-oidc` — for any IdP with a standard OIDC discovery document. Most modern providers (Okta, Keycloak, Cognito, Azure AD, Google) work with this; configure `id`, `issuer`, `audience`, `tenantClaim` and you're done.
|
|
957
|
+
2. **`jwtBearerStrategy`** from `@voltro/protocol/jwt` directly — for non-OIDC bearer-token providers, custom audiences, or when you want absolute control over the JWKS URL + verification rules:
|
|
958
|
+
|
|
959
|
+
```ts
|
|
960
|
+
import { jwtBearerStrategy } from '@voltro/protocol/jwt'
|
|
961
|
+
|
|
962
|
+
const custom = jwtBearerStrategy({
|
|
963
|
+
id: 'my-idp',
|
|
964
|
+
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
|
|
965
|
+
issuer: 'https://idp.example.com',
|
|
966
|
+
audience: 'https://api.yourapp.com',
|
|
967
|
+
cookieName: null, // Bearer header only
|
|
968
|
+
tenantIdFromClaims: (claims) => (claims['org_id'] as string) ?? null,
|
|
969
|
+
})
|
|
970
|
+
```
|
|
971
|
+
3. **Custom AuthStrategy implementation** — for non-JWT auth (cookies that resolve via a DB lookup, mTLS, …). See [strategies](/docs/authentication/strategies) for the contract.
|
|
972
|
+
|
|
973
|
+
Returning `null` from `tenantIdFromClaims` is a `failed` verdict — the strategy owns the request but can't satisfy the tenant invariant, so it won't silently produce a tenant-less Subject.
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
---
|
|
978
|
+
|
|
979
|
+
<!-- source: en/authentication/handlers.md -->
|
|
980
|
+
## HTTP handlers
|
|
981
|
+
|
|
982
|
+
_handleSignIn, handleSignUp, handleSignOut — the HTTP-layer functions that turn form fields into session cookies._
|
|
983
|
+
|
|
984
|
+
`@voltro/plugin-auth` exposes three HTTP-shape handlers. They take request bits + a user store + auth config, and return response bits (status, body, cookie, redirect). They're transport-agnostic — wire them into Node's `http`, Express, Hono, anything.
|
|
985
|
+
|
|
986
|
+
## handleSignIn
|
|
987
|
+
|
|
988
|
+
```ts
|
|
989
|
+
import { handleSignIn } from '@voltro/plugin-auth'
|
|
990
|
+
|
|
991
|
+
const result = await handleSignIn(
|
|
992
|
+
{
|
|
993
|
+
email: fields.email,
|
|
994
|
+
password: fields.password,
|
|
995
|
+
redirectAfter: true, // 302 redirect vs JSON response
|
|
996
|
+
},
|
|
997
|
+
userStore,
|
|
998
|
+
AUTH_CONFIG,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
// result: {
|
|
1002
|
+
// status: number,
|
|
1003
|
+
// body: string,
|
|
1004
|
+
// contentType: string,
|
|
1005
|
+
// setCookie?: string,
|
|
1006
|
+
// location?: string,
|
|
1007
|
+
// }
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
What it does:
|
|
1011
|
+
|
|
1012
|
+
1. `userStore.findByEmail(email)` — fetch the user (or pretend, see [timing oracle](/docs/authentication/passwords#timing-oracle-defence)).
|
|
1013
|
+
2. `verifyPassword(password, user.passwordHash)` — always run, even on unknown email.
|
|
1014
|
+
3. On success: `issueSession(subject, config.secret, {...})` → set the `Set-Cookie` header.
|
|
1015
|
+
4. If `redirectAfter` is true (form POST): 302 to `AUTH_CONFIG.successRedirect`.
|
|
1016
|
+
5. If `redirectAfter` is false (XHR / fetch): 200 + JSON `{ ok: true, subject }`.
|
|
1017
|
+
|
|
1018
|
+
Failures return a 401 `HandlerResult` with a generic `{ error: 'invalid credentials' }` body — no email-existence disclosure. The handlers encode failures as 4xx/5xx `HandlerResult`s rather than throwing domain errors across the wire.
|
|
1019
|
+
|
|
1020
|
+
## handleSignUp
|
|
1021
|
+
|
|
1022
|
+
```ts
|
|
1023
|
+
const result = await handleSignUp(
|
|
1024
|
+
{ email, password, redirectAfter: true },
|
|
1025
|
+
userStore,
|
|
1026
|
+
AUTH_CONFIG,
|
|
1027
|
+
)
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
What it does:
|
|
1031
|
+
|
|
1032
|
+
1. Reject empty email/password (400) and passwords under 8 characters (400). That length floor is the only built-in policy; richer rules belong in your sign-up route, see [Passwords](/docs/authentication/passwords#password-policy).
|
|
1033
|
+
2. `userStore.findByEmail(email)` — if exists, return 409.
|
|
1034
|
+
3. `hashPassword(password)`.
|
|
1035
|
+
4. `userStore.insert({ email, passwordHash, tenantId: defaultTenantId })`.
|
|
1036
|
+
5. `issueSession(...)` + 302 redirect or 200 JSON `{ ok: true, subject }`. Any failure in this body collapses to `500 { error: 'signup_failed' }`.
|
|
1037
|
+
|
|
1038
|
+
The new user is added to the `defaultTenantId` from `AuthConfig`. For multi-tenant invite flows where the tenant is decided by an invite token, write a custom handler — `handleSignUp` is the convenient default.
|
|
1039
|
+
|
|
1040
|
+
## handleSignOut
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
const result = handleSignOut(AUTH_CONFIG)
|
|
1044
|
+
|
|
1045
|
+
// result: {
|
|
1046
|
+
// status: 302,
|
|
1047
|
+
// setCookie: 'voltro:session=; Max-Age=0; …',
|
|
1048
|
+
// location: '/', // config.successRedirect ?? '/'
|
|
1049
|
+
// }
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
Synchronous — just emits an expired-cookie header + a redirect. The cookie kills any subsequent verifier; no server-side state to update.
|
|
1053
|
+
|
|
1054
|
+
## Wiring into Node http
|
|
1055
|
+
|
|
1056
|
+
```ts
|
|
1057
|
+
// apps/api/index.ts
|
|
1058
|
+
import { createServer } from 'node:http'
|
|
1059
|
+
import { Effect } from 'effect'
|
|
1060
|
+
import {
|
|
1061
|
+
handleSignIn, handleSignUp, handleSignOut,
|
|
1062
|
+
postgresUserStore, type AuthConfig,
|
|
1063
|
+
} from '@voltro/plugin-auth'
|
|
1064
|
+
|
|
1065
|
+
const AUTH_CONFIG: AuthConfig = {
|
|
1066
|
+
secret: process.env.AUTH_SECRET!,
|
|
1067
|
+
defaultTenantId: 'acme',
|
|
1068
|
+
cookieSecure: process.env.NODE_ENV === 'production',
|
|
1069
|
+
cookieDomain: '.your-product.com', // optional
|
|
1070
|
+
successRedirect: process.env.AUTH_SUCCESS ?? '/dashboard',
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// postgresUserStore takes an `@effect/sql` SqlClient and returns a
|
|
1074
|
+
// UserStore synchronously — the caller owns the client's lifecycle.
|
|
1075
|
+
const store = postgresUserStore(sql)
|
|
1076
|
+
|
|
1077
|
+
createServer(async (req, res) => {
|
|
1078
|
+
const url = req.url ?? ''
|
|
1079
|
+
const fields = await parseFormBody(req)
|
|
1080
|
+
|
|
1081
|
+
if (req.method === 'POST' && url === '/auth/signin') {
|
|
1082
|
+
const r = await Effect.runPromise(handleSignIn(
|
|
1083
|
+
{ ...fields, redirectAfter: true },
|
|
1084
|
+
store,
|
|
1085
|
+
AUTH_CONFIG,
|
|
1086
|
+
))
|
|
1087
|
+
res.statusCode = r.status
|
|
1088
|
+
if (r.setCookie) res.setHeader('set-cookie', r.setCookie)
|
|
1089
|
+
if (r.location) res.setHeader('location', r.location)
|
|
1090
|
+
res.setHeader('content-type', r.contentType ?? 'text/plain')
|
|
1091
|
+
res.end(r.body)
|
|
1092
|
+
return
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// … signup, signout, plus your own queries
|
|
1096
|
+
}).listen(4100)
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
## With CORS + credentials
|
|
1100
|
+
|
|
1101
|
+
If your web app + api are on different origins, the form POST works (form submission is cross-origin-allowed) but XHR / fetch needs:
|
|
1102
|
+
|
|
1103
|
+
- Server: `Access-Control-Allow-Credentials: true` + exact-origin allow-list.
|
|
1104
|
+
- Client: `fetch(url, { credentials: 'include' })`.
|
|
1105
|
+
- Cookies: SameSite is hardcoded `lax` in the session builder (it is not an `AuthConfig` field) — which is exactly what cross-origin form POST sign-in needs. `strict` would block them, but the plugin never emits `strict`.
|
|
1106
|
+
|
|
1107
|
+
For single-origin deploys (one Caddy / nginx in front of both), none of this matters — same-origin cookies always flow.
|
|
1108
|
+
|
|
1109
|
+
## How the handlers surface failures
|
|
1110
|
+
|
|
1111
|
+
The handlers do NOT throw domain errors across the wire — sign-in / sign-up encode failures as 4xx/5xx `HandlerResult`s:
|
|
1112
|
+
|
|
1113
|
+
| Result | When |
|
|
1114
|
+
|---|---|
|
|
1115
|
+
| `400 { error: 'email and password required' }` | missing field |
|
|
1116
|
+
| `400 { error: 'password must be at least 8 characters' }` | sign-up, password too short |
|
|
1117
|
+
| `401 { error: 'invalid credentials' }` | sign-in: email not found OR password mismatch (uniform timing) |
|
|
1118
|
+
| `409 { error: 'email already registered' }` | sign-up: email already in use |
|
|
1119
|
+
| `500 { error: 'signup_failed' }` | any uncaught failure inside `handleSignUp` (e.g. a hash or insert error) |
|
|
1120
|
+
|
|
1121
|
+
The *tagged* errors that the lower-level primitives raise — and that you can `Effect.catchTag` if you compose them yourself — are `PasswordEmptyError`, `PasswordHashError` (from `@voltro/plugin-auth/password`), `UserAlreadyExistsError`, `UserNotFoundError` (from the `UserStore`), and `TotpVerifyError` (from the MFA path). They extend `Data.TaggedError` / `Schema.TaggedError`, so they carry typed payloads.
|
|
1122
|
+
|
|
1123
|
+
## Customising the response shape
|
|
1124
|
+
|
|
1125
|
+
The default body is JSON for XHR + redirects for form POST. To customise:
|
|
1126
|
+
|
|
1127
|
+
```ts
|
|
1128
|
+
const r = await handleSignIn({ ... }, store, AUTH_CONFIG)
|
|
1129
|
+
// r.status, r.setCookie, r.location are stable.
|
|
1130
|
+
// Replace r.body with your own JSON / HTML / template render.
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
For instance, returning HTML on the sign-in page itself (no redirect) for a "you're signed in — refresh to continue" flow.
|
|
1134
|
+
|
|
1135
|
+
## MFA / TOTP handlers (shipped)
|
|
1136
|
+
|
|
1137
|
+
MFA is enforced at sign-in, not just enrolled. `handleSignIn` reads `user.mfaEnrolledAt`: for an enrolled user it does **not** issue a session — it mints a single-use, short-lived pending token (hash stored in `authTokens`) and returns `{ ok: true, mfaRequired: true, pendingToken }`. The second factor is completed separately:
|
|
1138
|
+
|
|
1139
|
+
- `handleMfaVerify({ pendingToken, code? , recoveryCode? }, store, config)` — redeems the pending token atomically (single-use), verifies the 6-digit TOTP `code` against the stored secret (or a single-use `recoveryCode` as the lost-authenticator fallback), and only then issues the real session via the same `issueSession` path as password sign-in (so rotation + revocation apply). A wrong code is `401`, and the challenge is consumed regardless — a guess can't be retried against the same token.
|
|
1140
|
+
|
|
1141
|
+
Enrolment is a separate ceremony (mount its routes with the plugin's `mfa: { issuer }` config):
|
|
1142
|
+
|
|
1143
|
+
- `handleMfaEnrollStart({ userId, issuer, accountName }, store)` — generates a TOTP secret, stashes it via `store.setMfaSecret`, returns `{ secret, otpauthUrl }` so the dashboard can render the QR.
|
|
1144
|
+
- `handleMfaEnrollVerify({ userId, code }, store)` — verifies the first code; on success marks the user enrolled via `store.markMfaEnrolled` **and returns a one-time batch of `recoveryCodes`** (shown once, stored hashed via `store.replaceRecoveryCodes`).
|
|
1145
|
+
- `handleMfaRegenerateRecoveryCodes({ userId }, store)` — wipes + reissues the recovery-code set.
|
|
1146
|
+
- `handleMfaUnenroll({ userId }, store)` — clears the secret via `store.clearMfa` and wipes the recovery codes.
|
|
1147
|
+
|
|
1148
|
+
They return the same `Effect<HandlerResult>` shape as the others. The `UserStore` MFA methods (`setMfaSecret` / `markMfaEnrolled` / `clearMfa` / `replaceRecoveryCodes` / `consumeRecoveryCode` / `countRecoveryCodes`) back them; both `memoryUserStore` and `postgresUserStore` implement them.
|
|
1149
|
+
|
|
1150
|
+
## Magic link + password reset
|
|
1151
|
+
|
|
1152
|
+
`handleMagicLinkRequest` / `handleMagicLinkConsume` and `handlePasswordResetRequest` / `handlePasswordResetConfirm` round out the passwordless + recovery flows. The request handlers always return `202` (no account-enumeration); they mint a single-use, hashed, expiring token (`@voltro/plugin-auth/tokens`), persist its hash via `store.insertToken`, and call the injected `config.sendEmail` hook to deliver the link. The consume/confirm handlers redeem the token atomically via `store.consumeToken` (single-use guard) — `handleMagicLinkConsume` issues a session, `handlePasswordResetConfirm` sets the new hashed password and revokes every existing session.
|
|
1153
|
+
|
|
1154
|
+
```ts
|
|
1155
|
+
const requested = await Effect.runPromise(
|
|
1156
|
+
handleMagicLinkRequest({ email }, store, { ...config, appBaseUrl: 'https://app.example.com', sendEmail }),
|
|
1157
|
+
) // 202 regardless of whether `email` exists
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
## Sessions, memberships, switch-tenant
|
|
1161
|
+
|
|
1162
|
+
`handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` back the device-management UI (`handleSignIn` writes a `sessions` row on every login). `handleListMemberships` + `handleSwitchTenant` drive multi-tenant switching — `handleSwitchTenant` validates membership via `store.membershipRole`, re-issues the cookie with the new active tenant, and rebinds the live connection's Subject when a `clientId` + `rebind` callback are supplied.
|
|
1163
|
+
|
|
1164
|
+
## CSRF + passkeys
|
|
1165
|
+
|
|
1166
|
+
`handleCsrf` issues a signed double-submit token (`voltro:csrf` cookie + JSON body). The passkey ceremony handlers (`handlePasskeyRegisterOptions` / `…RegisterVerify` / `…AssertOptions` / `…AssertVerify`) live in `@voltro/plugin-auth` and verify challenge, origin, rpId hash, signature, and a strictly-increasing counter. You don't mount any of these by hand — `authRoutesPlugin()` mounts the whole set under `/auth`.
|
|
1167
|
+
|
|
1168
|
+
## See also
|
|
1169
|
+
|
|
1170
|
+
- [Auth plugin overview](/docs/plugins/auth) — `authRoutesPlugin()`, the mounted route table, passkeys, rotation
|
|
1171
|
+
- [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
|
|
1172
|
+
- [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
|
|
1173
|
+
- [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
|
|
1174
|
+
- [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints
|
|
1175
|
+
|
|
1176
|
+
- [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
|
|
1177
|
+
- [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
|
|
1178
|
+
- [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
|
|
1179
|
+
- [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
---
|
|
1184
|
+
|
|
1185
|
+
<!-- source: en/authentication/user-stores.md -->
|
|
1186
|
+
## User stores
|
|
1187
|
+
|
|
1188
|
+
_memoryUserStore, postgresUserStore, and writing your own UserStore against a different backend._
|
|
1189
|
+
|
|
1190
|
+
A `UserStore` is the interface between `@voltro/plugin-auth` and your user data. The plugin ships two implementations + the interface so you can plug in your own.
|
|
1191
|
+
|
|
1192
|
+
## The interface
|
|
1193
|
+
|
|
1194
|
+
Every method returns an **Effect**, not a Promise:
|
|
1195
|
+
|
|
1196
|
+
```ts
|
|
1197
|
+
import type { UserStore } from '@voltro/plugin-auth'
|
|
1198
|
+
import { Effect } from 'effect'
|
|
1199
|
+
|
|
1200
|
+
interface UserStore {
|
|
1201
|
+
findByEmail: (email: string) => Effect.Effect<UserRecord | null>
|
|
1202
|
+
findById: (id: string) => Effect.Effect<UserRecord | null>
|
|
1203
|
+
insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>
|
|
1204
|
+
// MFA / TOTP enrolment — back the handlers in HTTP handlers § MFA.
|
|
1205
|
+
setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>
|
|
1206
|
+
markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
|
|
1207
|
+
clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
interface UserRecord {
|
|
1211
|
+
readonly id: string
|
|
1212
|
+
readonly email: string
|
|
1213
|
+
readonly passwordHash: string
|
|
1214
|
+
readonly tenantId: string
|
|
1215
|
+
readonly createdAt: Date
|
|
1216
|
+
readonly mfaSecret?: string | null // base32 TOTP secret; null = not enrolled
|
|
1217
|
+
readonly mfaEnrolledAt?: Date | null // first successful verify; null until enrolled
|
|
1218
|
+
}
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
There is no `updatePasswordHash` method and no `updatedAt` field. The interface is deliberately small. Add your own fields (display name, avatar URL, locale) on a sibling table joined by `userId` — keep the auth-critical fields in the auth table.
|
|
1222
|
+
|
|
1223
|
+
## memoryUserStore
|
|
1224
|
+
|
|
1225
|
+
For dev + tests:
|
|
1226
|
+
|
|
1227
|
+
```ts
|
|
1228
|
+
import { memoryUserStore, hashPassword } from '@voltro/plugin-auth'
|
|
1229
|
+
import { Effect } from 'effect'
|
|
1230
|
+
|
|
1231
|
+
const store = memoryUserStore()
|
|
1232
|
+
await Effect.runPromise(Effect.gen(function* () {
|
|
1233
|
+
yield* store.insert({
|
|
1234
|
+
id: 'usr_demo',
|
|
1235
|
+
email: 'demo@example.com',
|
|
1236
|
+
passwordHash: yield* hashPassword('voltro-demo-2026'),
|
|
1237
|
+
tenantId: 'acme',
|
|
1238
|
+
})
|
|
1239
|
+
}))
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
`memoryUserStore(seed?)` accepts an optional seed array of `UserRecord`s.
|
|
1243
|
+
|
|
1244
|
+
Lives in-process. Restart = data gone. Use it for CI smoke tests + the cloud `/demo` query.
|
|
1245
|
+
|
|
1246
|
+
## postgresUserStore
|
|
1247
|
+
|
|
1248
|
+
For production:
|
|
1249
|
+
|
|
1250
|
+
```ts
|
|
1251
|
+
import { postgresUserStore } from '@voltro/plugin-auth'
|
|
1252
|
+
import { SqlClient } from '@effect/sql'
|
|
1253
|
+
import { Effect } from 'effect'
|
|
1254
|
+
|
|
1255
|
+
// postgresUserStore is SYNCHRONOUS — it takes an @effect/sql SqlClient and
|
|
1256
|
+
// returns a UserStore directly. No await, no { url }, no internal pool.
|
|
1257
|
+
// The caller owns the SqlClient's lifecycle.
|
|
1258
|
+
const program = Effect.gen(function* () {
|
|
1259
|
+
const sql = yield* SqlClient.SqlClient
|
|
1260
|
+
const store = postgresUserStore(sql)
|
|
1261
|
+
// … use store …
|
|
1262
|
+
})
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
What it does:
|
|
1266
|
+
|
|
1267
|
+
- Runs its queries through the **provided** `SqlClient` — it does NOT open or own a connection.
|
|
1268
|
+
- Reads + writes the `users` table from `@voltro/plugin-auth/schema`.
|
|
1269
|
+
- Uses parameterised queries — no SQL injection.
|
|
1270
|
+
- Maps the Postgres `23505` unique-violation on `email` to a typed `UserAlreadyExistsError` on insert; other SQL errors become defects.
|
|
1271
|
+
|
|
1272
|
+
The schema you need is in your `database/schema.ts`:
|
|
1273
|
+
|
|
1274
|
+
```ts
|
|
1275
|
+
import { usersTable } from '@voltro/plugin-auth/schema'
|
|
1276
|
+
export const users = usersTable
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
Run `voltro migrate` after adding this — the table + indexes (`email` UNIQUE, `tenantId` btree) get created.
|
|
1280
|
+
|
|
1281
|
+
## Custom store — example: external IdP
|
|
1282
|
+
|
|
1283
|
+
If you sync users from an external identity provider, write a store that reads from your provider's API + your local cache:
|
|
1284
|
+
|
|
1285
|
+
```ts
|
|
1286
|
+
import type { UserStore } from '@voltro/plugin-auth'
|
|
1287
|
+
import { Effect } from 'effect'
|
|
1288
|
+
|
|
1289
|
+
const okta = (): UserStore => ({
|
|
1290
|
+
findByEmail: (email) =>
|
|
1291
|
+
Effect.promise(async () => {
|
|
1292
|
+
const oktaUser = await fetch(`https://okta.acme.com/api/v1/users?email=${email}`)
|
|
1293
|
+
.then((r) => r.json())
|
|
1294
|
+
if (!oktaUser) return null
|
|
1295
|
+
return {
|
|
1296
|
+
id: `okta:${oktaUser.id}`,
|
|
1297
|
+
email: oktaUser.profile.email,
|
|
1298
|
+
passwordHash: '', // we never auth password locally — see below
|
|
1299
|
+
tenantId: oktaUser.profile.tenantId,
|
|
1300
|
+
createdAt: new Date(oktaUser.created),
|
|
1301
|
+
}
|
|
1302
|
+
}),
|
|
1303
|
+
// … the remaining methods (findById, insert, setMfaSecret,
|
|
1304
|
+
// markMfaEnrolled, clearMfa) all return Effects too …
|
|
1305
|
+
})
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
For an external IdP you usually wouldn't use `handleSignIn` at all — sign-in happens at the IdP and a [strategy](/docs/authentication/strategies) (e.g. the Okta path of `@voltro/plugin-auth-oidc`) verifies the resulting JWT. A custom `UserStore` only matters if you also need to persist a local mirror.
|
|
1309
|
+
|
|
1310
|
+
## Custom store — example: scoped extension
|
|
1311
|
+
|
|
1312
|
+
If you want to keep `postgresUserStore` for the auth-critical bits but augment with your own fields:
|
|
1313
|
+
|
|
1314
|
+
```ts
|
|
1315
|
+
import { postgresUserStore } from '@voltro/plugin-auth'
|
|
1316
|
+
import type { UserStore } from '@voltro/plugin-auth'
|
|
1317
|
+
import { Effect } from 'effect'
|
|
1318
|
+
|
|
1319
|
+
const base = postgresUserStore(sql) // sql: a provided SqlClient
|
|
1320
|
+
|
|
1321
|
+
const extended: UserStore = {
|
|
1322
|
+
...base,
|
|
1323
|
+
insert: (user) =>
|
|
1324
|
+
base.insert(user).pipe(
|
|
1325
|
+
Effect.tap((inserted) =>
|
|
1326
|
+
// Side-effect: also create a profile row
|
|
1327
|
+
Effect.promise(() =>
|
|
1328
|
+
ctx.store.insert('profiles', { userId: inserted.id, displayName: '' }),
|
|
1329
|
+
),
|
|
1330
|
+
),
|
|
1331
|
+
),
|
|
1332
|
+
}
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
Wrap don't fork — the base store is maintained by the framework; you keep your extensions in your code.
|
|
1336
|
+
|
|
1337
|
+
## Tenant on sign-up
|
|
1338
|
+
|
|
1339
|
+
`UserStore.insert(record)` requires `tenantId`. The handler picks it from:
|
|
1340
|
+
|
|
1341
|
+
1. `record.tenantId` if you pass it explicitly (custom handler with an invite flow).
|
|
1342
|
+
2. `AuthConfig.defaultTenantId` otherwise.
|
|
1343
|
+
|
|
1344
|
+
For real apps, you want one of:
|
|
1345
|
+
|
|
1346
|
+
- **Per-invite tenant** — store an `invites` table; sign-up consumes an invite token that names the tenant.
|
|
1347
|
+
- **One-tenant-per-email-domain** — derive tenant from email domain on sign-up.
|
|
1348
|
+
- **Self-serve tenant create** — sign-up creates a new tenant + the user is its admin.
|
|
1349
|
+
|
|
1350
|
+
The framework doesn't pick for you; the auth plugin ships the primitives + you wire the policy.
|
|
1351
|
+
|
|
1352
|
+
## Anti-patterns
|
|
1353
|
+
|
|
1354
|
+
- **Storing the password hash with the user-facing record.** Even with `HttpOnly` cookies, accidentally returning `passwordHash` from a query is a disaster. Use a separate "PublicUser" type for everything that crosses the wire.
|
|
1355
|
+
- **Using `tenantId: undefined` for "global" users.** Voltro doesn't model global users — every user belongs to exactly one tenant. For cross-tenant admins, use a separate `staff` table or the `system` subject.
|
|
1356
|
+
- **Auth + business data in the same table.** Keep `users` minimal (email, hash, tenantId, timestamps). Profile data, settings, etc. go in joined tables.
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
---
|
|
1361
|
+
|
|
1362
|
+
<!-- source: en/authentication/react.md -->
|
|
1363
|
+
## React on the web side
|
|
1364
|
+
|
|
1365
|
+
_SubjectProvider, useSubject, useOptionalSubject, RequireAuth — the client-side auth surface from @voltro/plugin-auth/web._
|
|
1366
|
+
|
|
1367
|
+
`@voltro/plugin-auth/web` is the client-side surface. It never imports `node:crypto`, so it's safe to bundle into the browser. The cookie is verified on the server; this package only provides React glue for "what does the current user look like".
|
|
1368
|
+
|
|
1369
|
+
## SubjectProvider
|
|
1370
|
+
|
|
1371
|
+
Mount once near the top of your layout tree:
|
|
1372
|
+
|
|
1373
|
+
```tsx
|
|
1374
|
+
import { SubjectProvider } from '@voltro/plugin-auth/web'
|
|
1375
|
+
import { useServerRequest } from '@voltro/web'
|
|
1376
|
+
import { decodeSubjectFromRequest } from '../lib/auth'
|
|
1377
|
+
|
|
1378
|
+
export default function Layout({ children }) {
|
|
1379
|
+
const req = useServerRequest() // SSR snapshot of cookies + headers
|
|
1380
|
+
const subject = decodeSubjectFromRequest(req)
|
|
1381
|
+
return <SubjectProvider subject={subject}>{children}</SubjectProvider>
|
|
1382
|
+
}
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
`subject` can be `null` (anonymous) or a real `Subject` (signed in). The provider just stores it in a React context — no async work, no transport.
|
|
1386
|
+
|
|
1387
|
+
## useSubject
|
|
1388
|
+
|
|
1389
|
+
```tsx
|
|
1390
|
+
import { useSubject } from '@voltro/plugin-auth/web'
|
|
1391
|
+
|
|
1392
|
+
const Component = () => {
|
|
1393
|
+
const subject = useSubject() // throws if no SubjectProvider OR signed out
|
|
1394
|
+
return <p>Hi {subject.id}</p>
|
|
1395
|
+
}
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
Throws when:
|
|
1399
|
+
|
|
1400
|
+
- No `<SubjectProvider>` is mounted (dev mistake).
|
|
1401
|
+
- The mounted provider has `subject={null}` (you're calling `useSubject` from outside a guarded subtree).
|
|
1402
|
+
|
|
1403
|
+
Use inside `<RequireAuth>` blocks or pages you've already gated.
|
|
1404
|
+
|
|
1405
|
+
## useOptionalSubject
|
|
1406
|
+
|
|
1407
|
+
```tsx
|
|
1408
|
+
import { useOptionalSubject } from '@voltro/plugin-auth/web'
|
|
1409
|
+
|
|
1410
|
+
const Header = () => {
|
|
1411
|
+
const subject = useOptionalSubject() // Subject | null
|
|
1412
|
+
return subject
|
|
1413
|
+
? <UserMenu subject={subject} />
|
|
1414
|
+
: <SignInButton />
|
|
1415
|
+
}
|
|
1416
|
+
```
|
|
1417
|
+
|
|
1418
|
+
The right hook for "what do I render based on signed-in vs. not".
|
|
1419
|
+
|
|
1420
|
+
## RequireAuth
|
|
1421
|
+
|
|
1422
|
+
A render-gate component:
|
|
1423
|
+
|
|
1424
|
+
```tsx
|
|
1425
|
+
import { RequireAuth } from '@voltro/plugin-auth/web'
|
|
1426
|
+
|
|
1427
|
+
const Dashboard = () => (
|
|
1428
|
+
<RequireAuth fallback={<RedirectToSignIn />}>
|
|
1429
|
+
{(subject) => (
|
|
1430
|
+
<div>
|
|
1431
|
+
<h1>Hi {subject.id}</h1>
|
|
1432
|
+
<Projects tenantId={subject.tenantId} />
|
|
1433
|
+
</div>
|
|
1434
|
+
)}
|
|
1435
|
+
</RequireAuth>
|
|
1436
|
+
)
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
- Children can be plain JSX or a `(subject: Subject) => ReactNode` render-prop. The render-prop gives you typed Subject access without an extra `useSubject` call.
|
|
1440
|
+
- `fallback` defaults to `null` — pass a component to redirect / show a sign-in form.
|
|
1441
|
+
|
|
1442
|
+
## Decoding the subject on SSR
|
|
1443
|
+
|
|
1444
|
+
The session cookie value is opaque + signed — on the server you'd verify with HMAC + `AUTH_SECRET`. On the client (which is what the framework's SSR usually serves at request time), you have two choices:
|
|
1445
|
+
|
|
1446
|
+
### Option A — trust-then-verify
|
|
1447
|
+
|
|
1448
|
+
The web app reads the cookie payload via base64 decode only, gets the subject for UI purposes. EVERY action that mutates state goes back to the api which re-verifies the signature.
|
|
1449
|
+
|
|
1450
|
+
```ts
|
|
1451
|
+
// web/src/lib/auth.ts
|
|
1452
|
+
export const decodeSubjectFromRequest = (req: ServerRequest | null): Subject | null => {
|
|
1453
|
+
if (!req) return null
|
|
1454
|
+
const cookie = req.cookies['voltro:session']
|
|
1455
|
+
if (!cookie) return null
|
|
1456
|
+
const [payloadB64] = cookie.split('.')
|
|
1457
|
+
if (!payloadB64) return null
|
|
1458
|
+
try {
|
|
1459
|
+
// The payload is `{ subject, exp }` — the whole Subject round-trips
|
|
1460
|
+
// inside the cookie, so read `payload.subject`, not a `sub` claim.
|
|
1461
|
+
const payload = JSON.parse(atob(payloadB64))
|
|
1462
|
+
return payload.subject ?? null
|
|
1463
|
+
} catch {
|
|
1464
|
+
return null
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
The downside: a tampered cookie shows the wrong UI. The upside: the api validates on every real action so the worst case is "wrong avatar in the header".
|
|
1470
|
+
|
|
1471
|
+
This is the cloud dashboard's pattern — keep `node:crypto` out of the web bundle.
|
|
1472
|
+
|
|
1473
|
+
### Option B — verify on the api, ship the decoded subject
|
|
1474
|
+
|
|
1475
|
+
The api exposes a `/auth/me` endpoint that decodes the cookie + returns the verified Subject. The web app's loader calls it.
|
|
1476
|
+
|
|
1477
|
+
```tsx
|
|
1478
|
+
export const loader = async ({ headers }) => {
|
|
1479
|
+
const res = await fetch(`${API}/auth/me`, { headers: { cookie: headers.cookie } })
|
|
1480
|
+
return res.ok ? await res.json() : null
|
|
1481
|
+
}
|
|
1482
|
+
```
|
|
1483
|
+
|
|
1484
|
+
The downside: one extra request per page load. The upside: cryptographically verified subject.
|
|
1485
|
+
|
|
1486
|
+
For high-stakes apps (banking, healthcare), use Option B. For typical SaaS, Option A + server-side verification on actions is fine.
|
|
1487
|
+
|
|
1488
|
+
## Sign-out button
|
|
1489
|
+
|
|
1490
|
+
Always a form POST — `HttpOnly` cookies mean JS can't clear them, only the server (via a `Set-Cookie: …; Max-Age=0`).
|
|
1491
|
+
|
|
1492
|
+
```tsx
|
|
1493
|
+
<form action={`${API}/auth/signout`} method="post">
|
|
1494
|
+
<button type="submit">Sign out</button>
|
|
1495
|
+
</form>
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
`SameSite=lax` cookies flow on form POSTs even cross-origin.
|
|
1499
|
+
|
|
1500
|
+
## Sign-in form
|
|
1501
|
+
|
|
1502
|
+
```tsx
|
|
1503
|
+
<form action={`${API}/auth/signin`} method="post">
|
|
1504
|
+
<input name="email" type="email" required />
|
|
1505
|
+
<input name="password" type="password" required />
|
|
1506
|
+
<button type="submit">Sign in</button>
|
|
1507
|
+
</form>
|
|
1508
|
+
```
|
|
1509
|
+
|
|
1510
|
+
The api 302-redirects to `AUTH_CONFIG.successRedirect` on success. The next page load reads the cookie via `<SubjectProvider>` + your auth-gated UI lights up.
|
|
1511
|
+
|
|
1512
|
+
For inline-validation / "shake on bad password" UX, use a fetch-based form + the JSON response variant of `handleSignIn`.
|
|
1513
|
+
|
|
1514
|
+
## Switching tenants
|
|
1515
|
+
|
|
1516
|
+
A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships — read them on the client with `subjectMemberships(subject)` to render a tenant switcher:
|
|
1517
|
+
|
|
1518
|
+
```tsx
|
|
1519
|
+
import { useSubject } from '@voltro/plugin-auth/web'
|
|
1520
|
+
import { subjectMemberships } from '@voltro/plugin-auth'
|
|
1521
|
+
|
|
1522
|
+
const TenantSwitcher = () => {
|
|
1523
|
+
const subject = useSubject()
|
|
1524
|
+
const memberships = subjectMemberships(subject)
|
|
1525
|
+
const switchTo = async (tenantId: string) => {
|
|
1526
|
+
await fetch('/auth/switch-tenant', {
|
|
1527
|
+
method: 'POST',
|
|
1528
|
+
credentials: 'include',
|
|
1529
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
|
|
1530
|
+
body: JSON.stringify({ targetTenantId: tenantId }),
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
return (
|
|
1534
|
+
<ul>
|
|
1535
|
+
{memberships.map((m) => (
|
|
1536
|
+
<li key={m.tenantId}>
|
|
1537
|
+
<button disabled={m.tenantId === subject.tenantId} onClick={() => switchTo(m.tenantId)}>
|
|
1538
|
+
{m.tenantId} ({m.role})
|
|
1539
|
+
</button>
|
|
1540
|
+
</li>
|
|
1541
|
+
))}
|
|
1542
|
+
</ul>
|
|
1543
|
+
)
|
|
1544
|
+
}
|
|
1545
|
+
```
|
|
1546
|
+
|
|
1547
|
+
`POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket — rebinds the connection's Subject so active subscriptions re-scope to the new tenant without a reconnect. No re-auth.
|
|
1548
|
+
|
|
1549
|
+
## Anti-patterns
|
|
1550
|
+
|
|
1551
|
+
- **Reading `document.cookie` for the session.** It's `HttpOnly` — you can't. Use `useSubject` / `useOptionalSubject`.
|
|
1552
|
+
- **Storing the subject in `localStorage` for "fast access".** It goes stale, leaks via XSS, and there's no security model that justifies it. The cookie IS the cache.
|
|
1553
|
+
- **Calling auth hooks in non-React contexts.** They depend on React context — use them inside components or custom hooks.
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
---
|
|
1558
|
+
|
|
1559
|
+
<!-- source: en/authentication/cookies.md -->
|
|
1560
|
+
## Cookie security
|
|
1561
|
+
|
|
1562
|
+
_The production cookie checklist — Secure, HttpOnly, SameSite, Domain, Path, Max-Age, CSRF defence._
|
|
1563
|
+
|
|
1564
|
+
A session cookie carries the keys to the user's account. Get the attributes wrong and you're handing them out to attackers. This page is the audit list.
|
|
1565
|
+
|
|
1566
|
+
## The attributes that matter
|
|
1567
|
+
|
|
1568
|
+
| Attribute | Value | Why |
|
|
1569
|
+
|---|---|---|
|
|
1570
|
+
| `Secure` | `true` in production | Cookie only sent over HTTPS. Without it, an attacker on the network reads the cookie out of an HTTP request. |
|
|
1571
|
+
| `HttpOnly` | always | JS can't read it. Defeats XSS-extraction of session tokens. |
|
|
1572
|
+
| `SameSite` | `lax` (hardcoded) | CSRF defence. Not configurable; the plugin never emits `strict` or `none`. |
|
|
1573
|
+
| `Domain` | unset (default) OR `.your-product.com` | Unset = host-only (strict). Set = shared across subdomains. |
|
|
1574
|
+
| `Path` | `/` | Always covers the whole app. Narrower paths cause subtle "cookie missing on some requests" bugs. |
|
|
1575
|
+
| `Max-Age` | configurable, default 7 days | Session lifetime. |
|
|
1576
|
+
|
|
1577
|
+
## Production AuthConfig
|
|
1578
|
+
|
|
1579
|
+
```ts
|
|
1580
|
+
const AUTH_CONFIG: AuthConfig = {
|
|
1581
|
+
secret: process.env.AUTH_SECRET!, // 32+ bytes from a CSPRNG
|
|
1582
|
+
defaultTenantId: 'public',
|
|
1583
|
+
cookieSecure: true, // ALWAYS true in prod
|
|
1584
|
+
cookieDomain: '.your-product.com', // optional; for subdomain sharing
|
|
1585
|
+
successRedirect: 'https://app.your-product.com/',
|
|
1586
|
+
}
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
`AuthConfig` is exactly `{ secret, defaultTenantId, cookieDomain?, cookieSecure?, successRedirect? }`. **`SameSite` is not configurable** — the session builder hardcodes `SameSite=lax`. There is no `cookieSameSite` field.
|
|
1590
|
+
|
|
1591
|
+
## SameSite is always lax
|
|
1592
|
+
|
|
1593
|
+
The cookie ships `SameSite=lax`. That's the right choice for the typical sign-in flow: `lax` cookies are sent on top-level GETs and cross-origin form POSTs, so the cookie lands before the post-sign-in redirect. `strict` would block cross-origin sign-in entirely, and `none` (cross-site cookies) is a footgun the framework simply doesn't emit. If you want the stricter same-origin posture, put api + web behind one reverse proxy (below) — same-origin requests carry the cookie regardless.
|
|
1594
|
+
|
|
1595
|
+
## Same-origin reverse proxy (recommended)
|
|
1596
|
+
|
|
1597
|
+
The cleanest production layout:
|
|
1598
|
+
|
|
1599
|
+
```
|
|
1600
|
+
https://acme.com/ → web (landing + dashboard)
|
|
1601
|
+
https://acme.com/api/* → api app
|
|
1602
|
+
https://acme.com/docs/* → docs site
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
Caddyfile:
|
|
1606
|
+
|
|
1607
|
+
```caddyfile
|
|
1608
|
+
acme.com {
|
|
1609
|
+
reverse_proxy /api/* api:4000
|
|
1610
|
+
reverse_proxy /docs/* docs:5181
|
|
1611
|
+
reverse_proxy * web:5191
|
|
1612
|
+
}
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
With this:
|
|
1616
|
+
|
|
1617
|
+
- One origin to the browser = `SameSite=strict` works
|
|
1618
|
+
- No `Domain` attribute needed (host-only is fine)
|
|
1619
|
+
- No CORS preflight noise
|
|
1620
|
+
- One TLS certificate covers everything
|
|
1621
|
+
|
|
1622
|
+
This is the layout we recommend for any deploy that isn't multi-region.
|
|
1623
|
+
|
|
1624
|
+
## Cross-origin (split api / web)
|
|
1625
|
+
|
|
1626
|
+
For multi-region + edge-cached web with the api in one region:
|
|
1627
|
+
|
|
1628
|
+
```
|
|
1629
|
+
https://acme.com → web (CDN-cached)
|
|
1630
|
+
https://api.acme.com → api (one region)
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
You need:
|
|
1634
|
+
|
|
1635
|
+
- `cookieDomain: '.acme.com'` so the cookie is set by api.acme.com but flows on acme.com requests
|
|
1636
|
+
- `SameSite=lax` (already the hardcoded default) so form POST sign-in works
|
|
1637
|
+
- CORS allow-list on the api: `Access-Control-Allow-Origin: https://acme.com` + `Access-Control-Allow-Credentials: true`
|
|
1638
|
+
|
|
1639
|
+
The cookie boundary is the registrable domain (`acme.com`), so `Domain=.acme.com` covers both subdomains.
|
|
1640
|
+
|
|
1641
|
+
## CSRF defence
|
|
1642
|
+
|
|
1643
|
+
`SameSite=lax` blocks GET-based CSRF + most cross-origin write attempts, and it's the first line of defence. On top of it, the plugin ships a signed double-submit CSRF token: `GET /auth/csrf` (handler `handleCsrf`) sets a readable `voltro:csrf` cookie + returns the token in JSON; the SPA echoes it in the `x-csrf-token` header on every state-changing call, and `authRoutesPlugin()` rejects authenticated mutations whose header and cookie don't match a server-signed token (`verifyCsrf`). The token is HMAC-signed so a cookie-injecting network attacker can't forge one.
|
|
1644
|
+
|
|
1645
|
+
## Cookie secret
|
|
1646
|
+
|
|
1647
|
+
```bash
|
|
1648
|
+
# Generate a strong secret
|
|
1649
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1652
|
+
Store it:
|
|
1653
|
+
|
|
1654
|
+
- **Production:** in your secrets manager (AWS SM, Doppler, 1Password, Vercel env). NEVER commit to git.
|
|
1655
|
+
- **Staging:** different secret than production. Rotating one shouldn't kill the other.
|
|
1656
|
+
- **CI:** mocked or randomly-generated per-job for tests.
|
|
1657
|
+
|
|
1658
|
+
## Rotation
|
|
1659
|
+
|
|
1660
|
+
Multi-key rotation ships. Set the new secret as `VOLTRO_SESSION_SECRET` and move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS`; `resolveSessionSecrets()` returns both, `signSession` stamps the current key's `kid`, and `verifySessionKeyed` accepts either during the rotation window. Decommission the old key (drop `_PREVIOUS`) after the max session lifetime — nobody is signed out.
|
|
1661
|
+
|
|
1662
|
+
## Audit checklist before going to prod
|
|
1663
|
+
|
|
1664
|
+
- [ ] `cookieSecure: true`
|
|
1665
|
+
- [ ] `AUTH_SECRET` is 32+ bytes from a real CSPRNG, in a secrets manager
|
|
1666
|
+
- [ ] HTTPS terminates at the edge with HSTS preload enabled
|
|
1667
|
+
- [ ] Cookies are HttpOnly (verify in browser devtools → Application → Cookies)
|
|
1668
|
+
- [ ] No JavaScript reads `document.cookie` for the session
|
|
1669
|
+
- [ ] CORS allow-list is exact origins (no `*` with credentials)
|
|
1670
|
+
- [ ] Sign-out POST endpoint exists and clears the cookie
|
|
1671
|
+
- [ ] Different `AUTH_SECRET` per environment
|
|
1672
|
+
- [ ] Session `Max-Age` matches your risk profile (not 365 days for a banking app)
|
|
1673
|
+
|
|
1674
|
+
## What the framework does NOT defend against
|
|
1675
|
+
|
|
1676
|
+
- **Server-side bugs that leak the cookie value** (e.g. logging `req.headers.cookie` to an aggregator). Audit your logging pipeline.
|
|
1677
|
+
- **Compromised user device.** A keylogger on a user's machine sees the password. TOTP/MFA enrolment ships today (see [HTTP handlers](/docs/authentication/handlers#mfa--totp-handlers-shipped)); passkeys ship too (phishing-resistant, no shared secret to keylog — see the [auth plugin overview](/docs/plugins/auth#passkeys--webauthn)). Ultimately devices have to be trusted.
|
|
1678
|
+
- **Compromised provider.** If your secrets manager leaks `AUTH_SECRET`, every session is forgeable. Rotate immediately + invalidate every session.
|
|
1679
|
+
- **Phishing.** A user signing in on an attacker-controlled site → attacker has the cookie. Defence is browser-level (SSL pinning, password managers refusing to autofill on wrong domain). Passkeys help here directly: WebAuthn binds the credential to the origin, so an assertion produced on a phishing domain fails the origin/rpId check server-side.
|
|
1680
|
+
|
|
1681
|
+
For high-stakes apps (banking, healthcare), layer on:
|
|
1682
|
+
|
|
1683
|
+
- Step-up auth for sensitive operations (re-prompt for a TOTP code or password before billing changes)
|
|
1684
|
+
- Device fingerprinting + new-device alerts
|
|
1685
|
+
- IP-based anomaly detection
|
|
1686
|
+
- Login notifications via email
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
---
|
|
1691
|
+
|
|
1692
|
+
<!-- source: en/authentication/authorization.md -->
|
|
1693
|
+
## Authorization (ReBAC)
|
|
1694
|
+
|
|
1695
|
+
_Relationship-based access control — declare per-resource policies (which relations grant which actions), decide with can()/assertCan() (fail-closed, typed AccessDenied), hide forbidden rows from reads, and reactively drop rows from open subscriptions the moment a grant is revoked._
|
|
1696
|
+
|
|
1697
|
+
Authorization is **relationship-based (ReBAC)**: access follows from
|
|
1698
|
+
*relationships* between a subject and a resource (`owner`, `editor`, `viewer`, a
|
|
1699
|
+
team membership) rather than a flat role. You declare a per-resource policy, the
|
|
1700
|
+
engine decides with `can(...)`, and — because the framework is reactive — a
|
|
1701
|
+
**revoked grant removes the now-forbidden rows from every open subscription
|
|
1702
|
+
live**, with no refresh.
|
|
1703
|
+
|
|
1704
|
+
## Relationship tuples
|
|
1705
|
+
|
|
1706
|
+
A tuple says *subject `S` has relation `R` on `<type>:<id>`*. They're plain rows
|
|
1707
|
+
(the framework's `_voltro_rebac_tuples` table, or your own relation rows):
|
|
1708
|
+
|
|
1709
|
+
```ts
|
|
1710
|
+
// alice is the owner of todo:42 ; the acme team can view it
|
|
1711
|
+
{ subjectId: 'alice', relation: 'owner', resourceType: 'todo', resourceId: '42' }
|
|
1712
|
+
{ subjectId: 'acme', relation: 'viewer', resourceType: 'todo', resourceId: '42' }
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
## Declare a policy
|
|
1716
|
+
|
|
1717
|
+
`defineResourcePolicy` maps each action to the relations that grant it (ANY-of),
|
|
1718
|
+
with optional relation `implies` (a transitive closure — `owner ⇒ editor ⇒
|
|
1719
|
+
viewer`). Registered at import; the capability map exposes the graph.
|
|
1720
|
+
|
|
1721
|
+
```ts
|
|
1722
|
+
import { defineResourcePolicy } from '@voltro/runtime'
|
|
1723
|
+
|
|
1724
|
+
export const todoPolicy = defineResourcePolicy({
|
|
1725
|
+
resourceType: 'todo',
|
|
1726
|
+
actions: {
|
|
1727
|
+
view: ['viewer', 'editor', 'owner'],
|
|
1728
|
+
edit: ['editor', 'owner'],
|
|
1729
|
+
delete: ['owner'],
|
|
1730
|
+
},
|
|
1731
|
+
implies: { owner: ['editor'], editor: ['viewer'] },
|
|
1732
|
+
})
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
## Decide — `can` / `assertCan`
|
|
1736
|
+
|
|
1737
|
+
`can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
|
|
1738
|
+
throws the typed `AccessDenied`. It **fails closed**: `admin:full` scope is the
|
|
1739
|
+
only bypass, an anonymous subject is denied, a cross-tenant resource is denied,
|
|
1740
|
+
an unknown action is denied — otherwise allow iff the subject's effective
|
|
1741
|
+
relations intersect the action's grant set.
|
|
1742
|
+
|
|
1743
|
+
```ts
|
|
1744
|
+
import { assertCan, loadResourceTuples, AccessDenied } from '@voltro/runtime'
|
|
1745
|
+
import { todoPolicy } from '../policies/todo.policy'
|
|
1746
|
+
|
|
1747
|
+
// in a mutation's *.server.ts
|
|
1748
|
+
const tuples = await loadResourceTuples(ctx.store, ctx.request.subject.id, 'todo', input.id)
|
|
1749
|
+
assertCan(
|
|
1750
|
+
ctx.request.subject,
|
|
1751
|
+
'edit',
|
|
1752
|
+
{ type: 'todo', id: input.id, tenantId: ctx.request.subject.tenantId },
|
|
1753
|
+
{ policy: todoPolicy, tuples },
|
|
1754
|
+
) // throws AccessDenied on a deny
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
Declare `error: AccessDenied` on the descriptor so the denial surfaces to the
|
|
1758
|
+
client **typed + pattern-matchable**, never a bare 500. For per-rpc enforcement
|
|
1759
|
+
without a hand-written guard, `buildRebacInterceptor` runs the same `can()` check
|
|
1760
|
+
in the rpc pipeline; `buildRebacReadFilter` / `visibleRows` hide forbidden rows
|
|
1761
|
+
from a read instead of failing it.
|
|
1762
|
+
|
|
1763
|
+
## Live revocation
|
|
1764
|
+
|
|
1765
|
+
Because reads are reactive, authorization is too. `revokedIds(before, after)`
|
|
1766
|
+
is the core: the ids a subject could see before a permission-changing write but
|
|
1767
|
+
not after. The reactive layer pushes that delta to every open subscription, so
|
|
1768
|
+
the moment alice's grant on `todo:42` is revoked, the row **disappears from her
|
|
1769
|
+
screen** — no refetch, no refresh. (Enforcement is phased first; revocation is
|
|
1770
|
+
the headline that builds on it.)
|
|
1771
|
+
|
|
1772
|
+
## Client
|
|
1773
|
+
|
|
1774
|
+
`useResourceCan` / `useResourceCans` resolve a subject's permission reactively
|
|
1775
|
+
(fail-closed), so the UI hides an action the moment it's revoked:
|
|
1776
|
+
|
|
1777
|
+
```tsx
|
|
1778
|
+
import { useResourceCan } from '@voltro/client'
|
|
1779
|
+
|
|
1780
|
+
const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
|
|
1781
|
+
// canEdit.allowed: boolean | undefined (undefined until resolved); reactive.
|
|
1782
|
+
```
|
|
1783
|
+
|
|
1784
|
+
## Capability map
|
|
1785
|
+
|
|
1786
|
+
`rebacPolicyGraph()` returns every resource type, its actions, the relations each
|
|
1787
|
+
grants, and the implication edges — the policy graph a dashboard or an AI agent
|
|
1788
|
+
reads to reason about authority without grepping the code.
|