fraiseql-cli 2.19.1

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
//! Security configuration types for `[security.*]` and `[auth]` TOML sections.

use anyhow::Context as _;
use fraiseql_core::security::oidc::MeEndpointConfig;
use serde::{Deserialize, Serialize};

/// Security configuration
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecuritySettings {
    /// Declare that this schema serves multiple tenants.
    ///
    /// Activates the subscription tenant fail-closed gate and the cache+RLS boot
    /// gate. Both gates named this key in their own error messages while
    /// `deny_unknown_fields` made it a compile error to set (#758).
    pub multi_tenant:           bool,
    /// Declare that database Row-Level Security isolates this schema's data.
    ///
    /// `[security.rls] enabled = true`. Verified against the live catalog at boot.
    pub rls:                    fraiseql_core::schema::RlsConfig,
    /// Field-level RBAC role definitions — `[[security.role_definitions]]` (`#897`).
    ///
    /// `role_has_scope` is the only input to field-level access, and it consults nothing
    /// else. Without a producer on this workflow, marking a field `requires_scope` in a
    /// `--schema-dir` or TOML-schema project produced a schema where no role could ever
    /// grant that scope, so every caller was denied the field with no way out but
    /// migrating to the other compile workflow.
    ///
    /// Shares `RoleDefinitionConfig` and its single lowering with the project-config
    /// producer, so the two cannot drift the way #757's did.
    pub role_definitions:       Vec<crate::config::security::RoleDefinitionConfig>,
    /// Role granted to an authenticated principal that carries none — `[security]
    /// default_role` (`#897`).
    ///
    /// Consulted by `can_access_scope` when the principal's role set is empty (#894).
    /// Never reaches an anonymous request, which is classified before that point.
    pub default_role:           Option<String>,
    /// Custom authorization rules
    pub rules:                  Vec<AuthorizationRule>,
    /// Authorization policies
    pub policies:               Vec<AuthorizationPolicy>,
    /// Field-level authorization rules
    pub field_auth:             Vec<FieldAuthRule>,
    /// Enterprise security configuration (legacy flags)
    pub enterprise:             EnterpriseSecurityConfig,
    /// Error sanitization — controls what detail clients see in error responses
    pub error_sanitization:     Option<ErrorSanitizationTomlConfig>,
    /// Rate limiting — per-endpoint request caps
    pub rate_limiting:          Option<RateLimitingSecurityConfig>,
    /// State encryption — AEAD encryption for OAuth state and PKCE blobs
    pub state_encryption:       Option<StateEncryptionConfig>,
    /// PKCE — Proof Key for Code Exchange for OAuth Authorization Code flows
    pub pkce:                   Option<PkceConfig>,
    /// API key authentication — static or database-backed key-based auth
    pub api_keys:               Option<ApiKeySecurityConfig>,
    /// Token revocation — reject JWTs by `jti` after revocation
    pub token_revocation:       Option<TokenRevocationSecurityConfig>,
    /// Trusted documents — query allowlist (strict or permissive mode)
    pub trusted_documents:      Option<TrustedDocumentsConfig>,
    /// Force persisted-operations-only enforcement (#379).
    ///
    /// When `true`, the runtime forces the trusted-document store into `strict` mode
    /// (reject any non-persisted operation) regardless of `[security.trusted_documents].mode`.
    /// Requires a configured trusted-documents manifest to have any effect.
    pub persisted_queries_only: bool,
    /// Operation cost budgets (#379): `per_request_max` (a hard per-operation
    /// ceiling the executor enforces on every transport) and
    /// `per_tenant_per_minute_default` (the rolling window seeded for tenants
    /// without their own budget). Shares the runtime's own type so the
    /// authoring and consuming shapes cannot drift.
    pub cost_budget:            Option<fraiseql_core::schema::CostBudgetConfig>,
}

