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, memory, or S3).
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
104// ---------------------------------------------------------------------------
105// HTTP binding
106// ---------------------------------------------------------------------------
107
108/// HTTP listener settings — matches JSS `host`/`port`/`baseUrl`.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ServerSection {
111 /// `JSS_HOST`, default `0.0.0.0` (matches JSS default).
112 #[serde(default = "default_host")]
113 pub host: String,
114
115 /// `JSS_PORT`, default `3000` (matches JSS default).
116 #[serde(default = "default_port")]
117 pub port: u16,
118
119 /// `JSS_BASE_URL` — optional; used for pod-URL construction.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub base_url: Option<String>,
122}
123
124impl Default for ServerSection {
125 fn default() -> Self {
126 Self {
127 host: default_host(),
128 port: default_port(),
129 base_url: None,
130 }
131 }
132}
133
134fn default_host() -> String {
135 "0.0.0.0".to_string()
136}
137
138fn default_port() -> u16 {
139 3000
140}
141
142// ---------------------------------------------------------------------------
143// Storage backend selection
144// ---------------------------------------------------------------------------
145
146/// Tagged storage backend selector — matches JSS's
147/// `{ "type": "fs"|"memory"|"s3", … }` JSON shape.
148///
149/// `JSS_STORAGE_TYPE` drives the variant; `JSS_STORAGE_ROOT` /
150/// `JSS_ROOT` feeds the `fs` root.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(tag = "type", rename_all = "lowercase")]
153pub enum StorageBackendConfig {
154 /// Filesystem backend (JSS default).
155 Fs {
156 #[serde(default = "default_fs_root")]
157 root: String,
158 },
159
160 /// In-memory (ephemeral) backend.
161 Memory,
162
163 /// S3-compatible object store backend.
164 S3 {
165 bucket: String,
166
167 region: String,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 prefix: Option<String>,
171 },
172}
173
174impl Default for StorageBackendConfig {
175 fn default() -> Self {
176 Self::Fs {
177 root: default_fs_root(),
178 }
179 }
180}
181
182fn default_fs_root() -> String {
183 "./data".to_string()
184}
185
186// ---------------------------------------------------------------------------
187// Auth
188// ---------------------------------------------------------------------------
189
190/// Auth toggles.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AuthConfig {
193 /// NIP-98 (Nostr HTTP Auth) — default on; matches `nip98_enabled`
194 /// semantics on the JSS side.
195 #[serde(default = "default_true")]
196 pub nip98_enabled: bool,
197
198 /// Solid-OIDC — `JSS_OIDC_ENABLED` / JSS `idp`.
199 #[serde(default)]
200 pub oidc_enabled: bool,
201
202 /// Issuer URL — `JSS_OIDC_ISSUER` / JSS `idpIssuer`.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub oidc_issuer: Option<String>,
205
206 /// DPoP replay-cache TTL (seconds).
207 ///
208 /// `JSS_DPOP_REPLAY_TTL_SECONDS`; default 300s.
209 /// [TODO verify JSS]: JSS does not currently expose this knob;
210 /// we add it to parity the Rust side's DPoP replay cache.
211 #[serde(default = "default_dpop_ttl")]
212 pub dpop_replay_ttl_seconds: u64,
213}
214
215impl Default for AuthConfig {
216 fn default() -> Self {
217 Self {
218 nip98_enabled: true,
219 oidc_enabled: false,
220 oidc_issuer: None,
221 dpop_replay_ttl_seconds: default_dpop_ttl(),
222 }
223 }
224}
225
226fn default_true() -> bool {
227 true
228}
229
230fn default_dpop_ttl() -> u64 {
231 300
232}
233
234// ---------------------------------------------------------------------------
235// Notifications
236// ---------------------------------------------------------------------------
237
238/// Solid Notifications channel toggles.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct NotificationsConfig {
241 /// WebSocketChannel2023 — `JSS_NOTIFICATIONS_WS2023`.
242 #[serde(default = "default_true")]
243 pub ws2023_enabled: bool,
244
245 /// WebhookChannel2023 — `JSS_NOTIFICATIONS_WEBHOOK`.
246 #[serde(default)]
247 pub webhook2023_enabled: bool,
248
249 /// Legacy `solid-0.1` PATCH-based channel — `JSS_NOTIFICATIONS_LEGACY`.
250 ///
251 /// JSS sets this on by default for backwards compatibility; we mirror
252 /// that for drop-in replacement.
253 #[serde(default = "default_true")]
254 pub legacy_solid_01_enabled: bool,
255}
256
257impl Default for NotificationsConfig {
258 fn default() -> Self {
259 Self {
260 ws2023_enabled: true,
261 webhook2023_enabled: false,
262 legacy_solid_01_enabled: true,
263 }
264 }
265}
266
267// ---------------------------------------------------------------------------
268// Security
269// ---------------------------------------------------------------------------
270
271/// Security primitives — SSRF, dotfiles, ACL origin.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SecurityConfig {
274 /// Allow outbound requests to RFC 1918 / loopback / link-local —
275 /// `JSS_SSRF_ALLOW_PRIVATE`. Defaults off (production-safe).
276 #[serde(default)]
277 pub ssrf_allow_private: bool,
278
279 /// Explicit allowlist of hosts/CIDRs — `JSS_SSRF_ALLOWLIST`
280 /// (comma-separated in env; JSON array in file).
281 #[serde(default)]
282 pub ssrf_allowlist: Vec<String>,
283
284 /// Explicit denylist — `JSS_SSRF_DENYLIST`.
285 #[serde(default)]
286 pub ssrf_denylist: Vec<String>,
287
288 /// Dotfile allowlist (e.g. `.acl`, `.meta`) —
289 /// `JSS_DOTFILE_ALLOWLIST`.
290 #[serde(default = "default_dotfile_allowlist")]
291 pub dotfile_allowlist: Vec<String>,
292
293 /// ACL-origin lockdown toggle — `JSS_ACL_ORIGIN_ENABLED`.
294 #[serde(default = "default_true")]
295 pub acl_origin_enabled: bool,
296}
297
298impl Default for SecurityConfig {
299 fn default() -> Self {
300 Self {
301 ssrf_allow_private: false,
302 ssrf_allowlist: Vec::new(),
303 ssrf_denylist: Vec::new(),
304 dotfile_allowlist: default_dotfile_allowlist(),
305 acl_origin_enabled: true,
306 }
307 }
308}
309
310fn default_dotfile_allowlist() -> Vec<String> {
311 vec![
312 ".acl".to_string(),
313 ".meta".to_string(),
314 // JSS commit 32c0db2: allow `.account` for IdP login.
315 ".account".to_string(),
316 ]
317}
318
319// ---------------------------------------------------------------------------
320// Basic validation helpers
321// ---------------------------------------------------------------------------
322
323impl ServerConfig {
324 /// Sanity-check the resolved snapshot. Called once at the end of
325 /// [`crate::config::loader::ConfigLoader::load`].
326 ///
327 /// Returns a human-readable error; `Ok(())` means valid.
328 pub fn validate(&self) -> Result<(), String> {
329 // Port 0 is allowed (means "pick any free port") — don't reject.
330 // But port > u16::MAX isn't representable anyway.
331
332 if self.auth.oidc_enabled && self.auth.oidc_issuer.is_none() {
333 return Err(
334 "auth.oidc_enabled=true but auth.oidc_issuer is not set (set JSS_OIDC_ISSUER)"
335 .to_string(),
336 );
337 }
338
339 if let StorageBackendConfig::S3 { bucket, region, .. } = &self.storage {
340 if bucket.is_empty() {
341 return Err("storage.type=s3 but storage.bucket is empty".to_string());
342 }
343 if region.is_empty() {
344 return Err("storage.type=s3 but storage.region is empty".to_string());
345 }
346 }
347
348 Ok(())
349 }
350}