1#![forbid(unsafe_code)]
2
3pub mod control;
6mod control_resources;
7pub mod export;
8pub mod health;
9pub mod runtime;
10pub mod server;
11
12pub use health::HealthResponse;
13
14use utoipa::openapi::{Content, OpenApi, Ref, RefOr, Response};
15
16pub const FEDERATED_PROJECT_AUTH_AVAILABLE: bool = true;
18
19pub(crate) fn json_error_response(
20 description: &str,
21 schema_name: &str,
22 content_type: &str,
23) -> RefOr<Response> {
24 Response::builder()
25 .description(description)
26 .content(
27 content_type,
28 Content::new(Some(Ref::from_schema_name(schema_name))),
29 )
30 .build()
31 .into()
32}
33
34pub(crate) fn add_response_to_operations(
35 openapi: &mut OpenApi,
36 status: &str,
37 mut response_for_path: impl FnMut(&str) -> RefOr<Response>,
38) {
39 for (path, item) in &mut openapi.paths.paths {
40 let response = response_for_path(path);
41 for operation in [
42 item.get.as_mut(),
43 item.put.as_mut(),
44 item.post.as_mut(),
45 item.delete.as_mut(),
46 item.options.as_mut(),
47 item.head.as_mut(),
48 item.patch.as_mut(),
49 item.trace.as_mut(),
50 ]
51 .into_iter()
52 .flatten()
53 {
54 operation
55 .responses
56 .responses
57 .insert(status.to_owned(), response.clone());
58 }
59 }
60}
61
62#[cfg(test)]
63mod tests {
64 use serde_json::Value;
65
66 use crate::{control, export, runtime, server};
67
68 #[test]
69 fn every_listener_operation_declares_its_request_timeout_envelope() {
70 let documents = [
71 (
72 serde_json::to_value(runtime::openapi()).unwrap(),
73 "RuntimeError",
74 "application/json",
75 ),
76 (
77 serde_json::to_value(server::openapi()).unwrap(),
78 "ServerError",
79 "application/json",
80 ),
81 (
82 serde_json::to_value(control::openapi()).unwrap(),
83 "ProblemDetails",
84 "application/problem+json",
85 ),
86 ];
87 for (document, schema, content_type) in documents {
88 for (path, item) in document["paths"].as_object().unwrap() {
89 for method in [
90 "get", "put", "post", "delete", "options", "head", "patch", "trace",
91 ] {
92 let Some(operation) = item.get(method) else {
93 continue;
94 };
95 let timeout = &operation["responses"]["408"];
96 assert!(
97 timeout.is_object(),
98 "missing 408 response for {method} {path}"
99 );
100 if schema == "RuntimeError" && path.starts_with("/auth/") {
101 assert!(timeout["content"]["text/html"].is_object());
102 } else {
103 assert_eq!(
104 timeout["content"][content_type]["schema"]["$ref"],
105 format!("#/components/schemas/{schema}")
106 );
107 }
108 }
109 }
110 }
111 }
112
113 #[test]
114 #[allow(
115 clippy::too_many_lines,
116 reason = "one contract test keeps complete cross-plane path and component isolation visible"
117 )]
118 fn generated_documents_are_plane_pure_and_versioned() {
119 let runtime =
120 serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI should serialize");
121 let server =
122 serde_json::to_value(server::openapi()).expect("Server OpenAPI should serialize");
123 let control =
124 serde_json::to_value(control::openapi()).expect("Control OpenAPI should serialize");
125
126 assert_eq!(runtime["info"]["version"], env!("CARGO_PKG_VERSION"));
127 assert_eq!(server["info"]["version"], env!("CARGO_PKG_VERSION"));
128 assert_eq!(control["info"]["version"], env!("CARGO_PKG_VERSION"));
129 assert!(runtime["paths"]["/health"].is_object());
130 assert!(runtime["paths"]["/ready"].is_object());
131 assert!(runtime["paths"].get("/v1/system").is_none());
132 assert!(runtime["paths"]["/v1/projects/{project_public_id}/auth/config"].is_object());
133 assert!(
134 runtime["paths"]["/projects/{project_public_id}/.well-known/jwks.json"].is_object()
135 );
136 for required in [
137 "/v1/projects/{project_public_id}/auth/login/start",
138 "/auth/interactions/{interaction}",
139 "/v1/projects/{project_public_id}/auth/interactions/{interaction}/method",
140 "/v1/projects/{project_public_id}/auth/interactions/{interaction}/session/reuse",
141 "/projects/{project_public_id}/auth/callback/{provider_key}",
142 "/v1/projects/{project_public_id}/auth/handoff/exchange",
143 "/v1/projects/{project_public_id}/auth/sessions/refresh",
144 "/v1/projects/{project_public_id}/auth/users/me",
145 "/v1/projects/{project_public_id}/auth/sessions/logout",
146 "/v1/projects/{project_public_id}/auth/browser-logout/prepare",
147 "/auth/browser-logout/{preparation}",
148 "/v1/projects/{project_public_id}/auth/browser-logout/{preparation}/confirm",
149 ] {
150 assert!(
151 runtime["paths"][required].is_object(),
152 "federated authentication path is missing from Runtime OpenAPI: {required}"
153 );
154 }
155 assert!(runtime["paths"].get("/v1/projects").is_none());
156 for forbidden_schema in [
157 "CreateProviderRequest",
158 "NamedProviderPreflightRequest",
159 "OidcPreflightRequest",
160 ] {
161 assert!(
162 runtime["components"]["schemas"]
163 .get(forbidden_schema)
164 .is_none()
165 );
166 }
167 assert!(
168 runtime["components"]["schemas"]
169 .get("IntrospectProjectTokenRequest")
170 .is_none()
171 );
172
173 for path in [
174 "/health",
175 "/ready",
176 "/v1/projects/{project_id}/users",
177 "/v1/projects/{project_id}/users/lookup",
178 "/v1/projects/{project_id}/users/{user_id}",
179 "/v1/projects/{project_id}/applications/{application_id}/users/{user_id}",
180 "/v1/projects/{project_id}/tokens/introspect",
181 ] {
182 assert!(
183 server["paths"][path].is_object(),
184 "missing Server path: {path}"
185 );
186 }
187 assert!(server["components"]["securitySchemes"]["project_server_key"].is_object());
188 assert!(server["paths"].get("/v1/system").is_none());
189 assert!(
190 server["paths"]
191 .get("/v1/projects/{project_public_id}/auth/config")
192 .is_none()
193 );
194 for forbidden_schema in [
195 "CreateProviderRequest",
196 "NamedProviderPreflightRequest",
197 "OidcPreflightRequest",
198 "PublicApplicationConfig",
199 "RefreshSessionRequest",
200 "ProjectServerKey",
201 ] {
202 assert!(
203 server["components"]["schemas"]
204 .get(forbidden_schema)
205 .is_none(),
206 "Server OpenAPI leaked {forbidden_schema}"
207 );
208 }
209
210 assert!(control["paths"]["/v1/system"].is_object());
211 let capabilities = &control["components"]["schemas"]["SystemCapabilities"];
212 assert!(capabilities["properties"].get("project_auth").is_none());
213 assert!(capabilities["properties"]["provisioning"].is_object());
214 assert!(capabilities["properties"]["login_readiness"].is_object());
215 assert!(capabilities["properties"]["federated_project_auth"].is_object());
216 let advertised = control::get_system();
217 assert!(advertised.provisioning);
218 assert!(advertised.login_readiness);
219 assert!(advertised.federated_project_auth);
220 assert!(control["paths"]["/v1/projects"].is_object());
221 let signing_key_collection = &control["paths"]["/v1/projects/{project_id}/signing-keys"];
222 assert!(signing_key_collection["get"].is_object());
223 assert!(signing_key_collection.get("post").is_none());
224 assert!(
225 control["paths"]["/v1/projects/{project_id}/signing-keys/rotate"]["post"].is_object()
226 );
227 for removed_path in [
228 "/v1/projects/{project_id}/signing-keys/{key_id}/reconcile",
229 "/v1/projects/{project_id}/signing-keys/{key_id}/activate",
230 "/v1/projects/{project_id}/signing-keys/{key_id}/retire",
231 ] {
232 assert!(
233 control["paths"].get(removed_path).is_none(),
234 "removed signing-key path leaked into Control OpenAPI: {removed_path}"
235 );
236 }
237 assert!(
238 control["paths"]["/v1/projects/{project_id}/providers/{provider_id}/reconcile"]
239 .is_object()
240 );
241 assert!(
242 control["paths"]
243 .get("/v1/projects/{project_public_id}/auth/config")
244 .is_none()
245 );
246 assert!(
247 control["paths"]
248 .get("/projects/{project_public_id}/.well-known/jwks.json")
249 .is_none()
250 );
251 assert!(control["components"]["securitySchemes"]["operator_api_key"].is_object());
252 for (path, request_schema) in [
253 (
254 "/v1/projects/{project_id}/providers/oidc/preflight",
255 "OidcPreflightRequest",
256 ),
257 (
258 "/v1/projects/{project_id}/providers/named/preflight",
259 "NamedProviderPreflightRequest",
260 ),
261 ] {
262 assert!(control["paths"][path]["post"].is_object());
263 let properties = &control["components"]["schemas"][request_schema]["properties"];
264 assert!(properties["provider_key"].is_object());
265 assert!(properties.get("client_secret").is_none());
266 assert!(properties.get("callback_url").is_none());
267 }
268 for result_schema in ["OidcPreflightResult", "NamedProviderPreflightResult"] {
269 let properties = &control["components"]["schemas"][result_schema]["properties"];
270 assert!(properties["callback_url"].is_object());
271 assert!(properties["callback_guidance"].is_object());
272 }
273 assert_eq!(
274 control["components"]["schemas"]["CreateProviderRequest"]["properties"]["client_secret"]
275 ["writeOnly"],
276 true
277 );
278 assert!(
279 control["components"]["schemas"]["CreateProviderRequest"]["required"]
280 .as_array()
281 .is_some_and(|required| required.iter().any(|field| field == "kind"))
282 );
283 for schema in [
284 "ReconcileProviderRequest",
285 "ReplaceProviderSecretRequest",
286 "ReconcileProviderSecretReplacementRequest",
287 ] {
288 assert_eq!(
289 control["components"]["schemas"][schema]["properties"]["client_secret"]["writeOnly"],
290 true
291 );
292 }
293 assert!(
294 control["components"]["schemas"]["UpdateProviderRequest"]["properties"]
295 .get("client_secret")
296 .is_none()
297 );
298 }
299
300 #[test]
301 #[allow(
302 clippy::too_many_lines,
303 reason = "the exact Control operation and exceptional-response inventory is reviewed as one protocol matrix"
304 )]
305 fn control_operation_inventory_and_exceptional_responses_are_exact() {
306 let control = serde_json::to_value(control::openapi()).expect("Control OpenAPI serializes");
307 for (path, methods) in [
308 ("/v1/projects", &["get", "post"][..]),
309 ("/v1/projects/{project_id}", &["get", "patch"]),
310 ("/v1/projects/{project_id}/overview", &["get"]),
311 ("/v1/projects/{project_id}/applications", &["get", "post"]),
312 (
313 "/v1/projects/{project_id}/applications/{application_id}",
314 &["get", "patch"],
315 ),
316 (
317 "/v1/projects/{project_id}/applications/{application_id}/webhook-endpoints",
318 &["get", "post"],
319 ),
320 (
321 "/v1/projects/{project_id}/applications/{application_id}/webhook-endpoints/{endpoint_id}",
322 &["get", "put"],
323 ),
324 ("/v1/projects/{project_id}/server-keys", &["get", "post"]),
325 ("/v1/projects/{project_id}/email-method", &["get", "put"]),
326 ("/v1/projects/{project_id}/policy", &["get", "put"]),
327 (
328 "/v1/projects/{project_id}/provider-egress-policy",
329 &["get", "put"],
330 ),
331 ("/v1/projects/{project_id}/providers", &["get", "post"]),
332 (
333 "/v1/projects/{project_id}/providers/{provider_id}",
334 &["patch"],
335 ),
336 (
337 "/v1/projects/{project_id}/providers/{provider_id}/replace-secret",
338 &["post"],
339 ),
340 (
341 "/v1/projects/{project_id}/providers/{provider_id}/replace-secret/reconcile",
342 &["post"],
343 ),
344 (
345 "/v1/projects/{project_id}/providers/{provider_id}/replace-secret/abandon",
346 &["post"],
347 ),
348 (
349 "/v1/projects/{project_id}/providers/{provider_id}/assignments/{application_id}",
350 &["put"],
351 ),
352 (
353 "/v1/projects/{project_id}/smtp-configurations",
354 &["get", "post"],
355 ),
356 ("/v1/system/smtp-default-generations", &["get", "post"]),
357 ] {
358 let path_item = &control["paths"][path];
359 for method in methods {
360 assert!(
361 path_item[method].is_object(),
362 "missing Control operation {method} {path}"
363 );
364 }
365 }
366
367 for path in [
368 "/v1/projects/{project_id}/providers/oidc/preflight",
369 "/v1/projects/{project_id}/providers/named/preflight",
370 ] {
371 assert!(control["paths"][path]["post"]["responses"]["422"].is_object());
372 }
373 let smtp_test = &control["paths"]["/v1/projects/{project_id}/smtp-configurations/{smtp_id}/test"]
374 ["post"];
375 assert!(smtp_test["responses"].get("200").is_none());
376 let accepted = &smtp_test["responses"]["202"];
377 assert_eq!(accepted["headers"]["Location"]["schema"]["type"], "string");
378 assert!(
379 accepted["headers"]["Location"]["description"]
380 .as_str()
381 .is_some_and(|description| description.contains("Exact Control path"))
382 );
383
384 for path in [
385 "/v1/projects/{project_id}/users/{user_id}/managed-provider-connections/{connection_id}/reauthorizations",
386 "/v1/projects/{project_id}/identity-mutation-intents",
387 ] {
388 let responses = &control["paths"][path]["post"]["responses"];
389 assert!(responses["200"].is_object(), "{path} must describe replay");
390 assert_eq!(
391 responses["201"]["headers"]["Location"]["schema"]["type"],
392 "string"
393 );
394 }
395 assert_eq!(
396 control["paths"]["/v1/projects/{project_id}/server-keys"]["post"]["responses"]["201"]["headers"]
397 ["Location"]["schema"]["type"],
398 "string"
399 );
400 assert!(
401 control["paths"]["/v1/projects/{project_id}/applications/{application_id}/webhook-deliveries/{delivery_id}"]["get"].is_object()
402 );
403 let replay = &control["paths"]["/v1/projects/{project_id}/applications/{application_id}/webhook-deliveries/{delivery_id}/replay"]
404 ["post"]["responses"];
405 assert!(replay.get("200").is_none());
406 assert_eq!(
407 replay["201"]["headers"]["Location"]["schema"]["type"],
408 "string"
409 );
410 }
411
412 #[test]
413 fn project_overview_contract_is_grouped_required_and_control_only() {
414 let control = serde_json::to_value(control::openapi()).expect("Control OpenAPI serializes");
415 let runtime = serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI serializes");
416 let server = serde_json::to_value(server::openapi()).expect("Server OpenAPI serializes");
417 let path = "/v1/projects/{project_id}/overview";
418 assert_eq!(
419 control["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
420 ["$ref"],
421 "#/components/schemas/ProjectOverviewSummary"
422 );
423 assert!(runtime["paths"].get(path).is_none());
424 assert!(server["paths"].get(path).is_none());
425 let summary = &control["components"]["schemas"]["ProjectOverviewSummary"];
426 let summary_required = summary["required"]
427 .as_array()
428 .expect("overview groups are required")
429 .iter()
430 .filter_map(serde_json::Value::as_str)
431 .collect::<std::collections::BTreeSet<_>>();
432 assert_eq!(
433 summary_required,
434 [
435 "applications",
436 "project_id",
437 "project_server_keys",
438 "providers",
439 "users",
440 ]
441 .into_iter()
442 .collect()
443 );
444
445 for (schema, fields) in [
446 (
447 "ProjectOverviewApplicationCounts",
448 &["active", "configured", "total"][..],
449 ),
450 (
451 "ProjectOverviewProviderCounts",
452 &["active", "active_assignments", "total"],
453 ),
454 (
455 "ProjectOverviewUserCounts",
456 &["active", "disabled", "merged", "total"],
457 ),
458 (
459 "ProjectOverviewServerKeyCounts",
460 &["active", "revoked", "total"],
461 ),
462 ] {
463 let definition = &control["components"]["schemas"][schema];
464 let required = definition["required"]
465 .as_array()
466 .expect("overview count fields are required")
467 .iter()
468 .filter_map(serde_json::Value::as_str)
469 .collect::<std::collections::BTreeSet<_>>();
470 assert_eq!(required, fields.iter().copied().collect());
471 for field in fields {
472 assert_eq!(definition["properties"][field]["minimum"], 0);
473 }
474 }
475 }
476
477 #[test]
478 fn runtime_pending_email_and_hosted_document_contracts_are_minimal() {
479 let runtime = serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI serializes");
480 let pending = &runtime["components"]["schemas"]["HostedPendingEmailChallenge"];
481 let properties = pending["properties"]
482 .as_object()
483 .expect("pending properties");
484 assert_eq!(
485 properties
486 .keys()
487 .cloned()
488 .collect::<std::collections::BTreeSet<_>>(),
489 ["challenge_id", "expires_at", "generation", "proof_modes"]
490 .into_iter()
491 .map(str::to_owned)
492 .collect()
493 );
494 for forbidden in [
495 "email",
496 "address",
497 "account",
498 "delivery",
499 "otp",
500 "magic_proof",
501 "smtp",
502 ] {
503 assert!(properties.get(forbidden).is_none());
504 }
505 assert!(
506 runtime["components"]["schemas"]["HostedInteractionResponse"]["properties"]
507 ["pending_email_challenge"]
508 .is_object()
509 );
510 for path in [
511 "/auth/interactions/{interaction}",
512 "/auth/email/confirm/{challenge_id}",
513 "/auth/browser-logout/{preparation}",
514 "/auth/managed-reauthorizations/{interaction}",
515 "/auth/identity-mutations/{intent}",
516 "/auth/identity-mutations/email/confirm/{challenge_id}",
517 ] {
518 assert_eq!(
519 runtime["paths"][path]["get"]["responses"]["200"]["content"]["text/html"]["schema"]
520 ["type"],
521 "string",
522 "Hosted document must declare text/html for {path}"
523 );
524 }
525 for excluded in ["/auth/", "/auth/assets/{asset}"] {
526 assert!(runtime["paths"].get(excluded).is_none());
527 }
528 }
529
530 #[test]
531 fn server_auth_projection_and_introspection_contract_is_exact() {
532 let server: Value = serde_json::from_str(
533 &export::to_pretty_json(export::OpenApiPlane::Server)
534 .expect("Server OpenAPI should serialize"),
535 )
536 .expect("exported Server OpenAPI should be JSON");
537 let operations = [
538 ("/v1/projects/{project_id}/users", "get"),
539 ("/v1/projects/{project_id}/users/lookup", "post"),
540 ("/v1/projects/{project_id}/users/{user_id}", "get"),
541 (
542 "/v1/projects/{project_id}/applications/{application_id}/users/{user_id}",
543 "get",
544 ),
545 ("/v1/projects/{project_id}/tokens/introspect", "post"),
546 ];
547 for (path, method) in operations {
548 let challenge =
549 &server["paths"][path][method]["responses"]["401"]["headers"]["WWW-Authenticate"];
550 assert_eq!(challenge["required"], true, "{method} {path}");
551 assert_eq!(challenge["schema"]["type"], "string", "{method} {path}");
552 }
553
554 let schemas = &server["components"]["schemas"];
555 assert_eq!(
556 schemas["InactiveProjectToken"]["properties"]["active"]["const"],
557 false
558 );
559 assert_eq!(
560 schemas["ActiveProjectToken"]["properties"]["active"]["const"],
561 true
562 );
563 assert!(
564 schemas["ServerApplicationUserProjection"]["required"]
565 .as_array()
566 .is_some_and(|required| required.iter().any(|field| field == "user_revision"))
567 );
568 assert_eq!(
569 schemas["ServerApplicationUserProjection"]["properties"]["user_revision"]["minimum"],
570 1
571 );
572 assert!(
573 schemas["ServerUserList"]["required"]
574 .as_array()
575 .is_some_and(|required| required.iter().any(|field| field == "next_cursor"))
576 );
577 assert_eq!(
578 schemas["ServerUserList"]["properties"]["next_cursor"]["maxLength"],
579 64
580 );
581
582 let parameters = server["paths"]["/v1/projects/{project_id}/users"]["get"]["parameters"]
583 .as_array()
584 .expect("Server list parameters");
585 let parameter = |name: &str| {
586 parameters
587 .iter()
588 .find(|parameter| parameter["name"] == name)
589 .unwrap_or_else(|| panic!("missing Server parameter {name}"))
590 };
591 assert_eq!(parameter("project_id")["schema"]["maxLength"], 96);
592 assert_eq!(parameter("cursor")["schema"]["maxLength"], 64);
593 }
594
595 #[test]
596 fn public_contracts_are_bounded_and_reject_private_jwk_members() {
597 let runtime =
598 serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI should serialize");
599 let control =
600 serde_json::to_value(control::openapi()).expect("Control OpenAPI should serialize");
601
602 assert_eq!(
603 control["components"]["schemas"]["ApplicationType"]["enum"],
604 serde_json::json!(["web", "native"])
605 );
606 assert_eq!(
607 control["components"]["schemas"]["UpdateProjectPolicyRequest"]["properties"]["access_token_lifetime_seconds"]
608 ["minimum"],
609 60
610 );
611 assert_eq!(
612 runtime["components"]["schemas"]["PublicApplicationConfig"]["properties"]["providers"]
613 ["maxItems"],
614 50
615 );
616 assert!(
617 serde_json::from_value::<runtime::PublicJwk>(serde_json::json!({
618 "kty": "OKP",
619 "crv": "Ed25519",
620 "alg": "EdDSA",
621 "use": "sig",
622 "kid": "key-1",
623 "x": "public-value",
624 "d": "private-value"
625 }))
626 .is_err()
627 );
628 }
629
630 #[test]
631 fn separate_exports_are_deterministic() {
632 for plane in [
633 export::OpenApiPlane::Runtime,
634 export::OpenApiPlane::Server,
635 export::OpenApiPlane::Control,
636 ] {
637 let first = export::to_pretty_json(plane).expect("OpenAPI should serialize");
638 let second = export::to_pretty_json(plane).expect("OpenAPI should serialize");
639 assert_eq!(first, second);
640
641 let parsed: Value = serde_json::from_str(&first).expect("OpenAPI should be JSON");
642 assert_eq!(parsed["openapi"], "3.1.0");
643 }
644 }
645}