// The `[security.*]` section shapes are owned by `fraiseql_core::schema` (#977):
// the compiled schema's `SecurityConfig` carries them as typed fields, so the
// authoring (TOML), compiled and consuming (server) sides share one definition
// per subsystem and cannot drift. Re-exported here under the names this module
// historically used.
pub use fraiseql_core::schema::{
    ApiKeySecurityConfig, AuthorizationPolicy, AuthorizationRule, CodeChallengeMethod,
    EncryptionAlgorithm, EnterpriseSecurityConfig,
    ErrorSanitizationConfig as ErrorSanitizationTomlConfig, FieldAuthRule, KeySource,
    PkceSecurityConfig as PkceConfig, RateLimitingSecurityConfig, StateEncryptionConfig,
    StaticApiKeyEntry, TokenRevocationSecurityConfig, TrustedDocumentMode, TrustedDocumentsConfig,
};

/// OIDC configuration for `[auth]`.
///
/// This is the CLI's **compile-time view** of the `[auth]` block. The server
/// reads the same `[auth]` block independently into its own
/// [`OidcConfig`](fraiseql_core::security::OidcConfig) at runtime, so this
/// schema exists to *accept and structurally validate* the same block the
/// server consumes — not to lower it into the compiled schema (the compiled
/// schema carries no auth). Every JWT-validation field here must therefore stay
/// field-compatible with `OidcConfig`, or `deny_unknown_fields` would reject a
/// config the server accepts.
///
/// Two independent groups may be configured, together or separately:
///
/// - **JWT validation** — `issuer` **or** `jwks_uri`, plus `audience` and the rest of
///   `OidcConfig`'s JWT settings (`additional_audiences`, `allowed_algorithms`,
///   `jwks_cache_ttl_secs`, `clock_skew_secs`, `required`, `scope_claim`, `require_jti`,
///   `[auth.me]`). The server consumes these to validate incoming bearer tokens; the CLI accepts
///   them so the same file parses under both. Accepted and functional. `issuer` may be omitted for
///   identity providers whose access tokens carry no `iss` claim (e.g. self-hosted Hanko); in that
///   **issuer-less** mode `jwks_uri` must be pinned, since discovery cannot locate the JWKS
///   endpoint without an issuer.
/// - **PKCE OAuth client** (server-side login) — `discovery_url`, `client_id`, `client_secret_env`,
///   `server_redirect_uri`, configured all four together. **Not yet functional on the compiled path
///   (tracked in #621):** the compiled schema carries no `auth`/`auth_endpoints` for the server to
///   consume, so a complete client group is *rejected at compile time* rather than silently
///   accepted.
///
/// At least one group must be present; an empty `[auth]` is a load error. The client
/// secret itself must never appear here — `client_secret_env` names the environment
/// variable that holds it.
///
/// ```toml
/// [auth]
/// issuer   = "https://accounts.google.com"
/// audience = "my-api"
/// ```
///
/// Issuer-less (e.g. Hanko), pinning the JWKS endpoint:
///
/// ```toml
/// [auth]
/// jwks_uri = "https://hanko.example.com/.well-known/jwks.json"
/// audience = "my-relying-party-id"
/// ```
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct OidcClientConfig {
    /// OIDC issuer URL for JWT validation (e.g. `"https://accounts.google.com"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub issuer:   Option<String>,
    /// Expected `aud` claim for JWT validation. Required at runtime by the
    /// server's `OidcConfig` to prevent token-confusion attacks.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub audience: Option<String>,
    /// Pinned JWKS endpoint for JWT validation (skips OIDC discovery).
    ///
    /// Required for **issuer-less** identity providers — IdPs whose access
    /// tokens omit the `iss` claim (e.g. self-hosted Hanko). With `issuer`
    /// unset, discovery cannot locate the JWKS endpoint, so it must be pinned
    /// here. May also be set alongside `issuer` to skip discovery.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jwks_uri: Option<String>,

    // JWT-validation fields below mirror the server's `OidcConfig` so a single
    // config file's `[auth]` block parses under both. They are accepted and
    // structurally validated here but consumed by the server (which re-reads the
    // same block); the CLI does not act on their values. `cli_auth_schema_...`
    // in tests pins that this list stays complete as `OidcConfig` evolves.
    /// Additional accepted `aud` values (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub additional_audiences: Vec<String>,
    /// Allowed JWT signing algorithms (union-compat; the server defaults to RS256).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_algorithms:   Vec<String>,
    /// JWKS cache TTL in seconds (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub jwks_cache_ttl_secs:  Option<u64>,
    /// Clock-skew tolerance in seconds (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub clock_skew_secs:      Option<u64>,
    /// Require authentication for all requests (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub required:             Option<bool>,
    /// JWT scope-claim name (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scope_claim:          Option<String>,
    /// Require the `jti` claim on every token (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub require_jti:          Option<bool>,
    /// `[auth.me]` session-identity endpoint config (union-compat with `OidcConfig`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub me:                   Option<MeEndpointConfig>,
    /// `[[auth.additional_issuers]]` further trusted token issuers (#1400; union-compat
    /// with `OidcConfig`). The server's own type, so the two cannot drift.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub additional_issuers:   Vec<fraiseql_core::security::oidc::TrustedIssuer>,

    /// PKCE: OIDC provider discovery URL. **Not yet functional (#621).**
    #[serde(skip_serializing_if = "Option::is_none")]
    pub discovery_url:       Option<String>,
    /// PKCE: OAuth2 `client_id` registered with the provider. **Not yet functional (#621).**
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_id:           Option<String>,
    /// PKCE: name of the environment variable that holds the client secret.
    /// The secret itself must never appear in TOML or the compiled schema.
    /// **Not yet functional (#621).**
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_secret_env:   Option<String>,
    /// PKCE: the full URL of this server's `/auth/callback` endpoint.
    /// **Not yet functional (#621).**
    #[serde(skip_serializing_if = "Option::is_none")]
    pub server_redirect_uri: Option<String>,

    /// Social-login providers (`[auth.social.google]` / `[auth.social.github]`,
    /// #368). Deserializes directly into the **compiled** type so the authored
    /// and compiled shapes cannot drift; the merger embeds it verbatim under
    /// the compiled `auth.social` object. An unimplemented provider key
    /// (`apple`, `discord`, …) is an unknown-field load error, not a
    /// silently-ignored table.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub social: Option<fraiseql_core::schema::SocialAuthConfig>,

    /// First-party auth methods the server operates itself (`[auth.local]`,
    /// #367): email+password, email `OTP`/magic link, `TOTP` `MFA`, anonymous
    /// guest sessions. Deserializes directly into the compiled type so the
    /// authored and compiled shapes cannot drift.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub local: Option<fraiseql_core::schema::LocalAuthConfig>,

    /// How long the sessions the server mints last, and whether they can be refreshed
    /// (`[auth.session]`, #1853). Deserializes directly into the compiled type.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub session: Option<fraiseql_core::schema::SessionConfig>,

    /// The database first-party auth connects to, as its own role (`[auth.database]`,
    /// #1965). Deserializes directly into the compiled type.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub database: Option<fraiseql_core::schema::AuthDatabaseConfig>,
}

