Skip to main content

solid_pod_rs/config/
schema.rs

1//! `ServerConfig` root + value objects.
2//!
3//! See the bounded-context doc
4//! `docs/design/jss-parity/05-config-platform-context.md` for the
5//! aggregate model. In short: `ServerConfig` is the root, loaded by
6//! [`crate::config::loader::ConfigLoader`] from a precedence-ordered
7//! list of sources, and validated once at the end of the load.
8//!
9//! The struct shapes below are designed so **the same JSS
10//! `config.json` file boots both JSS and solid-pod-rs** — field names
11//! and JSON structure mirror JSS's `config.json` where semantics align.
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Root aggregate
17// ---------------------------------------------------------------------------
18
19/// Fully resolved server configuration snapshot.
20///
21/// Construct via [`crate::config::loader::ConfigLoader`]; never mutate
22/// after construction. Reload swaps in a new snapshot atomically.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct ServerConfig {
25    /// HTTP listener settings (host, port, base URL).
26    #[serde(default)]
27    pub server: ServerSection,
28
29    /// Storage backend selection (filesystem or memory).
30    #[serde(default)]
31    pub storage: StorageBackendConfig,
32
33    /// Authentication toggles (NIP-98, Solid-OIDC, DPoP).
34    #[serde(default)]
35    pub auth: AuthConfig,
36
37    /// Solid Notifications channel toggles (WebSocket, Webhook, legacy).
38    #[serde(default)]
39    pub notifications: NotificationsConfig,
40
41    /// Security primitives (SSRF guard, dotfile allowlist, ACL origin).
42    #[serde(default)]
43    pub security: SecurityConfig,
44
45    /// Sprint 11 (row 120-124): operator-facing extras that do not yet
46    /// have first-class sections on `ServerConfig`. The env-var overlay
47    /// writes here (e.g. `JSS_CORS_ALLOWED_ORIGINS`, `JSS_SUBDOMAINS`,
48    /// `JSS_BASE_DOMAIN`, `JSS_IDP_ENABLED`). Binaries consult this map
49    /// until a richer typed section supersedes it.
50    #[serde(default)]
51    pub extras: ExtrasConfig,
52}
53
54/// Flat bag for operator-facing knobs not yet promoted to a typed
55/// section. Each field is `#[serde(default)]` + `skip_serializing_if` so
56/// a pristine `ExtrasConfig` serialises to an empty object.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ExtrasConfig {
60    /// `JSS_CONNEG` — content-negotiation toggle. Default off until
61    /// promoted to its own typed section.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub conneg_enabled: Option<bool>,
64
65    /// `JSS_CORS_ALLOWED_ORIGINS` — CSV list. Empty vec means unset.
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    pub cors_allowed_origins: Vec<String>,
68
69    /// `JSS_MAX_BODY_SIZE` / `JSS_MAX_REQUEST_BODY` — bytes.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub max_body_size_bytes: Option<u64>,
72
73    /// `JSS_MAX_ACL_BYTES` — bytes.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub max_acl_bytes: Option<u64>,
76
77    /// `JSS_RATE_LIMIT_WRITES_PER_MIN`.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub rate_limit_writes_per_min: Option<u64>,
80
81    /// `JSS_SUBDOMAINS` — enable subdomain multi-tenancy.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub subdomains_enabled: Option<bool>,
84
85    /// `JSS_BASE_DOMAIN` — authoritative base domain when subdomains
86    /// are on.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub base_domain: Option<String>,
89
90    /// `JSS_IDP_ENABLED` — local IdP service toggle.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub idp_enabled: Option<bool>,
93
94    /// `JSS_INVITE_ONLY` — restrict new pod registration.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub invite_only: Option<bool>,
97
98    /// `JSS_ADMIN_KEY` — operator override token. Never serialise to
99    /// telemetry; this serde pass is solely for config reload symmetry.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub admin_key: Option<String>,
102
103    /// `JSS_ACTIVITYPUB` — enable ActivityPub federation (JSS
104    /// `config.js` 79, 185). Needs the server's `activitypub` feature.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub activitypub_enabled: Option<bool>,
107
108    /// `JSS_AP_USERNAME` — ActivityPub preferredUsername (default `me`).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub ap_username: Option<String>,
111
112    /// `JSS_AP_DISPLAY_NAME` — ActivityPub display name.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub ap_display_name: Option<String>,
115
116    /// `JSS_AP_SUMMARY` — ActivityPub bio.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub ap_summary: Option<String>,
119
120    /// `JSS_AP_NOSTR_PUBKEY` — hex pubkey linked as `alsoKnownAs`
121    /// `did:nostr:<pk>` on the actor.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub ap_nostr_pubkey: Option<String>,
124
125    /// `JSS_TRUST_PROXY` — honour `X-Forwarded-Proto` / `cf-visitor` for
126    /// the request scheme. Default off.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub trust_proxy: Option<bool>,
129
130    /// `JSS_DID_NOSTR_ORIGINS` — CSV of origins whose
131    /// `/.well-known/did/nostr/<pk>.json` documents may link a Nostr key to
132    /// a WebID (cross-identity). Empty means local profile only.
133    #[serde(skip_serializing_if = "Vec::is_empty")]
134    pub did_nostr_origins: Vec<String>,
135}
136
137// ---------------------------------------------------------------------------
138// HTTP binding
139// ---------------------------------------------------------------------------
140
141/// HTTP listener settings — matches JSS `host`/`port`/`baseUrl`.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ServerSection {
144    /// `JSS_HOST`, default `0.0.0.0` (matches JSS default).
145    #[serde(default = "default_host")]
146    pub host: String,
147
148    /// `JSS_PORT`, default `3000` (matches JSS default).
149    #[serde(default = "default_port")]
150    pub port: u16,
151
152    /// `JSS_BASE_URL` — optional; used for pod-URL construction.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub base_url: Option<String>,
155}
156
157impl Default for ServerSection {
158    fn default() -> Self {
159        Self {
160            host: default_host(),
161            port: default_port(),
162            base_url: None,
163        }
164    }
165}
166
167fn default_host() -> String {
168    "0.0.0.0".to_string()
169}
170
171fn default_port() -> u16 {
172    3000
173}
174
175// ---------------------------------------------------------------------------
176// Storage backend selection
177// ---------------------------------------------------------------------------
178
179/// Tagged storage backend selector — matches JSS's
180/// `{ "type": "fs"|"memory", … }` JSON shape.
181///
182/// `JSS_STORAGE_TYPE` drives the variant; `JSS_STORAGE_ROOT` /
183/// `JSS_ROOT` feeds the `fs` root.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(tag = "type", rename_all = "lowercase")]
186pub enum StorageBackendConfig {
187    /// Filesystem backend (JSS default).
188    Fs {
189        #[serde(default = "default_fs_root")]
190        root: String,
191    },
192
193    /// In-memory (ephemeral) backend.
194    Memory,
195}
196
197impl Default for StorageBackendConfig {
198    fn default() -> Self {
199        Self::Fs {
200            root: default_fs_root(),
201        }
202    }
203}
204
205fn default_fs_root() -> String {
206    "./data".to_string()
207}
208
209// ---------------------------------------------------------------------------
210// Auth
211// ---------------------------------------------------------------------------
212
213/// Auth toggles.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct AuthConfig {
216    /// NIP-98 (Nostr HTTP Auth) — default on; matches `nip98_enabled`
217    /// semantics on the JSS side.
218    #[serde(default = "default_true")]
219    pub nip98_enabled: bool,
220
221    /// Solid-OIDC — `JSS_OIDC_ENABLED` / JSS `idp`.
222    #[serde(default)]
223    pub oidc_enabled: bool,
224
225    /// Issuer URL — `JSS_OIDC_ISSUER` / JSS `idpIssuer`.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub oidc_issuer: Option<String>,
228
229    /// DPoP replay-cache TTL (seconds).
230    ///
231    /// `JSS_DPOP_REPLAY_TTL_SECONDS`; default 300s.
232    /// [TODO verify JSS]: JSS does not currently expose this knob;
233    /// we add it to parity the Rust side's DPoP replay cache.
234    #[serde(default = "default_dpop_ttl")]
235    pub dpop_replay_ttl_seconds: u64,
236}
237
238impl Default for AuthConfig {
239    fn default() -> Self {
240        Self {
241            nip98_enabled: true,
242            oidc_enabled: false,
243            oidc_issuer: None,
244            dpop_replay_ttl_seconds: default_dpop_ttl(),
245        }
246    }
247}
248
249fn default_true() -> bool {
250    true
251}
252
253fn default_dpop_ttl() -> u64 {
254    300
255}
256
257// ---------------------------------------------------------------------------
258// Notifications
259// ---------------------------------------------------------------------------
260
261/// Solid Notifications channel toggles.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct NotificationsConfig {
264    /// WebSocketChannel2023 — `JSS_NOTIFICATIONS_WS2023`.
265    #[serde(default = "default_true")]
266    pub ws2023_enabled: bool,
267
268    /// WebhookChannel2023 — `JSS_NOTIFICATIONS_WEBHOOK`.
269    #[serde(default)]
270    pub webhook2023_enabled: bool,
271
272    /// Legacy `solid-0.1` PATCH-based channel — `JSS_NOTIFICATIONS_LEGACY`.
273    ///
274    /// JSS sets this on by default for backwards compatibility; we mirror
275    /// that for drop-in replacement.
276    #[serde(default = "default_true")]
277    pub legacy_solid_01_enabled: bool,
278}
279
280impl Default for NotificationsConfig {
281    fn default() -> Self {
282        Self {
283            ws2023_enabled: true,
284            webhook2023_enabled: false,
285            legacy_solid_01_enabled: true,
286        }
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Security
292// ---------------------------------------------------------------------------
293
294/// Security primitives — SSRF, dotfiles, ACL origin.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct SecurityConfig {
297    /// Allow outbound requests to RFC 1918 / loopback / link-local —
298    /// `JSS_SSRF_ALLOW_PRIVATE`. Defaults off (production-safe).
299    #[serde(default)]
300    pub ssrf_allow_private: bool,
301
302    /// Explicit allowlist of hosts/CIDRs — `JSS_SSRF_ALLOWLIST`
303    /// (comma-separated in env; JSON array in file).
304    #[serde(default)]
305    pub ssrf_allowlist: Vec<String>,
306
307    /// Explicit denylist — `JSS_SSRF_DENYLIST`.
308    #[serde(default)]
309    pub ssrf_denylist: Vec<String>,
310
311    /// Dotfile allowlist (e.g. `.acl`, `.meta`) —
312    /// `JSS_DOTFILE_ALLOWLIST`.
313    #[serde(default = "default_dotfile_allowlist")]
314    pub dotfile_allowlist: Vec<String>,
315
316    /// ACL-origin lockdown toggle — `JSS_ACL_ORIGIN_ENABLED`.
317    #[serde(default = "default_true")]
318    pub acl_origin_enabled: bool,
319
320    /// Default per-pod byte quota. Zero disables quota enforcement.
321    #[serde(default)]
322    pub default_quota_bytes: u64,
323}
324
325impl Default for SecurityConfig {
326    fn default() -> Self {
327        Self {
328            ssrf_allow_private: false,
329            ssrf_allowlist: Vec::new(),
330            ssrf_denylist: Vec::new(),
331            dotfile_allowlist: default_dotfile_allowlist(),
332            acl_origin_enabled: true,
333            default_quota_bytes: 0,
334        }
335    }
336}
337
338fn default_dotfile_allowlist() -> Vec<String> {
339    vec![
340        ".acl".to_string(),
341        ".meta".to_string(),
342        // JSS commit 32c0db2: allow `.account` for IdP login.
343        ".account".to_string(),
344    ]
345}
346
347// ---------------------------------------------------------------------------
348// Basic validation helpers
349// ---------------------------------------------------------------------------
350
351impl ServerConfig {
352    /// Sanity-check the resolved snapshot. Called once at the end of
353    /// [`crate::config::loader::ConfigLoader::load`].
354    ///
355    /// Returns a human-readable error; `Ok(())` means valid.
356    pub fn validate(&self) -> Result<(), String> {
357        // Port 0 is allowed (means "pick any free port") — don't reject.
358        // But port > u16::MAX isn't representable anyway.
359
360        if self.auth.oidc_enabled && self.auth.oidc_issuer.is_none() {
361            return Err(
362                "auth.oidc_enabled=true but auth.oidc_issuer is not set (set JSS_OIDC_ISSUER)"
363                    .to_string(),
364            );
365        }
366
367        Ok(())
368    }
369}