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}