@voltro/cli 0.30.1 → 0.31.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,95 @@
1
+ # OIDC (generic)
2
+
3
+ > Generic OIDC AuthStrategy — verifies any OpenID-Connect IdP's JWTs via JWKS (Okta, Keycloak, Cognito, Azure AD, Google Workspace). Discovery or explicit JWKS URL; maps a claim → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-oidc.md -->
10
+ ## OIDC (generic)
11
+
12
+ _Generic OIDC AuthStrategy — verifies any OpenID-Connect IdP's JWTs via JWKS (Okta, Keycloak, Cognito, Azure AD, Google Workspace). Discovery or explicit JWKS URL; maps a claim → tenantId._
13
+
14
+ `@voltro/plugin-auth-oidc` is the **generic** OIDC adapter: an `AuthStrategy` (conforming to `@voltro/protocol`) for any IdP that publishes a standards-compliant OpenID Connect discovery document — **Okta, Keycloak, Cognito, Azure AD, Google Workspace**, and anything else OIDC-compliant that doesn't ship a first-party plugin. It verifies the IdP's JWTs against its **JWKS** (public-key verify — no client secret), maps a configured claim to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It performs no verification of its own — it adds the JWKS-discovery indirection and delegates the verify pipeline to the shared `jwtBearerStrategy`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-oidc
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `oidcStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { oidcStrategy } from '@voltro/plugin-auth-oidc'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ oidcStrategy({
37
+ id: 'okta', // stable id for logs + metadata.provider
38
+ issuer: 'https://acme.okta.com', // also the discovery base
39
+ audience: 'api://acme', // expected `aud`
40
+ tenantClaim: 'acme/tenant_id', // which claim carries the Voltro tenantId
41
+ // jwksUrl: 'https://acme.okta.com/oauth2/v1/keys', // skip discovery roundtrip
42
+ // discoveryUrl: '…', // non-standard discovery path
43
+ // defaultTenantId: 'public', // fallback when the claim is absent
44
+ // scopesFromClaims: (c) => String(c['scope'] ?? '').split(' ').filter(Boolean),
45
+ }),
46
+ ],
47
+ },
48
+ }
49
+ ```
50
+
51
+ Two modes: pass `jwksUrl` for zero boot-time IO, or omit it and the strategy fetches `jwks_uri` from `${issuer}/.well-known/openid-configuration` on the first request (cached per-URL for the process lifetime). Pass `discoveryUrl` if your provider hosts the document at a non-standard path.
52
+
53
+ Tenant resolution: the value at `tenantClaim` (default `'tenant'`) becomes `tenantId`; or supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict — the strategy owns the request but can't satisfy the tenant invariant). `scopesFromClaims` maps verified claims onto `Subject.scopes` so `requireScope` / `hasScope` gate handlers off the token with no second lookup.
54
+
55
+ To accept your own password sessions AND OIDC during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
56
+
57
+ ```ts
58
+ import { composeAuthStrategies } from '@voltro/protocol'
59
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
60
+ import { oidcStrategy } from '@voltro/plugin-auth-oidc'
61
+
62
+ const resolve = composeAuthStrategies([
63
+ voltroPasswordStrategy(),
64
+ oidcStrategy({ id: 'keycloak', issuer: 'https://id.acme.com/realms/acme', tenantClaim: 'realm_access.tenant' }),
65
+ ])
66
+ ```
67
+
68
+ ## Options
69
+
70
+ | Option | Default | Notes |
71
+ |---|---|---|
72
+ | `id` | — (**required**) | Stable id for logs + `metadata.provider`. |
73
+ | `issuer` | — (**required**) | Expected `iss`; discovery base. |
74
+ | `audience` | (off) | Expected `aud`. String or array. |
75
+ | `jwksUrl` | (discovery) | Explicit JWKS URL — skips discovery. |
76
+ | `discoveryUrl` | `${issuer}/.well-known/openid-configuration` | Override for non-standard paths. |
77
+ | `algorithms` | `['RS256']` | Allowlist; extend with `ES256` / `PS256` / `EdDSA`. |
78
+ | `cookieName` | (off) | Cookie carrying the JWT; `null` disables cookie fallback. |
79
+ | `tenantClaim` | `'tenant'` | Claim carrying the Voltro `tenantId`. |
80
+ | `tenantIdFromClaims` | (uses `tenantClaim`) | Derive `tenantId`; `null` → `failed`. |
81
+ | `defaultTenantId` | (none) | Fallback when the claim is absent. |
82
+ | `scopesFromClaims` | (none) | Map claims → `Subject.scopes`. |
83
+
84
+ ## Environment variables
85
+
86
+ None read directly — pass `issuer` / `audience` / `jwksUrl` explicitly (typically from your own `defineEnv`-declared vars).
87
+
88
+ ## Security
89
+
90
+ Server-only; fails closed — a forged, expired, or wrong-audience token (or a discovery failure) yields `failed`, never `matched`. Asymmetric algorithms only (`HS256` is rejected — a shared-secret HMAC over a public JWKS flow is a downgrade vector).
91
+
92
+ ## See also
93
+
94
+ - [External identity providers](/docs/authentication/external-idp)
95
+ - [Auth plugin](/docs/plugins/auth)
@@ -0,0 +1,103 @@
1
+ # Supabase Auth
2
+
3
+ > Supabase Auth (GoTrue) AuthStrategy — verifies Supabase-issued JWTs via the project's JWKS and maps app_metadata.tenant_id → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-supabase.md -->
10
+ ## Supabase Auth
11
+
12
+ _Supabase Auth (GoTrue) AuthStrategy — verifies Supabase-issued JWTs via the project's JWKS and maps app_metadata.tenant_id → tenantId._
13
+
14
+ `@voltro/plugin-auth-supabase` integrates [Supabase Auth (GoTrue)](https://supabase.com/auth): an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies Supabase-issued JWTs — by default against the project's **JWKS endpoint** (asymmetric public-key verify, no secret), or via an opt-in **HS256 shared secret** (`jwtSecret`) for legacy / self-hosted deployments that sign symmetrically and expose no JWKS — maps a server-controlled metadata claim to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds Supabase's defaults and performs no verification of its own. Verified claims (both the server-controlled `app_metadata` and the user-controlled `user_metadata` blobs) arrive on the Subject under `metadata.claims`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-supabase
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `supabaseStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { supabaseStrategy } from '@voltro/plugin-auth-supabase'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ supabaseStrategy({
37
+ projectRef: 'abcdef', // builds the hosted JWKS URL
38
+ // OR self-hosted GoTrue — pass an explicit URL + issuer:
39
+ // jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
40
+ // issuer: 'https://my-supabase.example.com/auth/v1',
41
+ // OR legacy / self-hosted Supabase signing with the shared secret
42
+ // (HS256, no JWKS) — verify with the secret instead:
43
+ // jwtSecret: process.env.SUPABASE_JWT_SECRET!, // server-only credential
44
+ // tenantClaim: 'app_metadata.tenant_id', // default (dotted path)
45
+ // defaultTenantId: 'public', // fallback for single-tenant deployments
46
+ }),
47
+ ],
48
+ },
49
+ }
50
+ ```
51
+
52
+ Pass `projectRef` (hosted), `jwksUrl` (self-hosted GoTrue), or `jwtSecret` (legacy / self-hosted HS256 shared secret — see below). For a hosted project, the JWKS URL defaults to `https://<projectRef>.supabase.co/auth/v1/.well-known/jwks.json` and the issuer to `https://<projectRef>.supabase.co/auth/v1`. `audience` defaults to `'authenticated'` (Supabase's role for signed-in users).
53
+
54
+ Tenant resolution maps from `tenantClaim` — default the dotted path `app_metadata.tenant_id` (a server-controlled blob users can't forge), then `defaultTenantId`. Apps that route tenants via `user_metadata` set `tenantClaim: 'user_metadata.org'` (same dotted-path resolution). Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict).
55
+
56
+ `cookieName` defaults to `null` (header-only) — most Voltro apps send the token as an `Authorization: Bearer` header. For **server-rendered / `@supabase/ssr` apps** that keep the session in a cookie, set `cookieName: 'sb-<projectRef>-auth-token'`. `@supabase/ssr` does NOT store a raw JWT there — it stores the whole GoTrue session as a JSON envelope, by default prefixed with `base64-` and base64url-encoded, and (for large sessions) split across `sb-<projectRef>-auth-token.0`, `.1`, … chunk cookies. The strategy unwraps all of that — URL-decode, strip the `base64-` prefix + base64url-decode, concatenate chunks in order — down to the inner `access_token` before verifying it. The `Authorization: Bearer` header is always a raw JWT and is unaffected.
57
+
58
+ Strategies live on your **`type:'api'` app** — a `type:'web'` app has no auth middleware, so putting `auth.strategies` in a web `app.config.ts` does nothing. The web app forwards the request cookie to the api (including from [`ctx.query` in a loader](/docs/routing/loaders-and-meta#authentication-in-loaders)); the api's strategies verify it.
59
+
60
+ > Client side — this strategy only *verifies*; your browser must *send* the token. For HTTP that's the normal `Authorization: Bearer` header; for **live subscriptions over the WebSocket** (the usual cross-origin api), pass it to `mount({ apis: { api: { headers: async () => ({ authorization: 'Bearer ' + token }) } } })` — a browser can't set WS upgrade headers, so without this every subscription connects anonymous. See [Client: getting the token to the api](/docs/authentication/external-idp#client-getting-the-token-to-the-api-http-and-websocket).
61
+
62
+ To accept your own password sessions AND Supabase during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
63
+
64
+ ```ts
65
+ import { composeAuthStrategies } from '@voltro/protocol'
66
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
67
+ import { supabaseStrategy } from '@voltro/plugin-auth-supabase'
68
+
69
+ const resolve = composeAuthStrategies([
70
+ voltroPasswordStrategy(),
71
+ supabaseStrategy({ projectRef: process.env.SUPABASE_PROJECT_REF! }),
72
+ ])
73
+ ```
74
+
75
+ ## Options
76
+
77
+ | Option | Default | Notes |
78
+ |---|---|---|
79
+ | `projectRef` | — | The `<ref>` in `<ref>.supabase.co`. Required unless `jwksUrl` is set. |
80
+ | `jwksUrl` | `https://<projectRef>.supabase.co/auth/v1/.well-known/jwks.json` | For self-hosted GoTrue; takes precedence over `projectRef`. |
81
+ | `jwtSecret` | — | **Opt-in HS256 mode** for legacy / self-hosted Supabase that signs with the shared `JWT_SECRET` and exposes no JWKS. Tokens are verified symmetrically (`HS256`); the JWKS path is not used. Server-only credential — never ship it to the browser. |
82
+ | `issuer` | `https://<projectRef>.supabase.co/auth/v1` | Set explicitly for self-hosted. |
83
+ | `audience` | `'authenticated'` | String or array. |
84
+ | `cookieName` | `null` (header-only) | Set to `sb-<ref>-auth-token` for `@supabase/ssr` cookie-mode apps — the JSON / `base64-` / chunked session envelope is unwrapped to the inner `access_token`. |
85
+ | `tenantClaim` | `'app_metadata.tenant_id'` | Dotted-path. |
86
+ | `tenantIdFromClaims` | (uses `tenantClaim`) | Derive `tenantId`; `null` → `failed`. |
87
+ | `defaultTenantId` | (none) | Fallback for single-tenant deployments. |
88
+ | `scopesFromClaims` | (none) | Map claims (Supabase `role`, or an `app_metadata` permissions array) → `Subject.scopes`. |
89
+
90
+ Algorithms follow the mode — JWKS pins `['RS256', 'ES256']`, `jwtSecret` pins `['HS256']`; the two are mutually exclusive, so there is no alg-confusion downgrade surface (an RS256 setup can't be tricked into accepting an HS256-forged token). `none` is never accepted.
91
+
92
+ ## Environment variables
93
+
94
+ None read directly — pass `projectRef` / `jwksUrl` explicitly (typically from your own `defineEnv`-declared vars).
95
+
96
+ ## Security
97
+
98
+ Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS mode does public-key verify only and handles no secret. The opt-in `jwtSecret` mode (legacy / self-hosted) verifies HS256 with the project's shared secret — a server-side credential; keep it out of the browser bundle and source control (load it from your env). Prefer JWKS whenever the deployment offers it.
99
+
100
+ ## See also
101
+
102
+ - [External identity providers](/docs/authentication/external-idp)
103
+ - [Auth plugin](/docs/plugins/auth)
@@ -0,0 +1,105 @@
1
+ # WorkOS
2
+
3
+ > WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth-workos.md -->
10
+ ## WorkOS
11
+
12
+ _WorkOS AuthStrategy — verifies WorkOS AuthKit / SSO JWTs via JWKS (no API key) and maps org_id → tenantId._
13
+
14
+ `@voltro/plugin-auth-workos` integrates [WorkOS](https://workos.com) AuthKit / SSO: an `AuthStrategy` (conforming to `@voltro/protocol`) that verifies WorkOS-issued JWTs against the **JWKS endpoint** (public-key verify — no API key), maps `org_id` to the framework's `tenantId`, and composes with other strategies (your password cookie, an API key, another IdP). It's a thin specialization of the shared `jwtBearerStrategy` — it adds WorkOS's defaults and performs no verification of its own. Verified claims arrive on the Subject as `metadata.provider === 'workos'` + `metadata.claims`.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pnpm add @voltro/plugin-auth-workos
20
+ ```
21
+
22
+ ## Wiring
23
+
24
+ Add `workosStrategy(...)` to `auth.strategies` in your api's `app.config.ts`. The built-in signed-cookie password strategy always runs first; your strategies run after it, in order, until one `matched`s (or one `failed`s).
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ import { workosStrategy } from '@voltro/plugin-auth-workos'
29
+
30
+ export default {
31
+ type: 'api' as const,
32
+ name: 'myApi',
33
+ store: 'postgres' as const,
34
+ auth: {
35
+ strategies: [
36
+ workosStrategy({
37
+ clientId: process.env.WORKOS_CLIENT_ID!, // client_01H… — builds JWKS URL + audience
38
+ // defaultTenantId: 'public', // fallback when claims expose no org_id
39
+ }),
40
+ ],
41
+ },
42
+ }
43
+ ```
44
+
45
+ `clientId` is the only required option. From it: the JWKS URL defaults to `https://api.workos.com/sso/jwks/<clientId>`, the issuer to `https://api.workos.com`, and the **audience to the `clientId`**. The cookie defaults to `wos-session` (set by AuthKit's cookie-mode SDK; pass `null` for header-only).
46
+
47
+ Tenant resolution maps from `claims.org_id`, then `defaultTenantId`. Supply `tenantIdFromClaims(claims)` to derive it (returning `null` is a `failed` verdict). Verified claims arrive as `WorkosClaims` (`sub`, `email`, `org_id`, `role`, `permissions`, …).
48
+
49
+ To accept your own password sessions AND WorkOS during a migration, build the chain explicitly with `composeAuthStrategies` (see [auth strategies](/docs/authentication/strategies)):
50
+
51
+ ```ts
52
+ import { composeAuthStrategies } from '@voltro/protocol'
53
+ import { voltroPasswordStrategy } from '@voltro/plugin-auth'
54
+ import { workosStrategy } from '@voltro/plugin-auth-workos'
55
+
56
+ const resolve = composeAuthStrategies([
57
+ voltroPasswordStrategy(),
58
+ workosStrategy({ clientId: process.env.WORKOS_CLIENT_ID! }),
59
+ ])
60
+ ```
61
+
62
+ ## Options
63
+
64
+ | Option | Default | Notes |
65
+ |---|---|---|
66
+ | `clientId` | — (**required**) | `client_01H…`. Builds JWKS URL + audience. |
67
+ | `jwksUrl` | `https://api.workos.com/sso/jwks/<clientId>` | Override. |
68
+ | `issuer` | `https://api.workos.com` | Override. |
69
+ | `cookieName` | `'wos-session'` | AuthKit cookie mode; `null` for header-only. |
70
+ | `tenantIdFromClaims` | (uses `org_id`) | Derive `tenantId`; `null` → `failed`. |
71
+ | `defaultTenantId` | (none) | Fallback when claims expose no `org_id`. |
72
+ | `scopesFromClaims` | (none) | Map claims (AuthKit's `permissions[]` / `role`) → `Subject.scopes`. |
73
+
74
+ The audience is the `clientId`; algorithms are pinned to `['RS256', 'ES256']` (no alg-confusion).
75
+
76
+ ## Environment variables
77
+
78
+ None read directly — pass `clientId` explicitly (typically from your own `defineEnv`-declared vars).
79
+
80
+ ## Security
81
+
82
+ Server-only; fails closed — a forged, expired, or wrong-audience token, or one with no resolvable tenant, yields `failed`, never `matched`. JWKS public-key verify only; no WorkOS API key is ever handled.
83
+
84
+ ## Hosted-login primitives (SSO login)
85
+
86
+ Beyond the verify-only `workosStrategy`, the package exports two primitives for the **hosted-login** flow — WorkOS logs the user in and your app mints its *own* session (an alternate front door alongside password login, not a replacement session authority):
87
+
88
+ - **`workosAuthorizationUrl({ clientId, redirectUri, state? })`** → the AuthKit login URL to redirect the browser to.
89
+ - **`workosAuthenticateWithCode({ clientId, apiKey, code })`** → exchanges the callback `code` for a `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). Server-only (carries the API key).
90
+
91
+ Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency). Wire them into a `GET /auth/workos/login` + `GET /auth/workos/callback` route pair, find-or-provision your user in the callback, then `issueSession(...)`. Full worked example: [WorkOS SSO login](/docs/authentication/external-idp#workos-sso-login).
92
+
93
+ ## Passwordless (Magic Auth)
94
+
95
+ For **passwordless** login — an email one-time code, no password and no browser redirect — the package exports the WorkOS Magic Auth pair:
96
+
97
+ - **`workosSendMagicAuthCode({ apiKey, email })`** → creates + emails a one-time code to `email`. Server-only (the API key is sent as a Bearer token).
98
+ - **`workosAuthenticateWithMagicAuth({ clientId, apiKey, email, code })`** → verifies the submitted `code` and returns the same `WorkosProfile` as the code exchange.
99
+
100
+ Wire them into a `POST /auth/passwordless/start` (send the code) + `POST /auth/passwordless/verify` (verify → find-or-provision → `issueSession(...)`) route pair — the same mint-your-own-session model as the hosted-login flow, without the redirect. Both are transport-thin (raw `fetch`).
101
+
102
+ ## See also
103
+
104
+ - [External identity providers](/docs/authentication/external-idp)
105
+ - [Auth plugin](/docs/plugins/auth)
@@ -0,0 +1,227 @@
1
+ # Auth
2
+
3
+ > Password + session-cookie auth — overview. Full docs in the Authentication section.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/auth.md -->
10
+ ## Auth
11
+
12
+ _Password + session-cookie auth — overview. Full docs in the Authentication section._
13
+
14
+ `@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.
15
+
16
+ The single `authRoutesPlugin()` mounts **every** auth HTTP route under `/auth` — you don't hand-wire endpoints.
17
+
18
+ **Status:** ✓ shipped.
19
+
20
+ This page is a 60-second overview. The dedicated [Authentication section](/docs/authentication/overview) has the depth:
21
+
22
+ - [Passwords](/docs/authentication/passwords) — scrypt hashing, timing-oracle defence
23
+ - [Sessions](/docs/authentication/sessions) — HMAC-SHA256 signed sessions
24
+ - [The Subject](/docs/authentication/subject) — the typed `ctx.subject` model
25
+ - [Auth strategies](/docs/authentication/strategies) — the `AuthStrategy` protocol + chain
26
+ - [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, Auth0, Supabase, OIDC
27
+ - [HTTP handlers](/docs/authentication/handlers) — `handleSignIn`, `handleSignUp`, `handleSignOut`
28
+ - [User stores](/docs/authentication/user-stores) — `memoryUserStore`, `postgresUserStore`, custom
29
+ - [React on the web side](/docs/authentication/react) — `SubjectProvider` + hooks
30
+ - [Cookie security](/docs/authentication/cookies) — production checklist
31
+
32
+ ## Quick wire-up
33
+
34
+ Add `authRoutesPlugin()` to your api's `plugins` array. It mounts the entire auth surface under `/auth` on the same listener the rpc server uses — no hand-wired endpoints. The documented email path forwards magic-link + password-reset mail to `@voltro/plugin-mail` via `mailSender(...)`:
35
+
36
+ ```ts
37
+ // app.config.ts
38
+ import { authRoutesPlugin, postgresUserStore, mailSender } from '@voltro/plugin-auth'
39
+ import { mailPlugin, MailService } from '@voltro/plugin-mail'
40
+ import { bindConnectionSubject } from '@voltro/runtime'
41
+ import { Effect } from 'effect'
42
+
43
+ // `mail` is the yielded MailService from the mail plugin's services layer.
44
+ const auth = (mail: MailService) =>
45
+ authRoutesPlugin({
46
+ store: postgresUserStore(sql), // sql: a provided @effect/sql SqlClient
47
+ secret: process.env.VOLTRO_SESSION_SECRET!,
48
+ defaultTenantId: 'public',
49
+ cookieSecure: process.env.NODE_ENV === 'production',
50
+ appBaseUrl: 'https://app.example.com',
51
+ sendEmail: mailSender(mail), // ← plugin-mail synergy (the default wiring)
52
+ rebind: bindConnectionSubject, // ← switch-tenant rebinds the live connection
53
+ passkey: {
54
+ rpId: 'example.com',
55
+ rpName: 'Acme',
56
+ origin: 'https://app.example.com',
57
+ },
58
+ })
59
+
60
+ export default {
61
+ type: 'api' as const,
62
+ name: 'myApi',
63
+ plugins: [ mailPlugin({ provider: 'resend', from: 'Acme <hi@acme.com>' }) /*, auth(...) */ ],
64
+ }
65
+ ```
66
+
67
+ `sendEmail` is a plain injected hook (`{ to, subject, html, text, kind, actionUrl } => Promise<void>`); `mailSender(mail)` is the first-class adapter that forwards it to `MailService.send`. Omit `sendEmail` and the magic-link / reset routes still mint + persist the token but can't deliver it (they return `202` so account existence never leaks).
68
+
69
+ ```ts
70
+ // app.config.ts — add identity strategies (the built-in password cookie always runs first)
71
+ import { jwtBearerStrategy } from '@voltro/protocol/jwt'
72
+ import { apiKeyStrategy } from '@voltro/protocol/apikey'
73
+
74
+ export default {
75
+ type: 'api' as const,
76
+ name: 'myApi',
77
+ auth: { strategies: [ jwtBearerStrategy({ /* … */ }), apiKeyStrategy({ /* … */ }) ] },
78
+ }
79
+ ```
80
+
81
+ The plugin:
82
+
83
+ - Mounts every auth route under `/auth` via `authRoutesPlugin()` (also re-exports the underlying Effect-typed handlers — `handleSignIn`, `handleSignUp`, `handleMagicLinkRequest`, `handleSwitchTenant`, … — if you'd rather mount a subset yourself).
84
+ - Resolves `ctx.subject` via `AuthMiddleware` running the strategy chain — the built-in `voltroPasswordStrategy` reads the session cookie.
85
+ - Exposes the typed user store via `postgresUserStore(sql)` (synchronous; the caller owns the `SqlClient`) or `memoryUserStore()` for dev/tests.
86
+ - Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`) via `authTables`.
87
+
88
+ ## Routes mounted under `/auth`
89
+
90
+ | Route | Auth | Purpose |
91
+ |---|---|---|
92
+ | `POST /auth/sign-in` · `/sign-up` · `/sign-out` | — | password flow (sign-out also revokes the session row). For an MFA-enrolled user, `sign-in` returns an **mfa-required challenge**, not a session — see below |
93
+ | `POST /auth/mfa/verify` | — | complete the second factor (TOTP or recovery code) with the pending token → issues the real session |
94
+ | `GET /auth/session` | cookie | session probe: current subject + `renewed` flag; re-issues the cookie when the sliding window (or a key rotation) asks for it |
95
+ | `GET /auth/csrf` | — | issue a double-submit CSRF token |
96
+ | `POST /auth/magic-link` · `/magic-link/callback` | — | passwordless sign-in |
97
+ | `POST /auth/password-reset` · `/password-reset/confirm` | — | reset flow (confirm revokes all sessions) |
98
+ | `GET /auth/sessions` · `POST /auth/sessions/revoke` · `/sessions/revoke-others` | cookie | device list + revocation |
99
+ | `GET /auth/memberships` · `POST /auth/switch-tenant` | cookie | multi-tenant membership + active-tenant switch |
100
+ | `POST /auth/mfa/enroll/start` · `/enroll/verify` · `/unenroll` · `/recovery-codes/regenerate` | cookie | TOTP enrolment (needs `mfa: { issuer }` config); `enroll/verify` returns one-time recovery codes |
101
+ | `POST /auth/passkey/register/options` · `/register/verify` | cookie | passkey enrolment |
102
+ | `POST /auth/passkey/assert/options` · `/assert/verify` | — | passkey sign-in |
103
+
104
+ State-changing authenticated routes require a valid `x-csrf-token` header matching the `voltro:csrf` cookie.
105
+
106
+ ## Schema
107
+
108
+ ```ts
109
+ import { authTables } from '@voltro/plugin-auth/schema'
110
+ // authTables = [usersTable, sessionsTable, membershipsTable, authTokensTable,
111
+ // passkeysTable, recoveryCodesTable, passkeyChallengesTable]
112
+ ```
113
+
114
+ Spread `authTables` into your `database/index.ts` handle + `voltro migrate` creates the tables.
115
+
116
+ ## Multi-tenant memberships + switch-tenant
117
+
118
+ A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` — rebinds the connection's Subject via `bindConnectionSubject` (the dispatcher re-scopes active subscriptions on rebind).
119
+
120
+ ## MFA / TOTP sign-in enforcement
121
+
122
+ MFA is a real sign-in gate, not just enrolment. **Enrolment** (`POST /auth/mfa/enroll/*`, needs `mfa: { issuer }` in the plugin config) stores a TOTP secret on the user (`generateTotpSecret` → `otpauthUrl` for the QR); `enroll/verify` confirms the first code, marks the user enrolled, and returns a one-time batch of **recovery codes** (shown once, stored hashed).
123
+
124
+ **Sign-in enforcement** is automatic for any enrolled user — no config flag:
125
+
126
+ 1. **Challenge, not session.** When an enrolled user posts valid credentials to `POST /auth/sign-in`, the response is `{ ok: true, mfaRequired: true, pendingToken }` — a short-lived (5 min), single-use token whose hash is stored in `authTokens`. **No session cookie is issued.** The pending token is NOT a session; it can only redeem the second factor.
127
+ 2. **Verify → session.** The client posts the pending token plus a `code` (6-digit TOTP) — or a `recoveryCode` as the lost-authenticator fallback — to `POST /auth/mfa/verify`. The token is redeemed atomically (single-use, so a guessed code can't be retried against the same challenge), the code is checked against the stored secret (±1 step drift, constant-time compare), and only then is the real session issued — through the same `issueSession` path as password sign-in, so **rotation, sliding-window renewal, and revocation all apply**.
128
+
129
+ A non-enrolled user still gets a session directly from `sign-in` (unchanged). Recovery codes are single-use; regenerate them with `POST /auth/mfa/recovery-codes/regenerate` (wipes the prior set) and they are wiped on `unenroll`. The TOTP secret is stored plaintext on the user row — put it behind your database's at-rest encryption.
130
+
131
+ ## Passkeys / WebAuthn
132
+
133
+ Server verification (`verifyRegistration` / `verifyAssertion`, `node:crypto`-only) checks the challenge, origin, rpId hash, and signature, and enforces a strictly-increasing signature counter (clone detection). Attestation is intentionally skipped (the 90% path). The browser ceremony helper lives in the `/web` subpath:
134
+
135
+ ```tsx
136
+ import { registerPasskey, signInWithPasskey, isPasskeySupported } from '@voltro/plugin-auth/web'
137
+
138
+ if (isPasskeySupported()) {
139
+ await registerPasskey({ userId, userName }, { csrfToken }) // navigator.credentials.create
140
+ await signInWithPasskey({ userId }) // navigator.credentials.get
141
+ }
142
+ ```
143
+
144
+ ### Clone detection is atomic
145
+
146
+ Per the WebAuthn spec, an authenticator's signature counter must strictly increase across assertions; a counter that regresses (or fails to advance) means a cloned credential. The counter bump is a **store-level compare-and-swap** — `advancePasskeyCounter` issues one `UPDATE … WHERE counter < :new RETURNING` statement, so the check and the write are a single atomic operation. Two replicas racing the *same* assertion can't both succeed: exactly one `UPDATE` matches, and the loser (zero rows advanced, counter unchanged) is rejected with `counter_regressed`. The guarantee holds across replicas, not just within one process.
147
+
148
+ ### Multi-replica: bring your own ChallengeStore
149
+
150
+ A passkey ceremony is two round-trips (`.../options` mints a challenge, `.../verify` redeems it). The default `memoryChallengeStore()` holds challenges in process memory — fine for a **single node**, but on ≥2 replicas the `options` and `verify` requests can land on different nodes and the challenge is missing. For any multi-replica deployment, pass a shared store:
151
+
152
+ ```ts
153
+ import { authRoutesPlugin, dataStoreChallengeStore } from '@voltro/plugin-auth'
154
+
155
+ authRoutesPlugin({
156
+ // …store, secret, passkey…
157
+ challengeStore: dataStoreChallengeStore(sql), // shared table, survives across replicas
158
+ })
159
+ ```
160
+
161
+ `dataStoreChallengeStore(sql)` persists challenges in the `passkeyChallenges` table (shipped in `authTables`) — single-use (`take` is one `DELETE … RETURNING`, so concurrent verifies can't both redeem) and short-lived (`put` stamps `expiresAt`; an expired row reads as absent). The caller owns the `SqlClient` lifecycle, same contract as `postgresUserStore`.
162
+
163
+ **BYO contract.** Any `ChallengeStore` you supply implements two Effect-returning methods, keyed by `<userId>:<ceremony>`:
164
+
165
+ ```ts
166
+ interface ChallengeStore {
167
+ put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>
168
+ take: (key: string) => Effect.Effect<string | null> // read AND delete (single-use); null if absent/expired
169
+ }
170
+ ```
171
+
172
+ `take` **must** delete on read (single-use) and return `null` for an absent, already-taken, or expired key. Back it with any shared, low-latency store (Postgres via `dataStoreChallengeStore`, or a Redis/KV of your own) so all replicas see the same challenges.
173
+
174
+ ## Multi-key session-secret rotation
175
+
176
+ To rotate without logging everyone out: set the new secret as `VOLTRO_SESSION_SECRET`, move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS` for one max-session-lifetime window, then drop the `_PREVIOUS` var. Every verify path is keyed — the framework's rpc auth chain, the plugin's `/auth/*` routes, and `readSession` — trying `current` first, then `previous`. A cookie that verified under the previous key is re-issued under the current key on the next authenticated `/auth/*` response (`GET /auth/session` triggers this proactively), so live sessions migrate onto the new key during the window. The optional `VOLTRO_SESSION_KID` / `VOLTRO_SESSION_KID_PREVIOUS` vars label the keys (non-secret; default `k0` / `k-previous`). Prefer explicit config over env? `authRoutesPlugin` / `AuthConfig` accept `secrets: { current, previous? }` directly; `resolveSessionSecrets()` (in `@voltro/protocol/session`) is the env reader underneath.
177
+
178
+ ## Sliding-window auto-renewal
179
+
180
+ Sessions carry an `iat`. `verifySessionKeyed` returns a `renew` flag once the session crosses the renewal threshold (default 70% of its lifetime); authenticated `/auth/*` responses act on it by re-issuing the cookie with its original lifetime (and sliding the `sessions` row's `expiresAt` forward), so an active user never gets logged out mid-session. `GET /auth/session` is the probe a client pings to keep a session sliding — rpc frames over the WebSocket can't set cookies, so renewal is an HTTP-response mechanism.
181
+
182
+ ## Request-time session revocation
183
+
184
+ Revoking a session (device-list revoke, "sign out other devices", sign-out, or password-reset's revoke-all) actually terminates the live cookie: on every verify, the cookie's `metadata.sessionId` is checked against the `sessions` table through an in-process TTL cache (default **30s**, tune via `authRoutesPlugin({ sessionRevocation: { ttlMs } })`). A kill is instant on the process that performed it (inline cache invalidation) and takes effect within the cache window on other replicas. The plugin carries a pre-wired `auth.sessionStrategy` (same store, same cache) that `voltro dev` / `voltro serve` slot into the rpc auth chain automatically — so WebSocket calls reject a revoked session exactly like the HTTP routes. A cookie minted by hand via `issueSession` (no `sessions` row, no `sessionId`) stays purely stateless and can't be revoked this way.
185
+
186
+ ## Post-authentication subject guards
187
+
188
+ Every login path (password, MFA, magic-link, passkey) resolves WHO you are, then — right before minting the session cookie — runs the configured **subject guards** against the authenticated `UserRecord`. A guard returns `{ ok: true }` to allow or `{ ok: false, code, message }` to VETO; the first rejection wins, the login returns a **403** carrying the guard's `code` as the body `error`, and **no session is issued**. Sign-up is exempt — it creates a brand-new user no guard could yet reject.
189
+
190
+ This is a general seam, not tied to any single concern — "account deactivated", "email not verified", "tenant suspended", "must accept new terms" all fit. Pass guards on `subjectGuards`:
191
+
192
+ ```ts
193
+ import { authRoutesPlugin, type SubjectGuard } from '@voltro/plugin-auth'
194
+ import { Effect } from 'effect'
195
+
196
+ const suspendedTenants = new Set(['tenant-under-review'])
197
+
198
+ const tenantSuspendedGuard: SubjectGuard = (user) =>
199
+ Effect.succeed(
200
+ suspendedTenants.has(user.tenantId)
201
+ ? { ok: false, code: 'tenant_suspended', message: 'tenant is suspended' }
202
+ : { ok: true },
203
+ )
204
+
205
+ authRoutesPlugin({
206
+ store,
207
+ secret: process.env.VOLTRO_SESSION_SECRET!,
208
+ defaultTenantId: 'acme',
209
+ subjectGuards: [tenantSuspendedGuard],
210
+ })
211
+ ```
212
+
213
+ The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation#enforcing-a-deactivated-user-cant-log-in). With no guards configured, every authenticated user proceeds exactly as before.
214
+
215
+ ## Rehash-on-verify
216
+
217
+ `verifyPasswordWithRehash` parses the scrypt cost out of the stored hash; when it's below the current cost, a successful sign-in returns a freshly-minted replacement that `handleSignIn` persists via `UserStore.updatePassword` — passwords strengthen silently on the next login, no forced reset.
218
+
219
+ ## When to use a different auth strategy
220
+
221
+ `plugin-auth`'s password flow is the default. For specific needs:
222
+
223
+ - **Existing user base in an IdP** (Okta, Auth0, Clerk, WorkOS, Kinde, Supabase) — add the matching [external IdP strategy](/docs/authentication/external-idp) to the chain. The framework keeps its own `Subject` + `tenantId`.
224
+ - **Any OIDC provider** — `@voltro/plugin-auth-oidc`'s `oidcStrategy`, or `jwtBearerStrategy` from `@voltro/protocol/jwt` directly.
225
+ - **Headless / programmatic callers** — `apiKeyStrategy` from `@voltro/protocol/apikey`.
226
+
227
+ For 95% of apps, the password flow is the right starting point. The depth lives in the Authentication section.