impl OidcClientConfig {
    /// Validate the `[auth]` group structure (#612 item 9).
    ///
    /// The JWT group (`issuer` or a pinned `jwks_uri`, plus optional `audience`) is
    /// accepted and functional. The PKCE client group is all-four-or-none, and a
    /// *complete* client group is rejected — it is not yet functional on the compiled
    /// path (#621), so it is refused rather than silently accepted. At least one group
    /// must be present.
    ///
    /// # Errors
    ///
    /// Returns an error naming the specific problem: an incomplete client group (with the
    /// missing fields), a complete-but-unsupported client group, `audience` without a JWT
    /// group (neither `issuer` nor `jwks_uri`), or an empty `[auth]` block.
    pub fn validate(&self) -> anyhow::Result<()> {
        let client_fields = [
            ("discovery_url", self.discovery_url.is_some()),
            ("client_id", self.client_id.is_some()),
            ("client_secret_env", self.client_secret_env.is_some()),
            ("server_redirect_uri", self.server_redirect_uri.is_some()),
        ];
        let client_set = client_fields.iter().filter(|(_, set)| *set).count();
        let has_client_group = client_set > 0;
        let has_social_group = self.social.is_some();
        let has_local_group = self.local.is_some();
        // A JWT-validation group exists if either `issuer` (used for discovery
        // and `iss` validation) or a pinned `jwks_uri` (issuer-less mode, for
        // IdPs that omit `iss`) is set. Mirrors the server's `OidcConfig`.
        let has_jwt_group = self.issuer.is_some() || self.jwks_uri.is_some();

        if has_client_group && client_set < client_fields.len() {
            let missing: Vec<&str> =
                client_fields.iter().filter(|(_, set)| !*set).map(|(name, _)| *name).collect();
            anyhow::bail!(
                "[auth] PKCE OAuth-client config is incomplete: discovery_url, client_id, \
                 client_secret_env, and server_redirect_uri must be set together (missing: {}).",
                missing.join(", ")
            );
        }

        // #621: a complete PKCE client group is now functional on the compiled
        // path — the merger emits the compiled `auth` object, and the runtime
        // resolves the OIDC discovery document at boot from `discovery_url`
        // (fetch-at-boot, so the compiler stays hermetic). It no longer bails.
        // (`has_client_group` with `client_set == 4` is accepted.)

        if self.audience.is_some() && !has_jwt_group {
            anyhow::bail!(
                "[auth] audience is set but neither issuer nor jwks_uri is configured. JWT \
                 validation needs an issuer (used for discovery and `iss` validation) or a pinned \
                 jwks_uri (for IdPs whose tokens omit `iss`, e.g. Hanko) — add one, or remove \
                 audience."
            );
        }

        // #368: [auth.social] must name at least one provider, and every named
        // provider's fields must be non-empty — an empty block or blank
        // client_id would compile into a registry that can never serve a login.
        if let Some(social) = &self.social {
            if social.google.is_none()
                && social.github.is_none()
                && social.apple.is_none()
                && social.discord.is_none()
                && social.facebook.is_none()
            {
                anyhow::bail!(
                    "[auth.social] is configured but names no provider. Add an \
                     [auth.social.{{google,github,apple,discord,facebook}}] block."
                );
            }
            // #943: Apple has no client_secret_env — the client secret is an
            // ES256 assertion the runtime signs with the `.p8` key. Exactly one
            // key source, refused at compile time so the operator hears it at
            // the file they are editing rather than at server boot.
            if let Some(apple) = &social.apple {
                match (&apple.private_key_env, &apple.private_key_path) {
                    (Some(_), None) | (None, Some(_)) => {},
                    (Some(_), Some(_)) => anyhow::bail!(
                        "[auth.social.apple] names both private_key_env and private_key_path — \
                         set exactly one."
                    ),
                    (None, None) => anyhow::bail!(
                        "[auth.social.apple] needs exactly one of private_key_env / \
                         private_key_path: the .p8 key is what signs the client-secret assertion."
                    ),
                }
            }
            let mut providers: Vec<(&str, Vec<(&str, &String)>)> = Vec::new();
            if let Some(g) = social.google.as_ref() {
                providers.push((
                    "google",
                    vec![
                        ("client_id", &g.client_id),
                        ("client_secret_env", &g.client_secret_env),
                        ("redirect_uri", &g.redirect_uri),
                    ],
                ));
            }
            if let Some(g) = social.github.as_ref() {
                providers.push((
                    "github",
                    vec![
                        ("client_id", &g.client_id),
                        ("client_secret_env", &g.client_secret_env),
                        ("redirect_uri", &g.redirect_uri),
                    ],
                ));
            }
            if let Some(a) = social.apple.as_ref() {
                providers.push((
                    "apple",
                    vec![
                        ("client_id", &a.client_id),
                        ("team_id", &a.team_id),
                        ("key_id", &a.key_id),
                        ("redirect_uri", &a.redirect_uri),
                    ],
                ));
            }
            if let Some(d) = social.discord.as_ref() {
                providers.push((
                    "discord",
                    vec![
                        ("client_id", &d.client_id),
                        ("client_secret_env", &d.client_secret_env),
                        ("redirect_uri", &d.redirect_uri),
                    ],
                ));
            }
            if let Some(f) = social.facebook.as_ref() {
                providers.push((
                    "facebook",
                    vec![
                        ("client_id", &f.client_id),
                        ("client_secret_env", &f.client_secret_env),
                        ("redirect_uri", &f.redirect_uri),
                    ],
                ));
            }
            for (provider, fields) in providers {
                for (field, value) in fields {
                    if value.trim().is_empty() {
                        anyhow::bail!("[auth.social.{provider}] {field} must not be empty.");
                    }
                }
            }
        }

        if let Some(session) = &self.session {
            session.check().map_err(|message| anyhow::anyhow!(message))?;
        }

        if let Some(database) = &self.database {
            database.check().map_err(|message| anyhow::anyhow!(message))?;
        }

        // #367: [auth.local] must enable at least one method, and every method
        // that sends mail or issues links must have what it needs to do so.
        // Refusing at compile time keeps the operator's feedback loop at the
        // file they are editing rather than at server boot.
        if let Some(local) = &self.local {
            local
                .check_session_transport(self.session.as_ref())
                .map_err(|message| anyhow::anyhow!(message))?;
            if !local.password && !local.otp && !local.mfa && !local.anonymous {
                anyhow::bail!(
                    "[auth.local] is configured but enables no method. Set at least one of \
                     password / otp / mfa / anonymous, or remove the block."
                );
            }
            // #945: verification proves the address a *local* identity claims. Without
            // password auth there is no local identity, so the routes would mount over
            // nothing — the accepted-and-unconsumed shape, refused at the file being
            // edited rather than discovered at runtime.
            if local.email_verification && !local.password {
                anyhow::bail!(
                    "[auth.local] email_verification = true needs password = true: \
                     verification proves the address a local password identity claims, and \
                     there is no such identity without it."
                );
            }
            if local.email_verification && local.verification_url_template.is_none() {
                anyhow::bail!(
                    "[auth.local] email_verification = true needs verification_url_template \
                     — the verification link points at your front end, which FraiseQL cannot \
                     guess. Example: verification_url_template = \
                     \"https://app.example.com/verify-email?token={{token}}\""
                );
            }
            if (local.otp || local.password || local.email_verification)
                && local.email_from.is_none()
            {
                anyhow::bail!(
                    "[auth.local] enables {} but sets no email_from. These flows deliver mail \
                     (OTP codes, reset links, verification links); name the [mailbox.<name>] \
                     account whose SMTP half should send them.",
                    if local.otp {
                        "otp"
                    } else if local.password {
                        "password"
                    } else {
                        "email_verification"
                    }
                );
            }
            if local.password && local.reset_url_template.is_none() {
                anyhow::bail!(
                    "[auth.local] password = true needs reset_url_template — the reset link \
                     points at your front end, which FraiseQL cannot guess. Example: \
                     reset_url_template = \"https://app.example.com/reset?token={{token}}\""
                );
            }
            if let Some(query) = &local.otp_allowed_query {
                if !local.otp {
                    anyhow::bail!(
                        "[auth.local] otp_allowed_query decides who may sign in by OTP, but otp \
                         is not enabled. Set otp = true, or remove the key."
                    );
                }
                fraiseql_core::schema::otp_allowed_statement(query).map_err(|problem| {
                    anyhow::anyhow!("[auth.local] otp_allowed_query is refused: {problem}: {query}")
                })?;
                if local.password || self.social.is_some() {
                    tracing::warn!(
                        "[auth.local] otp_allowed_query governs OTP only: password signup and \
                         social sign-in still create accounts for any address"
                    );
                }
            }
            if let Some(t) = &local.magic_link_template {
                if !t.contains("{token}") && !t.contains("{code}") {
                    anyhow::bail!(
                        "[auth.local] magic_link_template must contain the {{token}} \
                         placeholder, or every link it builds is the same dead link: {t}"
                    );
                }
                if !t.contains("{token}") {
                    tracing::warn!(
                        "[auth.local] magic_link_template carries only {{code}}: the link \
                         signs in only where the address is known. Use {{token}}, which \
                         signs in alone from any device"
                    );
                }
            }
            local.check_email().map_err(anyhow::Error::msg)?;
            for (field, template, placeholder) in [
                ("reset_url_template", local.reset_url_template.as_ref(), "{token}"),
                ("verification_url_template", local.verification_url_template.as_ref(), "{token}"),
            ] {
                if let Some(t) = template {
                    if !t.contains(placeholder) {
                        anyhow::bail!(
                            "[auth.local] {field} must contain the {placeholder} placeholder, \
                             or every link it builds is the same dead link: {t}"
                        );
                    }
                }
            }
        }

        // A PKCE client group alone (no JWT group) is a valid configuration:
        // server-side OAuth login without also validating bearer JWTs. Only an
        // entirely empty block is refused. `[auth.database]` alone serves a `[saml]` or
        // `[scim]` the server configuration enables.
        let has_database = self.database.is_some();
        if !has_jwt_group
            && !has_client_group
            && !has_social_group
            && !has_local_group
            && !has_database
        {
            anyhow::bail!(
                "[auth] is empty. Configure JWT validation with issuer (or jwks_uri for IdPs \
                 whose tokens omit `iss`, e.g. Hanko), a PKCE OAuth-client group \
                 (discovery_url, client_id, client_secret_env, server_redirect_uri), or \
                 [auth.social.*] providers. An empty [auth] block does nothing."
            );
        }

        Ok(())
    }
}

