Skip to main content

postrust_core/
config.rs

1//! Configuration for Postrust.
2//!
3//! Mirrors PostgREST's configuration options.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8const EXPECTED_BOOLEAN: &str = "one of: true, false, 1, 0, yes, no, on, off";
9
10/// An environment variable contains a value that cannot be used safely.
11#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
12#[error("invalid value for {variable}: {value:?}; expected {expected}")]
13pub struct ConfigError {
14    variable: &'static str,
15    value: String,
16    expected: &'static str,
17}
18
19/// Main application configuration.
20#[derive(Clone, Debug, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct AppConfig {
23    // ========================================================================
24    // Database Settings
25    // ========================================================================
26    /// PostgreSQL connection URI
27    #[serde(default = "default_db_uri")]
28    pub db_uri: String,
29
30    /// Schemas to expose via the API
31    #[serde(default = "default_db_schemas")]
32    pub db_schemas: Vec<String>,
33
34    /// Role for unauthenticated requests
35    pub db_anon_role: Option<String>,
36
37    /// Connection pool size
38    #[serde(default = "default_pool_size")]
39    pub db_pool_size: u32,
40
41    /// Pool acquisition timeout in seconds
42    #[serde(default = "default_pool_timeout")]
43    pub db_pool_timeout: u64,
44
45    /// Isolation level for the transaction every request runs in.
46    ///
47    /// `READ COMMITTED` unless set, which is PostgreSQL's own default; a
48    /// stricter level is issued as `SET TRANSACTION ISOLATION LEVEL` on the
49    /// request's transaction.
50    #[serde(default = "default_tx_isolation")]
51    pub db_tx_isolation: IsolationLevel,
52
53    /// Use prepared statements
54    #[serde(default = "default_true")]
55    pub db_prepared_statements: bool,
56
57    /// Schemas added to the `search_path` of every request, after the
58    /// request's own schema.
59    ///
60    /// These are not exposed as API endpoints; they are where a function an
61    /// exposed one calls, or a type it references, is allowed to live. An
62    /// aggregate returning a domain declared elsewhere cannot resolve that
63    /// domain without this. `public` by default, as in PostgREST.
64    #[serde(default = "default_extra_search_path")]
65    pub db_extra_search_path: Vec<String>,
66
67    /// LISTEN/NOTIFY channel for schema reload
68    #[serde(default = "default_db_channel")]
69    pub db_channel: String,
70
71    /// Enable NOTIFY-based schema cache reload
72    #[serde(default)]
73    pub db_channel_enabled: bool,
74
75    /// Function called on the request's transaction before its own query,
76    /// after every setting is applied. Anything it raises aborts the request.
77    pub db_pre_request: Option<String>,
78
79    /// Maximum rows allowed in a response
80    pub db_max_rows: Option<i64>,
81
82    /// Enable aggregate functions in `select`.
83    ///
84    /// Off by default, as in PostgREST: an aggregate over a large table costs
85    /// far more than the request looks like it asks for.
86    #[serde(default)]
87    pub db_aggregates_enabled: bool,
88
89    // ========================================================================
90    // Server Settings
91    // ========================================================================
92    /// Server host to bind
93    #[serde(default = "default_host")]
94    pub server_host: String,
95
96    /// Server port
97    #[serde(default = "default_port")]
98    pub server_port: u16,
99
100    /// Origins allowed by CORS.
101    ///
102    /// Empty means every origin is allowed, which is the default and what
103    /// `PGRST_SERVER_CORS_ORIGINS="*"` selects explicitly. A non-empty list
104    /// restricts the `Access-Control-Allow-Origin` response to exactly those
105    /// origins; anything else gets no CORS headers and is refused by the
106    /// browser.
107    #[serde(default)]
108    pub server_cors_origins: Vec<String>,
109
110    /// Maximum accepted request body, in bytes.
111    ///
112    /// The REST handler applies this when it reads the body; the routes that
113    /// take a body extractor get it through axum's `DefaultBodyLimit`. Before
114    /// this was read, the limit in force was a 10 MiB constant in the request
115    /// handler, whatever the variable said.
116    #[serde(default = "default_max_body_size")]
117    pub max_body_size: usize,
118
119    /// Listen on a Unix domain socket instead of host and port.
120    pub server_unix_socket: Option<String>,
121
122    /// Serve liveness and readiness on a second port of their own.
123    pub admin_server_port: Option<u16>,
124
125    // ========================================================================
126    // JWT Settings
127    // ========================================================================
128    /// JWT secret key (or JWKS URL)
129    pub jwt_secret: Option<String>,
130
131    /// JWT secret as base64
132    #[serde(default)]
133    pub jwt_secret_is_base64: bool,
134
135    /// JWT audience claim to validate
136    pub jwt_aud: Option<String>,
137
138    /// JWT claim that contains the role
139    #[serde(default = "default_jwt_role_claim")]
140    pub jwt_role_claim_key: String,
141
142    /// Cache validated tokens, so a repeated token is not verified again.
143    #[serde(default = "default_true")]
144    pub jwt_cache_enabled: bool,
145
146    /// How long a cached validation may be reused, in seconds.
147    ///
148    /// A token's own `exp` bounds this; the cache never answers with a token
149    /// past its expiry. `0` turns the cache off, as in PostgREST.
150    #[serde(default = "default_jwt_cache_max")]
151    pub jwt_cache_max_lifetime: u64,
152
153    // ========================================================================
154    // Hasura Authentication
155    // ========================================================================
156    /// The shared secret that authenticates an administrator, and the switch
157    /// that makes `x-hasura-*` headers mean anything at all.
158    ///
159    /// Unset, this server ignores those headers and reads session variables
160    /// only from a verified token -- because honouring a header nothing
161    /// authenticated would let any caller name its own identity, and a policy
162    /// reading a value the caller chose is not a policy. Hasura instead treats
163    /// an unsecured deployment as wholly administrative; the divergence is
164    /// deliberate and is recorded in `postrust_auth::hasura`.
165    pub hasura_admin_secret: Option<String>,
166
167    /// The role a request falls to when nothing authenticated it. Hasura's
168    /// `HASURA_GRAPHQL_UNAUTHORIZED_ROLE`; unset means such a request is
169    /// refused rather than answered as a stranger.
170    pub hasura_unauthorized_role: Option<String>,
171
172    // ========================================================================
173    // OpenAPI Settings
174    // ========================================================================
175    /// Address to advertise in the specification's `servers`, for when the
176    /// server sits behind a reverse proxy and its own address is not the one
177    /// clients use.
178    pub openapi_server_proxy_uri: Option<String>,
179
180    /// OpenAPI mode: disabled, follow-privileges, ignore-privileges
181    #[serde(default = "default_openapi_mode")]
182    pub openapi_mode: OpenApiMode,
183
184    // ========================================================================
185    // Logging Settings
186    // ========================================================================
187    /// Log level: crit, error, warn, info, debug
188    #[serde(default = "default_log_level")]
189    pub log_level: LogLevel,
190
191    // ========================================================================
192    // Role Settings
193    // ========================================================================
194    /// Per-role settings (isolation level, timeout)
195    #[serde(default)]
196    pub role_settings: HashMap<String, RoleSettings>,
197
198    /// App-level settings to expose via GUC
199    #[serde(default)]
200    pub app_settings: HashMap<String, String>,
201
202    // ========================================================================
203    // GraphQL Settings
204    // ========================================================================
205    /// Expose the GraphQL API as an Apollo Federation subgraph.
206    #[serde(default)]
207    pub graphql_federation: bool,
208
209    /// Namespace applied to generated GraphQL table types and root fields.
210    #[serde(default)]
211    pub graphql_type_prefix: Option<String>,
212
213    // ========================================================================
214    // Compatibility Settings
215    // ========================================================================
216    /// PostgREST compatibility mode.
217    ///
218    /// When enabled, the REST surface is also served at the root (so canonical
219    /// PostgREST paths like `/rpc/<name>` and `/<table>` work in addition to
220    /// the `/api`-prefixed paths), and RPC responses are un-wrapped to match
221    /// PostgREST's shape (bare object/scalar for non-set-returning functions,
222    /// a top-level array for set-returning ones) instead of the array-wrapped,
223    /// function-name-keyed default.
224    #[serde(default)]
225    pub compat_mode: bool,
226}
227
228impl Default for AppConfig {
229    fn default() -> Self {
230        Self {
231            db_uri: default_db_uri(),
232            db_schemas: default_db_schemas(),
233            db_anon_role: None,
234            db_pool_size: default_pool_size(),
235            db_pool_timeout: default_pool_timeout(),
236            db_tx_isolation: default_tx_isolation(),
237            db_prepared_statements: true,
238            db_extra_search_path: default_extra_search_path(),
239            db_channel: default_db_channel(),
240            db_channel_enabled: false,
241            db_pre_request: None,
242            db_max_rows: None,
243            db_aggregates_enabled: false,
244            server_host: default_host(),
245            server_port: default_port(),
246            server_cors_origins: Vec::new(),
247            max_body_size: default_max_body_size(),
248            server_unix_socket: None,
249            admin_server_port: None,
250            jwt_secret: None,
251            jwt_secret_is_base64: false,
252            jwt_aud: None,
253            jwt_role_claim_key: default_jwt_role_claim(),
254            jwt_cache_enabled: true,
255            jwt_cache_max_lifetime: default_jwt_cache_max(),
256            hasura_admin_secret: None,
257            hasura_unauthorized_role: None,
258            openapi_server_proxy_uri: None,
259            openapi_mode: OpenApiMode::FollowPrivileges,
260            log_level: default_log_level(),
261            role_settings: HashMap::new(),
262            app_settings: HashMap::new(),
263            graphql_federation: false,
264            graphql_type_prefix: None,
265            compat_mode: false,
266        }
267    }
268}
269
270/// Say that a variable was set to something unusable, rather than silently
271/// applying the default. A rejected value and an unset one are different
272/// mistakes and the operator can only fix the one they are told about.
273fn warn_ignored(var: &str, value: &str, expected: &str) {
274    tracing::warn!("Ignoring {}={:?}: expected {}", var, value, expected);
275}
276
277/// Parse the explicit spellings accepted for boolean environment variables.
278fn parse_bool(value: &str) -> Option<bool> {
279    match value.trim().to_ascii_lowercase().as_str() {
280        "true" | "1" | "yes" | "on" => Some(true),
281        "false" | "0" | "no" | "off" => Some(false),
282        _ => None,
283    }
284}
285
286fn env_bool(variable: &'static str, value: &str) -> Result<bool, ConfigError> {
287    parse_bool(value).ok_or_else(|| {
288        let error = ConfigError {
289            variable,
290            value: value.to_string(),
291            expected: EXPECTED_BOOLEAN,
292        };
293        tracing::warn!("{error}");
294        error
295    })
296}
297
298/// Validate a namespace before it becomes part of generated GraphQL names.
299fn parse_graphql_type_prefix(value: &str) -> Result<Option<String>, &'static str> {
300    let value = value.trim();
301    if value.is_empty() {
302        return Ok(None);
303    }
304
305    let mut chars = value.chars();
306    let valid_first = chars
307        .next()
308        .is_some_and(|c| c == '_' || c.is_ascii_alphabetic());
309    if !valid_first || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
310        return Err("a GraphQL identifier beginning with a letter or underscore");
311    }
312
313    Ok(Some(value.to_string()))
314}
315
316impl AppConfig {
317    /// Load configuration from environment variables.
318    ///
319    /// # Panics
320    ///
321    /// Panics if a boolean environment variable has an unsupported value. Use
322    /// [`Self::try_from_env`] when the caller can propagate a startup error.
323    pub fn from_env() -> Self {
324        Self::try_from_env().unwrap_or_else(|error| panic!("{error}"))
325    }
326
327    /// Load configuration from environment variables, rejecting invalid
328    /// boolean values.
329    pub fn try_from_env() -> Result<Self, ConfigError> {
330        let mut config = Self::default();
331
332        if let Ok(uri) = std::env::var("PGRST_DB_URI") {
333            config.db_uri = uri;
334        }
335        if let Ok(uri) = std::env::var("DATABASE_URL") {
336            config.db_uri = uri;
337        }
338        if let Ok(schemas) = std::env::var("PGRST_DB_SCHEMAS") {
339            config.db_schemas = schemas.split(',').map(|s| s.trim().to_string()).collect();
340        }
341        if let Ok(role) = std::env::var("PGRST_DB_ANON_ROLE") {
342            config.db_anon_role = Some(role);
343        }
344        // `PGRST_DB_POOL` is PostgREST's own name; `PGRST_DB_POOL_SIZE` is the
345        // one this project's documentation has always given, including the
346        // production checklist in docs/deployment.md. Only the former was read,
347        // so anybody who followed our own instructions silently kept the
348        // default pool.
349        for var in ["PGRST_DB_POOL", "PGRST_DB_POOL_SIZE"] {
350            if let Ok(size) = std::env::var(var) {
351                match size.parse::<u32>() {
352                    Ok(n) if n > 0 => config.db_pool_size = n,
353                    _ => warn_ignored(var, &size, "a positive integer"),
354                }
355            }
356        }
357        if let Ok(secs) = std::env::var("PGRST_DB_POOL_TIMEOUT") {
358            match secs.parse::<u64>() {
359                Ok(n) if n > 0 => config.db_pool_timeout = n,
360                _ => warn_ignored("PGRST_DB_POOL_TIMEOUT", &secs, "a positive integer"),
361            }
362        }
363        if let Ok(level) = std::env::var("PGRST_DB_TX_ISOLATION") {
364            match IsolationLevel::parse_config(&level) {
365                Some(l) => config.db_tx_isolation = l,
366                None => warn_ignored(
367                    "PGRST_DB_TX_ISOLATION",
368                    &level,
369                    "one of: read committed, repeatable read, serializable",
370                ),
371            }
372        }
373        if let Ok(v) = std::env::var("PGRST_DB_EXTRA_SEARCH_PATH") {
374            config.db_extra_search_path = v
375                .split(',')
376                .map(|s| s.trim().to_string())
377                .filter(|s| !s.is_empty())
378                .collect();
379        }
380
381        if let Ok(v) = std::env::var("PGRST_DB_AGGREGATES_ENABLED") {
382            config.db_aggregates_enabled = env_bool("PGRST_DB_AGGREGATES_ENABLED", &v)?;
383        }
384        if let Ok(v) = std::env::var("PGRST_DB_PREPARED_STATEMENTS") {
385            config.db_prepared_statements = env_bool("PGRST_DB_PREPARED_STATEMENTS", &v)?;
386        }
387        if let Ok(v) = std::env::var("PGRST_DB_PRE_REQUEST") {
388            if !v.trim().is_empty() {
389                config.db_pre_request = Some(v.trim().to_string());
390            }
391        }
392        if let Ok(v) = std::env::var("PGRST_DB_CHANNEL") {
393            if !v.trim().is_empty() {
394                config.db_channel = v.trim().to_string();
395            }
396        }
397        if let Ok(v) = std::env::var("PGRST_DB_CHANNEL_ENABLED") {
398            config.db_channel_enabled = env_bool("PGRST_DB_CHANNEL_ENABLED", &v)?;
399        }
400
401        // `PGRST_DB_MAX_ROWS` mirrors PostgREST's `db-max-rows`. Accept both.
402        for var in ["PGRST_DB_MAX_ROWS", "PGRST_MAX_ROWS"] {
403            if let Ok(max_rows) = std::env::var(var) {
404                match max_rows.parse::<i64>() {
405                    Ok(n) if n >= 0 => config.db_max_rows = Some(n),
406                    _ => warn_ignored(var, &max_rows, "a non-negative integer"),
407                }
408            }
409        }
410        if let Ok(secret) = std::env::var("PGRST_JWT_SECRET") {
411            config.jwt_secret = Some(secret);
412        }
413        if let Ok(aud) = std::env::var("PGRST_JWT_AUD") {
414            config.jwt_aud = Some(aud);
415        }
416        // Both of these reach the JWT layer already -- `JwtConfig` is built
417        // from them in every binary. Only the environment read was missing, so
418        // a base64 secret was verified against its own text and a role claim
419        // outside `role` was never found.
420        if let Ok(v) = std::env::var("PGRST_JWT_SECRET_IS_BASE64") {
421            config.jwt_secret_is_base64 = env_bool("PGRST_JWT_SECRET_IS_BASE64", &v)?;
422        }
423        if let Ok(key) = std::env::var("PGRST_JWT_ROLE_CLAIM_KEY") {
424            if !key.trim().is_empty() {
425                config.jwt_role_claim_key = key;
426            }
427        }
428        if let Ok(v) = std::env::var("PGRST_JWT_CACHE_ENABLED") {
429            config.jwt_cache_enabled = env_bool("PGRST_JWT_CACHE_ENABLED", &v)?;
430        }
431        // Zero is meaningful here -- it turns the cache off, as in PostgREST --
432        // so unlike the other durations it is not rejected.
433        if let Ok(v) = std::env::var("PGRST_JWT_CACHE_MAX_LIFETIME") {
434            match v.parse::<u64>() {
435                Ok(n) => config.jwt_cache_max_lifetime = n,
436                Err(_) => warn_ignored(
437                    "PGRST_JWT_CACHE_MAX_LIFETIME",
438                    &v,
439                    "a non-negative integer number of seconds",
440                ),
441            }
442        }
443        // Hasura's own spelling is accepted beside ours, because a deployment
444        // migrating from it has these in a compose file already and the point
445        // of the mode is that the client does not have to be rewritten. The
446        // `PGRST_` name wins where both are set, on the grounds that it is the
447        // more specific instruction to this server.
448        for var in ["HASURA_GRAPHQL_ADMIN_SECRET", "PGRST_HASURA_ADMIN_SECRET"] {
449            if let Ok(secret) = std::env::var(var) {
450                if !secret.is_empty() {
451                    config.hasura_admin_secret = Some(secret);
452                }
453            }
454        }
455        for var in [
456            "HASURA_GRAPHQL_UNAUTHORIZED_ROLE",
457            "PGRST_HASURA_UNAUTHORIZED_ROLE",
458        ] {
459            if let Ok(role) = std::env::var(var) {
460                if !role.is_empty() {
461                    config.hasura_unauthorized_role = Some(role);
462                }
463            }
464        }
465        if let Ok(host) = std::env::var("PGRST_SERVER_HOST") {
466            config.server_host = host;
467        }
468        if let Ok(port) = std::env::var("PGRST_SERVER_PORT") {
469            if let Ok(p) = port.parse() {
470                config.server_port = p;
471            }
472        }
473        if let Ok(port) = std::env::var("PORT") {
474            if let Ok(p) = port.parse() {
475                config.server_port = p;
476            }
477        }
478        // A bare `*` is the documented way to spell "any origin", and is the
479        // default. An empty list means the same thing, so a value of only
480        // separators does not accidentally lock everybody out.
481        if let Ok(origins) = std::env::var("PGRST_SERVER_CORS_ORIGINS") {
482            config.server_cors_origins = if origins.trim() == "*" {
483                Vec::new()
484            } else {
485                origins
486                    .split(',')
487                    .map(|s| s.trim().to_string())
488                    .filter(|s| !s.is_empty())
489                    .collect()
490            };
491        }
492        if let Ok(bytes) = std::env::var("PGRST_MAX_BODY_SIZE") {
493            match bytes.parse::<usize>() {
494                Ok(n) if n > 0 => config.max_body_size = n,
495                _ => warn_ignored("PGRST_MAX_BODY_SIZE", &bytes, "a positive integer"),
496            }
497        }
498        if let Ok(level) = std::env::var("PGRST_LOG_LEVEL") {
499            match LogLevel::parse_config(&level) {
500                Some(l) => config.log_level = l,
501                None => warn_ignored(
502                    "PGRST_LOG_LEVEL",
503                    &level,
504                    "one of: crit, error, warn, info, debug",
505                ),
506            }
507        }
508        if let Ok(path) = std::env::var("PGRST_SERVER_UNIX_SOCKET") {
509            if !path.trim().is_empty() {
510                config.server_unix_socket = Some(path.trim().to_string());
511            }
512        }
513        if let Ok(port) = std::env::var("PGRST_ADMIN_SERVER_PORT") {
514            match port.parse::<u16>() {
515                Ok(p) if p > 0 => config.admin_server_port = Some(p),
516                _ => warn_ignored("PGRST_ADMIN_SERVER_PORT", &port, "a port number"),
517            }
518        }
519        if let Ok(uri) = std::env::var("PGRST_OPENAPI_SERVER_PROXY_URI") {
520            if !uri.trim().is_empty() {
521                config.openapi_server_proxy_uri = Some(uri.trim().to_string());
522            }
523        }
524        if let Ok(mode) = std::env::var("PGRST_OPENAPI_MODE") {
525            match OpenApiMode::parse_config(&mode) {
526                Some(m) => config.openapi_mode = m,
527                None => warn_ignored(
528                    "PGRST_OPENAPI_MODE",
529                    &mode,
530                    "one of: disabled, follow-privileges, ignore-privileges",
531                ),
532            }
533        }
534
535        // `PGRST_ROLE_SETTINGS` is JSON rather than a per-role variable,
536        // because a PostgreSQL role name may hold characters an environment
537        // variable name cannot.
538        if let Ok(raw) = std::env::var("PGRST_ROLE_SETTINGS") {
539            match serde_json::from_str::<HashMap<String, RoleSettings>>(&raw) {
540                Ok(settings) => config.role_settings = settings,
541                Err(e) => warn_ignored(
542                    "PGRST_ROLE_SETTINGS",
543                    &raw,
544                    &format!("a JSON object of role to settings ({e})"),
545                ),
546            }
547        }
548
549        if let Ok(value) = std::env::var("PGRST_GRAPHQL_FEDERATION") {
550            config.graphql_federation = env_bool("PGRST_GRAPHQL_FEDERATION", &value)?;
551        }
552        if let Ok(value) = std::env::var("PGRST_GRAPHQL_TYPE_PREFIX") {
553            match parse_graphql_type_prefix(&value) {
554                Ok(prefix) => config.graphql_type_prefix = prefix,
555                Err(expected) => warn_ignored("PGRST_GRAPHQL_TYPE_PREFIX", &value, expected),
556            }
557        }
558
559        // `PGRST_APP_SETTINGS_<NAME>` becomes `app.settings.<name>`, which is
560        // where PostgREST puts it and where a policy expects to read it back.
561        for (key, value) in std::env::vars() {
562            if let Some(name) = key.strip_prefix("PGRST_APP_SETTINGS_") {
563                if !name.is_empty() {
564                    config.app_settings.insert(name.to_ascii_lowercase(), value);
565                }
566            }
567        }
568        // Accept either the PGRST_-prefixed name (for parity with other options)
569        // or a POSTRUST_-prefixed alias.
570        for var in ["PGRST_COMPAT_MODE", "POSTRUST_COMPAT_MODE"] {
571            if let Ok(v) = std::env::var(var) {
572                config.compat_mode = env_bool(var, &v)?;
573            }
574        }
575
576        Ok(config)
577    }
578
579    /// Get the default schema (first in the list).
580    pub fn default_schema(&self) -> &str {
581        self.db_schemas
582            .first()
583            .map(|s| s.as_str())
584            .unwrap_or("public")
585    }
586}
587
588// Serialized as the spelling configuration uses, not as the Rust variant name,
589// so that what this writes is what `parse_config` reads back. Deriving
590// `Serialize` here instead emits `"ReadCommitted"`, which then fails to
591// deserialize -- and `Serializable` happens to survive the round trip, which
592// makes the breakage look intermittent rather than total.
593impl Serialize for IsolationLevel {
594    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
595        serializer.serialize_str(self.as_config_str())
596    }
597}
598
599impl<'de> Deserialize<'de> for IsolationLevel {
600    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
601        let raw = String::deserialize(deserializer)?;
602        Self::parse_config(&raw).ok_or_else(|| {
603            serde::de::Error::custom(format!(
604                "unknown isolation level {raw:?}: expected one of \
605                 read committed, repeatable read, serializable"
606            ))
607        })
608    }
609}
610
611impl Serialize for OpenApiMode {
612    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
613        serializer.serialize_str(self.as_config_str())
614    }
615}
616
617impl<'de> Deserialize<'de> for OpenApiMode {
618    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
619        let raw = String::deserialize(deserializer)?;
620        Self::parse_config(&raw).ok_or_else(|| {
621            serde::de::Error::custom(format!(
622                "unknown OpenAPI mode {raw:?}: expected one of \
623                 disabled, follow-privileges, ignore-privileges"
624            ))
625        })
626    }
627}
628
629impl Serialize for LogLevel {
630    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
631        serializer.serialize_str(self.as_config_str())
632    }
633}
634
635impl<'de> Deserialize<'de> for LogLevel {
636    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
637        let raw = String::deserialize(deserializer)?;
638        Self::parse_config(&raw).ok_or_else(|| {
639            serde::de::Error::custom(format!(
640                "unknown log level {raw:?}: expected one of \
641                 crit, error, warn, info, debug"
642            ))
643        })
644    }
645}
646
647/// Per-role settings.
648#[derive(Clone, Debug, Default, Serialize, Deserialize)]
649#[non_exhaustive]
650pub struct RoleSettings {
651    /// Isolation level for this role
652    pub isolation_level: Option<IsolationLevel>,
653    /// Statement timeout in milliseconds
654    pub statement_timeout: Option<u64>,
655}
656
657/// Transaction isolation levels.
658///
659/// Deserialized from the spelling the documentation uses (`"serializable"`,
660/// `"read committed"`), not from the Rust variant name, so a `role_settings`
661/// entry reads the same as the environment variable.
662#[derive(Clone, Debug, PartialEq, Eq)]
663pub enum IsolationLevel {
664    /// PostgreSQL's default. A statement sees rows committed before it began.
665    ReadCommitted,
666    /// Every statement in the transaction sees the same snapshot.
667    RepeatableRead,
668    /// As `RepeatableRead`, and concurrent transactions are additionally
669    /// guaranteed to be equivalent to some serial order. May abort with a
670    /// serialization failure, which the client is expected to retry.
671    Serializable,
672}
673
674impl IsolationLevel {
675    /// The spelling PostgreSQL's `SET TRANSACTION ISOLATION LEVEL` expects.
676    pub fn to_sql(&self) -> &'static str {
677        match self {
678            Self::ReadCommitted => "READ COMMITTED",
679            Self::RepeatableRead => "REPEATABLE READ",
680            Self::Serializable => "SERIALIZABLE",
681        }
682    }
683
684    /// Parse the spelling used in configuration.
685    ///
686    /// Case-insensitive, and a hyphen, underscore or space separates the words
687    /// equally -- the documentation writes `read committed` and an environment
688    /// file is as likely to carry `read-committed`.
689    pub fn parse_config(s: &str) -> Option<Self> {
690        match s
691            .trim()
692            .to_ascii_lowercase()
693            .replace(['-', '_'], " ")
694            .as_str()
695        {
696            "read committed" => Some(Self::ReadCommitted),
697            "repeatable read" => Some(Self::RepeatableRead),
698            "serializable" => Some(Self::Serializable),
699            _ => None,
700        }
701    }
702
703    /// The inverse of [`parse_config`](Self::parse_config).
704    pub fn as_config_str(&self) -> &'static str {
705        match self {
706            Self::ReadCommitted => "read committed",
707            Self::RepeatableRead => "repeatable read",
708            Self::Serializable => "serializable",
709        }
710    }
711}
712
713/// OpenAPI generation mode.
714///
715/// PostgREST also has a `security-definer` mode, where the specification comes
716/// from a user-supplied `SECURITY DEFINER` function. There is no configuration
717/// here for naming that function, so the variant is absent rather than present
718/// and behaving as one of the others.
719#[derive(Clone, Debug, PartialEq, Eq)]
720pub enum OpenApiMode {
721    /// No OpenAPI document is served.
722    Disabled,
723    /// The document describes only what the requesting role may reach, so two
724    /// roles asking are answered differently.
725    FollowPrivileges,
726    /// The document describes the whole exposed schema, whoever is asking.
727    IgnorePrivileges,
728}
729
730impl OpenApiMode {
731    /// Parse the spelling used in configuration. Case-insensitive, and a
732    /// hyphen or an underscore separates the words equally.
733    pub fn parse_config(s: &str) -> Option<Self> {
734        match s
735            .trim()
736            .to_ascii_lowercase()
737            .replace(['-', '_'], " ")
738            .as_str()
739        {
740            "disabled" => Some(Self::Disabled),
741            "follow privileges" => Some(Self::FollowPrivileges),
742            "ignore privileges" => Some(Self::IgnorePrivileges),
743            _ => None,
744        }
745    }
746
747    /// The inverse of [`parse_config`](Self::parse_config).
748    pub fn as_config_str(&self) -> &'static str {
749        match self {
750            Self::Disabled => "disabled",
751            Self::FollowPrivileges => "follow-privileges",
752            Self::IgnorePrivileges => "ignore-privileges",
753        }
754    }
755}
756
757/// Log levels.
758#[derive(Clone, Debug, PartialEq, Eq)]
759pub enum LogLevel {
760    /// Only failures that stop the server. Mapped onto `ERROR`, which is the
761    /// least severe level `tracing` offers.
762    Crit,
763    /// Requests that failed, and faults that did not stop the server.
764    Error,
765    /// Configuration that was ignored, and conditions worth noticing.
766    Warn,
767    /// Start-up, schema reloads, and one line per request.
768    Info,
769    /// The generated SQL and the decisions behind it.
770    Debug,
771}
772
773impl LogLevel {
774    /// The `tracing` level this maps onto.
775    ///
776    /// `Crit` and `Error` both become `ERROR`: PostgREST distinguishes them
777    /// and `tracing` has nothing more severe to map `Crit` onto.
778    pub fn to_tracing(&self) -> tracing::Level {
779        match self {
780            Self::Crit | Self::Error => tracing::Level::ERROR,
781            Self::Warn => tracing::Level::WARN,
782            Self::Info => tracing::Level::INFO,
783            Self::Debug => tracing::Level::DEBUG,
784        }
785    }
786
787    /// Parse the spelling used in configuration. Case-insensitive.
788    pub fn parse_config(s: &str) -> Option<Self> {
789        match s.trim().to_ascii_lowercase().as_str() {
790            "crit" | "critical" => Some(Self::Crit),
791            "error" => Some(Self::Error),
792            "warn" | "warning" => Some(Self::Warn),
793            "info" => Some(Self::Info),
794            "debug" => Some(Self::Debug),
795            _ => None,
796        }
797    }
798
799    /// The inverse of [`parse_config`](Self::parse_config).
800    pub fn as_config_str(&self) -> &'static str {
801        match self {
802            Self::Crit => "crit",
803            Self::Error => "error",
804            Self::Warn => "warn",
805            Self::Info => "info",
806            Self::Debug => "debug",
807        }
808    }
809}
810
811// Default value functions
812fn default_db_uri() -> String {
813    "postgresql://localhost/postgres".to_string()
814}
815
816fn default_db_schemas() -> Vec<String> {
817    vec!["public".to_string()]
818}
819
820fn default_pool_size() -> u32 {
821    10
822}
823
824fn default_pool_timeout() -> u64 {
825    10
826}
827
828fn default_tx_isolation() -> IsolationLevel {
829    IsolationLevel::ReadCommitted
830}
831
832fn default_max_body_size() -> usize {
833    10 * 1024 * 1024
834}
835
836fn default_extra_search_path() -> Vec<String> {
837    vec!["public".to_string()]
838}
839
840fn default_db_channel() -> String {
841    "pgrst".to_string()
842}
843
844fn default_host() -> String {
845    "127.0.0.1".to_string()
846}
847
848fn default_port() -> u16 {
849    3000
850}
851
852fn default_jwt_role_claim() -> String {
853    "role".to_string()
854}
855
856fn default_jwt_cache_max() -> u64 {
857    3600
858}
859
860fn default_openapi_mode() -> OpenApiMode {
861    OpenApiMode::FollowPrivileges
862}
863
864// `info`, not `error`. The documentation has always said `info`, and the
865// server's own fallback filter is `postrust=info`, so `error` was a third
866// answer that agreed with neither -- harmless only for as long as nothing
867// read the field.
868fn default_log_level() -> LogLevel {
869    LogLevel::Info
870}
871
872fn default_true() -> bool {
873    true
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn test_default_config() {
882        let config = AppConfig::default();
883        assert_eq!(config.server_port, 3000);
884        assert_eq!(config.db_pool_size, 10);
885        assert!(config.db_prepared_statements);
886        assert!(!config.graphql_federation);
887        assert_eq!(config.graphql_type_prefix, None);
888    }
889
890    #[test]
891    fn test_default_schema() {
892        let mut config = AppConfig::default();
893        assert_eq!(config.default_schema(), "public");
894
895        config.db_schemas = vec!["api".to_string(), "public".to_string()];
896        assert_eq!(config.default_schema(), "api");
897    }
898
899    #[test]
900    fn test_isolation_level_sql() {
901        assert_eq!(IsolationLevel::ReadCommitted.to_sql(), "READ COMMITTED");
902        assert_eq!(IsolationLevel::Serializable.to_sql(), "SERIALIZABLE");
903    }
904
905    #[test]
906    fn isolation_level_accepts_the_documented_spellings() {
907        // The documentation writes `read committed`; an environment file is as
908        // likely to carry either separator, or capitals.
909        for s in [
910            "read committed",
911            "READ COMMITTED",
912            "read-committed",
913            " Read_Committed ",
914        ] {
915            assert_eq!(
916                IsolationLevel::parse_config(s),
917                Some(IsolationLevel::ReadCommitted),
918                "{s:?}"
919            );
920        }
921        assert_eq!(
922            IsolationLevel::parse_config("serializable"),
923            Some(IsolationLevel::Serializable)
924        );
925        assert_eq!(
926            IsolationLevel::parse_config("repeatable read"),
927            Some(IsolationLevel::RepeatableRead)
928        );
929        // Not a level. Rejected rather than resolved to a default, so the
930        // caller can say so.
931        assert_eq!(IsolationLevel::parse_config("dirty read"), None);
932        assert_eq!(IsolationLevel::parse_config(""), None);
933    }
934
935    #[test]
936    fn log_level_accepts_the_documented_spellings() {
937        assert_eq!(LogLevel::parse_config("INFO"), Some(LogLevel::Info));
938        assert_eq!(LogLevel::parse_config("warning"), Some(LogLevel::Warn));
939        assert_eq!(LogLevel::parse_config("crit"), Some(LogLevel::Crit));
940        // `trace` was listed in the documentation and is not a level this
941        // server has. It is rejected rather than silently read as `debug`.
942        assert_eq!(LogLevel::parse_config("trace"), None);
943    }
944
945    #[test]
946    fn default_log_level_matches_the_documented_one() {
947        // The struct default disagreed with both the documentation and the
948        // server's own fallback filter for as long as nothing read it.
949        assert_eq!(AppConfig::default().log_level, LogLevel::Info);
950    }
951
952    #[test]
953    fn parse_bool_accepts_only_the_supported_spellings() {
954        for s in ["true", "TRUE", "1", "yes", "on", " On "] {
955            assert_eq!(parse_bool(s), Some(true), "{s:?}");
956        }
957        for s in ["false", "FALSE", "0", "no", "off", " Off "] {
958            assert_eq!(parse_bool(s), Some(false), "{s:?}");
959        }
960        for s in ["", "maybe", "enabled", "2"] {
961            assert_eq!(parse_bool(s), None, "{s:?}");
962        }
963    }
964
965    #[test]
966    fn graphql_federation_configuration_is_read() {
967        let config = with_env(&[
968            ("PGRST_GRAPHQL_FEDERATION", "true"),
969            ("PGRST_GRAPHQL_TYPE_PREFIX", " test "),
970        ]);
971
972        assert!(config.graphql_federation);
973        assert_eq!(config.graphql_type_prefix.as_deref(), Some("test"));
974    }
975
976    #[test]
977    fn graphql_federation_accepts_an_explicit_false() {
978        assert!(!with_env(&[("PGRST_GRAPHQL_FEDERATION", "false")]).graphql_federation);
979    }
980
981    #[test]
982    fn malformed_graphql_federation_configuration_is_rejected() {
983        let error = with_env_result(&[("PGRST_GRAPHQL_FEDERATION", "sometimes")])
984            .expect_err("an invalid boolean must reject the configuration");
985
986        assert_eq!(
987            error.to_string(),
988            "invalid value for PGRST_GRAPHQL_FEDERATION: \"sometimes\"; expected one of: true, false, 1, 0, yes, no, on, off"
989        );
990    }
991
992    #[test]
993    fn an_empty_graphql_prefix_means_unset() {
994        let config = with_env(&[("PGRST_GRAPHQL_TYPE_PREFIX", "   ")]);
995
996        assert_eq!(config.graphql_type_prefix, None);
997    }
998
999    // `from_env` reads process-global state, so these run one at a time.
1000    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1001
1002    /// Set the given variables, load, then remove them again.
1003    fn with_env(vars: &[(&str, &str)]) -> AppConfig {
1004        with_env_result(vars).expect("test configuration should be valid")
1005    }
1006
1007    fn with_env_result(vars: &[(&str, &str)]) -> Result<AppConfig, ConfigError> {
1008        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1009        for (k, v) in vars {
1010            std::env::set_var(k, v);
1011        }
1012        let config = AppConfig::try_from_env();
1013        for (k, _) in vars {
1014            std::env::remove_var(k);
1015        }
1016        config
1017    }
1018
1019    #[test]
1020    fn documented_variables_are_read() {
1021        // Every one of these was documented and read by nothing. The features
1022        // behind them already worked -- only the environment read was missing.
1023        let config = with_env(&[
1024            ("PGRST_DB_POOL_SIZE", "42"),
1025            ("PGRST_DB_POOL_TIMEOUT", "7"),
1026            ("PGRST_DB_TX_ISOLATION", "serializable"),
1027            ("PGRST_JWT_SECRET_IS_BASE64", "true"),
1028            ("PGRST_JWT_ROLE_CLAIM_KEY", "user.role"),
1029            ("PGRST_LOG_LEVEL", "warn"),
1030            ("PGRST_MAX_BODY_SIZE", "1024"),
1031            (
1032                "PGRST_SERVER_CORS_ORIGINS",
1033                "https://a.example, https://b.example",
1034            ),
1035        ]);
1036
1037        assert_eq!(config.db_pool_size, 42);
1038        assert_eq!(config.db_pool_timeout, 7);
1039        assert_eq!(config.db_tx_isolation, IsolationLevel::Serializable);
1040        assert!(config.jwt_secret_is_base64);
1041        assert_eq!(config.jwt_role_claim_key, "user.role");
1042        assert_eq!(config.log_level, LogLevel::Warn);
1043        assert_eq!(config.max_body_size, 1024);
1044        assert_eq!(
1045            config.server_cors_origins,
1046            vec![
1047                "https://a.example".to_string(),
1048                "https://b.example".to_string()
1049            ]
1050        );
1051    }
1052
1053    #[test]
1054    fn pool_size_accepts_postgrests_own_name() {
1055        assert_eq!(with_env(&[("PGRST_DB_POOL", "5")]).db_pool_size, 5);
1056    }
1057
1058    #[test]
1059    fn a_star_origin_means_any_origin() {
1060        // Empty is how "any" is carried, so `*` must not become a literal
1061        // origin that matches nothing.
1062        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", "*")])
1063            .server_cors_origins
1064            .is_empty());
1065        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", " , ")])
1066            .server_cors_origins
1067            .is_empty());
1068    }
1069
1070    #[test]
1071    fn an_unusable_value_leaves_the_default() {
1072        let config = with_env(&[
1073            ("PGRST_DB_POOL_SIZE", "lots"),
1074            ("PGRST_DB_POOL_TIMEOUT", "0"),
1075            ("PGRST_MAX_BODY_SIZE", "-1"),
1076            ("PGRST_DB_TX_ISOLATION", "dirty read"),
1077            ("PGRST_LOG_LEVEL", "shout"),
1078        ]);
1079
1080        let d = AppConfig::default();
1081        assert_eq!(config.db_pool_size, d.db_pool_size);
1082        assert_eq!(config.db_pool_timeout, d.db_pool_timeout);
1083        assert_eq!(config.max_body_size, d.max_body_size);
1084        assert_eq!(config.db_tx_isolation, d.db_tx_isolation);
1085        assert_eq!(config.log_level, d.log_level);
1086    }
1087
1088    #[test]
1089    fn the_formerly_dead_fields_are_read() {
1090        // Every one of these was a public field that nothing set and nothing
1091        // consumed.
1092        let config = with_env(&[
1093            ("PGRST_DB_PREPARED_STATEMENTS", "false"),
1094            ("PGRST_DB_PRE_REQUEST", "auth.check"),
1095            ("PGRST_DB_CHANNEL", "my_channel"),
1096            ("PGRST_DB_CHANNEL_ENABLED", "yes"),
1097            ("PGRST_JWT_CACHE_ENABLED", "false"),
1098            ("PGRST_JWT_CACHE_MAX_LIFETIME", "60"),
1099            ("PGRST_SERVER_UNIX_SOCKET", "/tmp/postrust.sock"),
1100            ("PGRST_ADMIN_SERVER_PORT", "3001"),
1101            ("PGRST_OPENAPI_SERVER_PROXY_URI", "https://api.example.com"),
1102            ("PGRST_OPENAPI_MODE", "ignore-privileges"),
1103        ]);
1104
1105        assert!(!config.db_prepared_statements);
1106        assert_eq!(config.db_pre_request.as_deref(), Some("auth.check"));
1107        assert_eq!(config.db_channel, "my_channel");
1108        assert!(config.db_channel_enabled);
1109        assert!(!config.jwt_cache_enabled);
1110        assert_eq!(config.jwt_cache_max_lifetime, 60);
1111        assert_eq!(
1112            config.server_unix_socket.as_deref(),
1113            Some("/tmp/postrust.sock")
1114        );
1115        assert_eq!(config.admin_server_port, Some(3001));
1116        assert_eq!(
1117            config.openapi_server_proxy_uri.as_deref(),
1118            Some("https://api.example.com")
1119        );
1120        assert_eq!(config.openapi_mode, OpenApiMode::IgnorePrivileges);
1121    }
1122
1123    #[test]
1124    fn a_zero_jwt_cache_lifetime_is_kept_because_it_turns_the_cache_off() {
1125        // Unlike the other durations, 0 is a meaningful value here and must
1126        // not be rejected as unusable.
1127        assert_eq!(
1128            with_env(&[("PGRST_JWT_CACHE_MAX_LIFETIME", "0")]).jwt_cache_max_lifetime,
1129            0
1130        );
1131    }
1132
1133    #[test]
1134    fn app_settings_come_from_a_prefix() {
1135        let config = with_env(&[
1136            ("PGRST_APP_SETTINGS_JWT_LIFETIME", "3600"),
1137            ("PGRST_APP_SETTINGS_TenantName", "acme"),
1138        ]);
1139        // Lower-cased, because that is the name a policy reads back with
1140        // `current_setting('app.settings.<name>')`.
1141        assert_eq!(
1142            config.app_settings.get("jwt_lifetime").map(String::as_str),
1143            Some("3600")
1144        );
1145        assert_eq!(
1146            config.app_settings.get("tenantname").map(String::as_str),
1147            Some("acme")
1148        );
1149    }
1150
1151    #[test]
1152    fn role_settings_parse_from_json_using_the_documented_spellings() {
1153        let config = with_env(&[(
1154            "PGRST_ROLE_SETTINGS",
1155            r#"{"web_user":{"isolation_level":"serializable","statement_timeout":5000}}"#,
1156        )]);
1157        let web_user = config.role_settings.get("web_user").expect("role present");
1158        assert_eq!(web_user.isolation_level, Some(IsolationLevel::Serializable));
1159        assert_eq!(web_user.statement_timeout, Some(5000));
1160    }
1161
1162    #[test]
1163    fn unparseable_role_settings_leave_the_map_empty() {
1164        assert!(with_env(&[("PGRST_ROLE_SETTINGS", "{not json")])
1165            .role_settings
1166            .is_empty());
1167        // A valid JSON object naming a level that does not exist is refused
1168        // as a whole rather than half-applied.
1169        assert!(with_env(&[(
1170            "PGRST_ROLE_SETTINGS",
1171            r#"{"r":{"isolation_level":"dirty"}}"#
1172        )])
1173        .role_settings
1174        .is_empty());
1175    }
1176
1177    #[test]
1178    fn openapi_mode_accepts_the_documented_spellings() {
1179        assert_eq!(
1180            OpenApiMode::parse_config("follow-privileges"),
1181            Some(OpenApiMode::FollowPrivileges)
1182        );
1183        assert_eq!(
1184            OpenApiMode::parse_config("IGNORE_PRIVILEGES"),
1185            Some(OpenApiMode::IgnorePrivileges)
1186        );
1187        assert_eq!(
1188            OpenApiMode::parse_config("disabled"),
1189            Some(OpenApiMode::Disabled)
1190        );
1191        // PostgREST's fourth mode needs a function this server has no
1192        // configuration for, so it is not accepted rather than aliased.
1193        assert_eq!(OpenApiMode::parse_config("security-definer"), None);
1194    }
1195
1196    #[test]
1197    fn the_config_enums_round_trip_through_json() {
1198        // Deriving `Serialize` emits the Rust variant name while the
1199        // hand-written `Deserialize` reads the configuration spelling, so a
1200        // value this type writes could not be read back. `Serializable`
1201        // survived by coincidence -- its variant name lowercases to the
1202        // accepted spelling -- which made the breakage look intermittent.
1203        for level in [
1204            IsolationLevel::ReadCommitted,
1205            IsolationLevel::RepeatableRead,
1206            IsolationLevel::Serializable,
1207        ] {
1208            let json = serde_json::to_string(&level).unwrap();
1209            let back: IsolationLevel = serde_json::from_str(&json).unwrap();
1210            assert_eq!(back, level, "{json}");
1211        }
1212
1213        for mode in [
1214            OpenApiMode::Disabled,
1215            OpenApiMode::FollowPrivileges,
1216            OpenApiMode::IgnorePrivileges,
1217        ] {
1218            let json = serde_json::to_string(&mode).unwrap();
1219            assert_eq!(serde_json::from_str::<OpenApiMode>(&json).unwrap(), mode);
1220        }
1221
1222        for level in [
1223            LogLevel::Crit,
1224            LogLevel::Error,
1225            LogLevel::Warn,
1226            LogLevel::Info,
1227            LogLevel::Debug,
1228        ] {
1229            let json = serde_json::to_string(&level).unwrap();
1230            assert_eq!(serde_json::from_str::<LogLevel>(&json).unwrap(), level);
1231        }
1232    }
1233
1234    #[test]
1235    fn a_whole_config_round_trips_through_json() {
1236        // The struct derives both, which invites persisting it.
1237        let mut config = AppConfig::default();
1238        config.role_settings.insert(
1239            "web_user".to_string(),
1240            RoleSettings {
1241                isolation_level: Some(IsolationLevel::RepeatableRead),
1242                statement_timeout: Some(5000),
1243            },
1244        );
1245
1246        let json = serde_json::to_string(&config).unwrap();
1247        let back: AppConfig = serde_json::from_str(&json).expect("config should round trip");
1248
1249        assert_eq!(back.db_tx_isolation, config.db_tx_isolation);
1250        assert_eq!(back.log_level, config.log_level);
1251        assert_eq!(back.openapi_mode, config.openapi_mode);
1252        assert_eq!(
1253            back.role_settings["web_user"].isolation_level,
1254            Some(IsolationLevel::RepeatableRead)
1255        );
1256    }
1257
1258    #[test]
1259    fn the_serialized_spelling_is_the_configuration_spelling() {
1260        // Not the Rust variant name: what is written should be what an
1261        // operator could paste into the environment.
1262        assert_eq!(
1263            serde_json::to_string(&IsolationLevel::ReadCommitted).unwrap(),
1264            "\"read committed\""
1265        );
1266        assert_eq!(
1267            serde_json::to_string(&OpenApiMode::FollowPrivileges).unwrap(),
1268            "\"follow-privileges\""
1269        );
1270        assert_eq!(serde_json::to_string(&LogLevel::Warn).unwrap(), "\"warn\"");
1271    }
1272
1273    #[test]
1274    fn an_empty_role_claim_key_is_not_accepted() {
1275        // An empty key would look up nothing and every request would fall to
1276        // the anonymous role.
1277        assert_eq!(
1278            with_env(&[("PGRST_JWT_ROLE_CLAIM_KEY", "  ")]).jwt_role_claim_key,
1279            AppConfig::default().jwt_role_claim_key
1280        );
1281    }
1282}