Skip to main content

fraiseql_core/schema/compiled/
schema.rs

1//! Compiled schema types - pure Rust, no authoring-language references.
2//!
3//! These types represent GraphQL schemas after compilation from authoring languages.
4//! All data is owned by Rust - no foreign object references.
5//!
6//! # Schema Freeze Invariant
7//!
8//! After `CompiledSchema::from_json()`, the schema is frozen:
9//! - All data is Rust-owned
10//! - No authoring-language callbacks or object references
11//! - Safe to use from any Tokio worker thread
12//!
13//! This enables the Axum server to handle requests without any
14//! interaction with the authoring-language runtime.
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20use super::{directive::DirectiveDefinition, mutation::MutationDefinition, query::QueryDefinition};
21use crate::{
22    compiler::fact_table::FactTableMetadata,
23    schema::{
24        config_types::{
25            ChangelogConfig, DebugConfig, FederationConfig, GrpcConfig, McpConfig,
26            NamingConvention, ObserversConfig, RestConfig, SessionVariablesConfig,
27            SubscriptionsConfig, ValidationConfig,
28        },
29        graphql_type_defs::{
30            EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition,
31            UnionDefinition,
32        },
33        hierarchy::HierarchiesConfig,
34        observer_types::ObserverDefinition,
35        security_config::SecurityConfig,
36        source_types::SourceDefinition,
37        subscription_types::SubscriptionDefinition,
38    },
39    validation::CustomTypeRegistry,
40};
41
42/// The fraiseql build this binary is part of (#1304).
43///
44/// Every crate in the workspace takes `version.workspace`, so a compiler and a
45/// runtime that link the same `fraiseql-core` read the same value here — which
46/// is the invariant [`ProducerVersion`] compares, and a sharper one than the
47/// two binaries' own version strings.
48pub const CURRENT_FRAISEQL_VERSION: &str = env!("CARGO_PKG_VERSION");
49
50/// The fraiseql build that produced a compiled schema (#1304).
51///
52/// A compiled schema is a build artifact of the release that produced it, and
53/// its meaning is not stable across releases — an *absent* field is a value the
54/// runtime reads, not a gap it ignores. `pagination_order` is the worked
55/// example: 2.14 wrote none, 2.15 reads its absence as "this query has no page
56/// order", so a 2.14 artifact paginates a 2.15 runtime into overlapping pages
57/// under a `200`, which is the defect 2.15 shipped to remove. Nothing about
58/// that is visible to an operator.
59///
60/// The stamp is therefore *compared*, never interpreted: a runtime does not
61/// decide whether some other build's artifact is close enough, because deciding
62/// that is the judgement #1303 got wrong. What the type buys over the
63/// `Option<u32>` format version it replaces is that the two producers of a
64/// `CompiledSchema` can no longer collapse into one value:
65///
66/// * the compiler stamps the build it is part of into the artifact it writes;
67/// * a `CompiledSchema` built in process **is** this build, so [`Default`] yields it;
68/// * an artifact carrying no stamp is [`unstamped`](Self::unstamped), a third value — where `None`
69///   previously meant both "compiled before versioning existed" and "freshly constructed", and was
70///   accepted on the strength of the second reading.
71///
72/// See `docs/adr/0020-compiled-schema-build-identity.md`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(transparent)]
75pub struct ProducerVersion(Option<String>);
76
77impl Default for ProducerVersion {
78    /// This build — the honest answer for a schema this binary just constructed.
79    fn default() -> Self {
80        Self(Some(CURRENT_FRAISEQL_VERSION.to_string()))
81    }
82}
83
84impl ProducerVersion {
85    /// The value an artifact carrying no `fraiseql_version` deserializes to.
86    ///
87    /// Reachable only through deserialization: [`Default`] is this build, so a
88    /// missing JSON key is the one way to obtain it.
89    #[must_use]
90    pub const fn unstamped() -> Self {
91        Self(None)
92    }
93
94    /// The build named by the stamp, or `None` when the artifact carries none.
95    #[must_use]
96    pub fn get(&self) -> Option<&str> {
97        self.0.as_deref()
98    }
99
100    /// Whether the artifact carries no stamp.
101    #[must_use]
102    pub const fn is_unstamped(&self) -> bool {
103        self.0.is_none()
104    }
105}
106
107/// A `@subscribable` declaration in the compiled schema (#366).
108///
109/// Maps a GraphQL type to the physical base table(s) whose **external** writes
110/// (a raw `INSERT`/`UPDATE`/`DELETE` from psql / a migration / a third-party
111/// tool) should be captured onto the Change Spine by the shipped fallback trigger
112/// `core.fn_entity_change_log_capture`. The compiler aggregates one of these per
113/// type carrying `@subscribable(tables=[...])`; the
114/// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
115/// generator turns them into per-table statement-level triggers that stamp
116/// `object_type = entity_type` — the GraphQL type name the reader and the
117/// subscription matcher key on, never the table name — so a captured external
118/// write fans out through the existing poller with no table→type lookup.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct SubscribableEntity {
121    /// The GraphQL type name (e.g. `"Post"`) stamped as `object_type` on every
122    /// captured change-log row.
123    pub entity_type: String,
124
125    /// The physical base table(s) backing `entity_type` (e.g. `["tb_post"]`,
126    /// optionally schema-qualified `["public.tb_post"]`). A capture trigger is
127    /// installed on each.
128    pub tables: Vec<String>,
129
130    /// Whether the capture triggers on this entity's tables also record the
131    /// changed entity's **pre-image** (OLD) into `object_data_before` — the
132    /// out-of-band parity for the per-mutation
133    /// [`changelog_pre_image`](super::MutationDefinition::changelog_pre_image).
134    ///
135    /// The trigger always unifies `object_data` on the after-image (NEW)
136    /// regardless of this flag; `pre_image` only adds the separate before-image
137    /// column for opted-in tables, so audit-sensitive entities get an inline
138    /// Debezium `{before, after}` even for raw external writes. Default `false`
139    /// (opt in via `@subscribable(tables=[...], pre_image=True)`); an absent value
140    /// is byte-identical to before this field existed, so it does not churn the
141    /// codegen schema hash.
142    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
143    pub pre_image: bool,
144}
145
146/// Complete compiled schema - all type information for serving.
147///
148/// This is the central type that holds the entire GraphQL schema
149/// after compilation from any supported authoring language.
150///
151/// # Example
152///
153/// ```
154/// use fraiseql_core::schema::CompiledSchema;
155///
156/// let json = r#"{
157///     "types": [],
158///     "queries": [],
159///     "mutations": [],
160///     "subscriptions": []
161/// }"#;
162///
163/// let schema = CompiledSchema::from_json(json, false).unwrap();
164/// assert_eq!(schema.types.len(), 0);
165/// ```
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167pub struct CompiledSchema {
168    /// GraphQL object type definitions.
169    #[serde(default)]
170    pub types: Vec<TypeDefinition>,
171
172    /// GraphQL enum type definitions.
173    #[serde(default)]
174    pub enums: Vec<EnumDefinition>,
175
176    /// GraphQL input object type definitions.
177    #[serde(default)]
178    pub input_types: Vec<InputObjectDefinition>,
179
180    /// GraphQL interface type definitions.
181    #[serde(default)]
182    pub interfaces: Vec<InterfaceDefinition>,
183
184    /// GraphQL union type definitions.
185    #[serde(default)]
186    pub unions: Vec<UnionDefinition>,
187
188    /// GraphQL query definitions.
189    #[serde(default)]
190    pub queries: Vec<QueryDefinition>,
191
192    /// GraphQL mutation definitions.
193    #[serde(default)]
194    pub mutations: Vec<MutationDefinition>,
195
196    /// GraphQL subscription definitions.
197    #[serde(default)]
198    pub subscriptions: Vec<SubscriptionDefinition>,
199
200    /// Custom directive definitions.
201    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub directives: Vec<DirectiveDefinition>,
204
205    /// Fact table metadata (for analytics queries).
206    /// Key: table name (e.g., `tf_sales`)
207    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
208    pub fact_tables: HashMap<String, FactTableMetadata>,
209
210    /// Observer definitions (database change event listeners).
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub observers: Vec<ObserverDefinition>,
213
214    /// Scheduled ingress source definitions (#573) — the dual of `observers`.
215    ///
216    /// Each runs its `function` on a cron schedule, pulling from an external system
217    /// into the database via mutations with a durable cursor. Empty (and omitted
218    /// from the compiled JSON) when no source is declared, so a schema that predates
219    /// this field deserializes and re-serializes byte-for-byte unchanged.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub sources: Vec<SourceDefinition>,
222
223    /// `@subscribable` declarations (#366): GraphQL types whose underlying
224    /// table(s) get the shipped external-write capture trigger.
225    ///
226    /// Aggregated by the compiler from each type's `@subscribable(tables=[...])`
227    /// annotation; consumed by
228    /// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
229    /// to emit per-table capture triggers. Empty (and omitted from the compiled
230    /// JSON) when no type is subscribable — so a schema that predates this field
231    /// deserializes and re-serializes byte-for-byte unchanged.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub subscribable: Vec<SubscribableEntity>,
234
235    /// Per-operation `@cost(weight: N)` overrides (#379): root query/mutation name
236    /// → manual cost weight, consulted by the runtime per-tenant cost-budget check
237    /// (`estimate_query_cost`) so a top-level operation counts as exactly `N`
238    /// instead of its walked subtree complexity. Aggregated by the compiler from
239    /// each operation's `@cost` annotation. Empty (and omitted from the compiled
240    /// JSON) when no operation carries `@cost` — so a schema that predates this
241    /// field deserializes and re-serializes byte-for-byte unchanged.
242    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
243    pub operation_cost_weights: HashMap<String, usize>,
244
245    /// Federation metadata for Apollo Federation v2 support.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub federation: Option<FederationConfig>,
248
249    /// Security configuration (from fraiseql.toml).
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub security: Option<SecurityConfig>,
252
253    /// PKCE OAuth-client configuration for server-side login (`[auth]` PKCE group).
254    ///
255    /// Carries the OIDC client identity (`client_id`, `client_secret_env`,
256    /// `server_redirect_uri`) and the provider's `discovery_url`. The runtime
257    /// resolves the `authorization_endpoint` / `token_endpoint` by fetching the
258    /// discovery document **at boot** from `discovery_url` (#621) — the compiler
259    /// stays hermetic and the discovery document is always fresh. `None` when the
260    /// `[auth]` PKCE client group is absent.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub auth: Option<AuthClientConfig>,
263
264    /// Observers/event system configuration (from fraiseql.toml).
265    ///
266    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
267    /// event handler definitions compiled from the `[observers]` TOML section.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub observers_config: Option<ObserversConfig>,
270
271    /// `WebSocket` subscription configuration (hooks, limits).
272    /// Compiled from the `[subscriptions]` TOML section.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub subscriptions_config: Option<SubscriptionsConfig>,
275
276    /// Query validation config (depth/complexity limits).
277    /// Compiled from the `[validation]` TOML section.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub validation_config: Option<ValidationConfig>,
280
281    /// Debug/development configuration.
282    /// Compiled from the `[debug]` TOML section.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub debug_config: Option<DebugConfig>,
285
286    /// MCP (Model Context Protocol) server configuration.
287    /// Compiled from the `[mcp]` TOML section.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub mcp_config: Option<McpConfig>,
290
291    /// REST transport configuration.
292    /// Compiled from the `[rest]` TOML section.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub rest_config: Option<RestConfig>,
295
296    /// gRPC transport configuration.
297    /// Compiled from the `[grpc]` TOML section.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub grpc_config: Option<GrpcConfig>,
300
301    /// Changelog GraphQL-exposure configuration.
302    ///
303    /// Compiled from the `[changelog]` TOML section. When present with
304    /// `expose = true`, the compiler injects the `EntityChangeLog` /
305    /// `TransportCheckpoint` types plus their cursor query, point-lookup query, and
306    /// checkpoint upsert mutation. `None` when the block is absent (the default).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub changelog: Option<ChangelogConfig>,
309
310    /// Session variable injection configuration.
311    ///
312    /// When populated, the executor calls PostgreSQL `set_config()` before each
313    /// mutation, injecting per-request values (JWT claims, HTTP headers, literals)
314    /// as transaction-scoped settings.  SQL functions read these via
315    /// `current_setting('app.tenant_id', true)`.
316    ///
317    /// Compiled from the `[session_variables]` TOML section.
318    #[serde(default)]
319    pub session_variables: SessionVariablesConfig,
320
321    /// Hierarchy definitions for ID-based ltree operators.
322    ///
323    /// Maps hierarchy names to `table`/`path_column` pairs. Compiled from the
324    /// `[hierarchies]` TOML section. Used at runtime to resolve `HierarchyContext`
325    /// for `descendantOfId` / `ancestorOfId` WHERE clause generation.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub hierarchies_config: Option<HierarchiesConfig>,
328
329    /// Naming convention for GraphQL operation names.
330    ///
331    /// When set to `CamelCase`, operation names are converted from `snake_case`
332    /// (e.g., `create_dns_server` → `createDnsServer`) in the introspection
333    /// schema and lookup indexes. Compiled from `[fraiseql]` in `fraiseql.toml`.
334    #[serde(default)]
335    pub naming_convention: NamingConvention,
336
337    /// Acronyms whose internal digit stays attached when resolving a GraphQL field
338    /// name back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`). Added
339    /// to the built-in defaults at boot via `fraiseql_db::utils::set_runtime_acronyms`.
340    /// Skipped when empty so a schema with no project acronyms serializes byte-for-byte
341    /// as before this field existed (no schema-hash churn; back-compat on load).
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub naming_acronyms: Vec<String>,
344
345    /// The fraiseql build that produced this compiled schema (#1304).
346    ///
347    /// Checked by [`validate_producer_version`](Self::validate_producer_version)
348    /// at every seam that turns an artifact into a running executor. Anything
349    /// this build did not produce is refused, including an artifact that names
350    /// no build at all.
351    #[serde(
352        default = "ProducerVersion::unstamped",
353        skip_serializing_if = "ProducerVersion::is_unstamped"
354    )]
355    pub fraiseql_version: ProducerVersion,
356
357    /// Raw GraphQL schema as string (for SDL generation).
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub schema_sdl: Option<String>,
360
361    /// Custom scalar type registry.
362    ///
363    /// Contains definitions for custom scalar types defined in the schema.
364    /// Built during code generation from `IRScalar` definitions.
365    /// Not serialized - populated at runtime from `ir.scalars`.
366    #[serde(skip)]
367    pub custom_scalars: CustomTypeRegistry,
368
369    /// O(1) lookup index: query name → index into `self.queries`.
370    /// Built at construction time by `build_indexes()`; not serialized.
371    /// Populated automatically by `from_json()`; call `build_indexes()` after
372    /// direct mutation of `self.queries`.
373    #[serde(skip)]
374    pub query_index: HashMap<String, usize>,
375
376    /// O(1) lookup index: mutation name → index into `self.mutations`.
377    /// Built at construction time by `build_indexes()`; not serialized.
378    /// Populated automatically by `from_json()`; call `build_indexes()` after
379    /// direct mutation of `self.mutations`.
380    #[serde(skip)]
381    pub mutation_index: HashMap<String, usize>,
382
383    /// O(1) lookup index: subscription name → index into `self.subscriptions`.
384    /// Built at construction time by `build_indexes()`; not serialized.
385    /// Populated automatically by `from_json()`; call `build_indexes()` after
386    /// direct mutation of `self.subscriptions`.
387    #[serde(skip)]
388    pub subscription_index: HashMap<String, usize>,
389
390    /// The `where` keys of every type a nested predicate can descend into.
391    ///
392    /// A nested `where` level is adjudicated against the *target* type's keys —
393    /// `{machine: {bogus: {eq: …}}}` is refused because `MachineWhereInput` has
394    /// no `bogus`, rather than lowering to a JSON path that matches nothing.
395    /// Built at construction time by `build_indexes()`; not serialized.
396    #[serde(skip)]
397    pub where_relation_fields: fraiseql_db::where_clause::RelationFieldMaps,
398}
399
400impl PartialEq for CompiledSchema {
401    fn eq(&self, other: &Self) -> bool {
402        // Compare all fields except custom_scalars (runtime state)
403        self.fraiseql_version == other.fraiseql_version
404            && self.types == other.types
405            && self.enums == other.enums
406            && self.input_types == other.input_types
407            && self.interfaces == other.interfaces
408            && self.unions == other.unions
409            && self.queries == other.queries
410            && self.mutations == other.mutations
411            && self.subscriptions == other.subscriptions
412            && self.directives == other.directives
413            && self.fact_tables == other.fact_tables
414            && self.observers == other.observers
415            && self.sources == other.sources
416            && self.subscribable == other.subscribable
417            && self.federation == other.federation
418            && self.security == other.security
419            && self.auth == other.auth
420            && self.observers_config == other.observers_config
421            && self.subscriptions_config == other.subscriptions_config
422            && self.validation_config == other.validation_config
423            && self.debug_config == other.debug_config
424            && self.mcp_config == other.mcp_config
425            && self.changelog == other.changelog
426            && self.naming_convention == other.naming_convention
427            && self.naming_acronyms == other.naming_acronyms
428            && self.schema_sdl == other.schema_sdl
429    }
430}
431
432impl CompiledSchema {
433    /// Create empty schema.
434    #[must_use]
435    pub fn new() -> Self {
436        Self::default()
437    }
438}
439
440/// OAuth-client configuration compiled from the `[auth]` client groups.
441///
442/// Two independent groups, each optional (at least one is present — the CLI
443/// refuses to compile an empty group set): the PKCE server-side login client
444/// (#621, `[auth]`'s `discovery_url`/`client_id`/`client_secret_env`/
445/// `server_redirect_uri` quadruple, lowered into [`pkce`](Self::pkce)) and the
446/// social-login providers (#368, `[auth.social.*]`, lowered into
447/// [`social`](Self::social)). Client secrets are **never** carried here — each
448/// group names the environment variable the runtime reads its secret from.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct AuthClientConfig {
452    /// PKCE OAuth client for server-side login (`/auth/start`, `/auth/callback`).
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub pkce:   Option<PkceClientConfig>,
455    /// Social-login provider registry (`/auth/v1/authorize`, `/auth/v1/callback`).
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub social: Option<SocialAuthConfig>,
458    /// First-party auth methods this server operates itself (`[auth.local]`).
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub local:  Option<LocalAuthConfig>,
461}
462
463/// `[auth.local]` — the auth methods `FraiseQL` operates itself, rather than
464/// delegating to an `IdP` (#367).
465///
466/// Every enabled method mounts real `HTTP` routes and requires a database pool:
467/// credentials, `MFA` enrollments, `OTP` budgets and sessions are all durable
468/// state. A method enabled without what it needs refuses to boot rather than
469/// mounting a flow that cannot complete.
470#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct LocalAuthConfig {
473    /// Email + password sign-in: mounts `/auth/v1/password/{signup,login}` and
474    /// the reset pair. Reset-link delivery additionally requires
475    /// [`email_from`](Self::email_from).
476    #[serde(default)]
477    pub password: bool,
478
479    /// Email `OTP` / magic-link sign-in: mounts `/auth/v1/otp` and
480    /// `/auth/v1/verify`. Requires [`email_from`](Self::email_from) — a code
481    /// nobody receives is not a login method.
482    #[serde(default)]
483    pub otp: bool,
484
485    /// `TOTP` `MFA`: mounts `/auth/v1/mfa/{enroll,challenge,verify,unenroll}`,
486    /// backed by the Postgres enrollment store (enrollments must survive a
487    /// restart, or a deploy locks every user out of their own account).
488    #[serde(default)]
489    pub mfa: bool,
490
491    /// Anonymous guest sessions: mounts `POST /auth/v1/signup`, which issues a
492    /// session to any caller with no credentials at all. Off by default — this
493    /// is a deliberate "anyone may hold a session" posture, not a convenience.
494    #[serde(default)]
495    pub anonymous: bool,
496
497    /// Email verification for local-password accounts (#945): mounts
498    /// `POST /auth/v1/email/verify/{start,confirm}`, both of which require an
499    /// authenticated caller and act only on that caller's own account.
500    ///
501    /// Requires [`password`](Self::password) — verification proves the address a
502    /// *local* identity claims, and an account without one has nothing to verify
503    /// — plus [`email_from`](Self::email_from) and
504    /// [`verification_url_template`](Self::verification_url_template).
505    ///
506    /// Confirming promotes the account's own `core.tb_user.email`, which is what
507    /// lets a later trusted social sign-in for the same address link into it. It
508    /// never merges two accounts: an address already verified elsewhere is
509    /// refused.
510    #[serde(default)]
511    pub email_verification: bool,
512
513    /// Service name shown in authenticator apps for `MFA` enrollment
514    /// (`otpauth://…?issuer=`). Defaults to `"`FraiseQL`"`.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub mfa_issuer: Option<String>,
517
518    /// The `[mailbox.<name>]` account whose `SMTP` half delivers `OTP` codes and
519    /// password-reset links. Required whenever `otp` or `password` is enabled;
520    /// naming a mailbox with no `[mailbox.<name>.smtp]` section refuses to boot.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub email_from: Option<String>,
523
524    /// Template for the password-reset link, with `{token}` substituted for the
525    /// opaque reset token — e.g. `https://app.example.com/reset?token={token}`.
526    /// Required when `password` is enabled: the link points at the operator's
527    /// front end, which `FraiseQL` cannot guess.
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub reset_url_template: Option<String>,
530
531    /// Template for the `OTP` magic link, with `{code}` substituted. Optional:
532    /// when absent the email carries the bare six-digit code.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub magic_link_template: Option<String>,
535
536    /// Template for the email-verification link, with `{token}` substituted for
537    /// the opaque verification token — e.g.
538    /// `https://app.example.com/verify-email?token={token}`. Required when
539    /// [`email_verification`](Self::email_verification) is enabled: the link
540    /// points at the operator's front end, which `FraiseQL` cannot guess.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub verification_url_template: Option<String>,
543}
544
545/// PKCE OAuth-client configuration compiled from the `[auth]` PKCE group (#621).
546///
547/// The client secret is **never** carried here — `client_secret_env` names the
548/// environment variable the runtime reads it from. The `authorization_endpoint` /
549/// `token_endpoint` are deliberately absent: the runtime resolves them at boot by
550/// fetching `discovery_url`'s `.well-known/openid-configuration`, so the compiler
551/// performs no network I/O and the endpoints cannot go stale in a cached schema.
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct PkceClientConfig {
555    /// `OIDC` provider discovery base URL (e.g. `https://accounts.google.com`). The
556    /// runtime appends `/.well-known/openid-configuration` at boot.
557    pub discovery_url:       String,
558    /// `OAuth2` `client_id` registered with the provider.
559    pub client_id:           String,
560    /// Name of the environment variable holding the client secret.
561    pub client_secret_env:   String,
562    /// This server's `/auth/callback` URL, registered with the provider.
563    pub server_redirect_uri: String,
564}
565
566/// Social-login provider configuration compiled from `[auth.social]` (#368).
567///
568/// Each configured provider is auto-registered by the server and served through
569/// the account-linking trust gate at `/auth/v1/authorize` / `/auth/v1/callback`.
570/// Only implemented providers are typable: an `[auth.social.apple]` block is a
571/// compile-time unknown-field error, not a silently-ignored table.
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(deny_unknown_fields)]
574pub struct SocialAuthConfig {
575    /// Allow-listed `redirect_uri` values for the post-login token hand-off
576    /// (#427). Empty (the default) keeps the JSON-token response: the
577    /// client-supplied `redirect_uri` is never used as a redirect target, so
578    /// there is no open-redirect surface. Non-empty switches the callback to an
579    /// implicit-style fragment redirect, refusing any URI not matched here.
580    #[serde(default, skip_serializing_if = "Vec::is_empty")]
581    pub redirect_uri_allowlist: Vec<String>,
582
583    /// `Google` `OIDC` social login.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub google: Option<GoogleSocialConfig>,
586
587    /// `GitHub` `OAuth2` social login (non-`OIDC`; fixed well-known endpoints).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub github: Option<GitHubSocialConfig>,
590
591    /// Sign in with `Apple`. Configuring it also mounts the `POST` variant of
592    /// `/auth/v1/callback` that `Apple`'s `response_mode=form_post` requires.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub apple: Option<AppleSocialConfig>,
595
596    /// `Discord` `OAuth2` social login (non-`OIDC`).
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub discord: Option<DiscordSocialConfig>,
599
600    /// `Facebook` `OAuth2` social login (non-`OIDC`).
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub facebook: Option<FacebookSocialConfig>,
603}
604
605/// `[auth.social.google]` — `Google` `OIDC` social-login client (#368).
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(deny_unknown_fields)]
608pub struct GoogleSocialConfig {
609    /// `OAuth2` `client_id` from the `Google` Cloud Console.
610    pub client_id:         String,
611    /// Name of the environment variable holding the client secret.
612    pub client_secret_env: String,
613    /// This server's `/auth/v1/callback` URL, registered with `Google`.
614    pub redirect_uri:      String,
615    /// `OIDC` issuer override (default `https://accounts.google.com`). For
616    /// `Google`-compatible stand-ins; the runtime's SSRF guards still apply.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub discovery_url:     Option<String>,
619}
620
621/// `[auth.social.github]` — `GitHub` `OAuth2` social-login client (#368).
622///
623/// `GitHub` serves no `OIDC` discovery document; the endpoints are the fixed
624/// well-known paths under [`base_url`](Self::base_url) /
625/// [`api_base_url`](Self::api_base_url), overridable for `GitHub` Enterprise
626/// Server deployments.
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct GitHubSocialConfig {
630    /// `OAuth2` `client_id` of the `GitHub` OAuth app.
631    pub client_id:         String,
632    /// Name of the environment variable holding the client secret.
633    pub client_secret_env: String,
634    /// This server's `/auth/v1/callback` URL, registered with `GitHub`.
635    pub redirect_uri:      String,
636    /// Web base URL override (default `https://github.com`) — `GitHub` Enterprise
637    /// Server. The runtime's SSRF guards still apply.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub base_url:          Option<String>,
640    /// API base URL override (default `https://api.github.com`) — `GitHub`
641    /// Enterprise Server (`https://HOSTNAME/api/v3`).
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub api_base_url:      Option<String>,
644}
645
646/// `[auth.social.apple]` — Sign in with `Apple` (#943).
647///
648/// `Apple` has no `client_secret_env`: its client secret is an ES256 assertion
649/// the runtime mints from the `.p8` key over
650/// ([`team_id`](Self::team_id), [`key_id`](Self::key_id),
651/// [`client_id`](Self::client_id)) and re-mints before expiry. Supply the key
652/// through exactly one of [`private_key_env`](Self::private_key_env) or
653/// [`private_key_path`](Self::private_key_path) — naming both, or neither, is a
654/// configuration error rather than a silent preference.
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
656#[serde(deny_unknown_fields)]
657pub struct AppleSocialConfig {
658    /// The services ID registered with `Apple` (its `OAuth2` `client_id`, and
659    /// the `aud` of every `id_token`).
660    pub client_id:        String,
661    /// `Apple` developer team ID — the client-secret assertion's `iss`.
662    pub team_id:          String,
663    /// Key ID of the `.p8` signing key — the assertion header's `kid`.
664    pub key_id:           String,
665    /// Name of the environment variable holding the `.p8` private key, PEM text
666    /// and all. Mutually exclusive with
667    /// [`private_key_path`](Self::private_key_path).
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub private_key_env:  Option<String>,
670    /// Filesystem path to the `.p8` private key. Mutually exclusive with
671    /// [`private_key_env`](Self::private_key_env).
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub private_key_path: Option<String>,
674    /// This server's `/auth/v1/callback` URL, registered with `Apple`.
675    pub redirect_uri:     String,
676    /// Base URL override (default `https://appleid.apple.com`). This is also
677    /// the issuer every `id_token` must name, so the two move together. The
678    /// runtime's SSRF guards still apply.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub base_url:         Option<String>,
681}
682
683/// `[auth.social.discord]` — `Discord` `OAuth2` social login (#944).
684///
685/// `Discord` reports `verified` on the user object, and the runtime honours it:
686/// an unverified address keys on `(discord, id)` rather than on the email. That
687/// check is why `discord` is in the default trusted-email set.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(deny_unknown_fields)]
690pub struct DiscordSocialConfig {
691    /// `OAuth2` `client_id` of the `Discord` application.
692    pub client_id:         String,
693    /// Name of the environment variable holding the client secret.
694    pub client_secret_env: String,
695    /// This server's `/auth/v1/callback` URL, registered with `Discord`.
696    pub redirect_uri:      String,
697    /// Base URL override (default `https://discord.com`). The runtime's SSRF
698    /// guards still apply.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub base_url:          Option<String>,
701}
702
703/// `[auth.social.facebook]` — `Facebook` `OAuth2` social login (#944).
704///
705/// `Facebook` publishes **no** email-verification signal, so every `Facebook`
706/// identity keys on `(facebook, id)` and `facebook` is deliberately absent from
707/// the default trusted-email set. The Graph API version is part of the request
708/// path and `Meta` deprecates versions on a schedule, so it is configuration
709/// rather than a constant.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711#[serde(deny_unknown_fields)]
712pub struct FacebookSocialConfig {
713    /// `OAuth2` `client_id` (the `Meta` app ID).
714    pub client_id:         String,
715    /// Name of the environment variable holding the client secret.
716    pub client_secret_env: String,
717    /// This server's `/auth/v1/callback` URL, registered with `Meta`.
718    pub redirect_uri:      String,
719    /// Graph API version segment (default `v21.0`), e.g. `v22.0`.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub api_version:       Option<String>,
722    /// Web base URL override (default `https://www.facebook.com`) — the
723    /// authorization dialog. The runtime's SSRF guards still apply.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub base_url:          Option<String>,
726    /// Graph API base URL override (default `https://graph.facebook.com`) — the
727    /// token and profile endpoints. The runtime's SSRF guards still apply.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub graph_base_url:    Option<String>,
730}