/// Read each `[auth.local.email]` template file, relative to `project`, into the body the
/// compiled schema carries, and drop its path: the server may run where the files are not.
///
/// # Errors
///
/// Returns an error naming the key and the file when a file cannot be read, and refuses a body
/// written inline, which has no file to come from.
pub(crate) fn embed_email_templates(
    local: &mut fraiseql_core::schema::LocalAuthConfig,
    project: &std::path::Path,
) -> anyhow::Result<()> {
    let Some(email) = local.email.as_mut() else {
        return Ok(());
    };
    for (kind, template) in [
        ("otp", email.otp.as_mut()),
        ("reset", email.reset.as_mut()),
        ("verification", email.verification.as_mut()),
    ] {
        let Some(template) = template else {
            continue;
        };
        for (key, inline, path, body) in [
            ("text_template", "text", &mut template.text_template, &mut template.text),
            ("html_template", "html", &mut template.html_template, &mut template.html),
        ] {
            if body.is_some() {
                anyhow::bail!(
                    "[auth.local.email.{kind}] {inline} is the compiled body. In fraiseql.toml, \
                     write it in a file and name the file with {key}."
                );
            }
            if let Some(file) = path.take() {
                let read = project.join(&file);
                let contents = std::fs::read_to_string(&read).with_context(|| {
                    format!(
                        "[auth.local.email.{kind}] {key} = {file:?}: cannot read {}",
                        read.display()
                    )
                })?;
                *body = Some(contents);
            }
        }
    }
    Ok(())
}