Skip to content

Commit dee6d8d

Browse files
committed
Propagate required environment prompts through V10 ABI
2 parents 8a1b09e + 6972d73 commit dee6d8d

2 files changed

Lines changed: 97 additions & 11 deletions

File tree

crates/client-api/src/routes/database.rs

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -580,21 +580,54 @@ impl From<Database> for DatabaseResponse {
580580
}
581581
}
582582

583-
fn publish_error(error: anyhow::Error) -> axum::response::ErrorResponse {
584-
if let Some(error) = error.downcast_ref::<spacetimedb_lib::environment::EnvironmentSchemaError>() {
585-
return (StatusCode::BAD_REQUEST, error.to_string()).into();
586-
}
587-
if let Some(spacetimedb::db::environment::EnvironmentError::Schema(error)) =
588-
error.downcast_ref::<spacetimedb::db::environment::EnvironmentError>()
583+
fn environment_validation_error(error: &anyhow::Error) -> Option<axum::response::ErrorResponse> {
584+
use spacetimedb::db::environment::EnvironmentError;
585+
use spacetimedb::host::module_host::InitDatabaseError;
586+
use spacetimedb_lib::environment::{validate_key, EnvironmentSchemaError, EnvironmentSchemaErrorKind};
587+
if let Some(InitDatabaseError::Other(error)) = error.downcast_ref::<InitDatabaseError>() {
588+
return environment_validation_error(error);
589+
}
590+
let error =
591+
error
592+
.downcast_ref::<EnvironmentSchemaError>()
593+
.or_else(|| match error.downcast_ref::<EnvironmentError>() {
594+
Some(EnvironmentError::Schema(error)) => Some(error),
595+
_ => None,
596+
})?;
597+
// Only typed host validation can ask the caller for a secret. Never infer
598+
// missing keys from module failures or arbitrary diagnostic text.
599+
if error.kind == EnvironmentSchemaErrorKind::MissingRequired
600+
&& let Some(key) = error.key.as_deref().filter(|key| validate_key(key).is_ok())
589601
{
590-
return (StatusCode::BAD_REQUEST, error.to_string()).into();
602+
return Some(
603+
(
604+
StatusCode::BAD_REQUEST,
605+
axum::Json(serde_json::json!({
606+
"error": "missing_required_environment",
607+
"key": key,
608+
})),
609+
)
610+
.into(),
611+
);
612+
}
613+
Some((StatusCode::BAD_REQUEST, error.to_string()).into())
614+
}
615+
616+
fn publish_error(error: anyhow::Error) -> axum::response::ErrorResponse {
617+
if let Some(response) = environment_validation_error(&error) {
618+
return response;
591619
}
592620
if let Some(error) = error.downcast_ref::<spacetimedb::host::EnvironmentVersionConflict>() {
593621
return (StatusCode::CONFLICT, error.to_string()).into();
594622
}
595623
log_and_500(error)
596624
}
597625

626+
fn publish_migration_error(error: anyhow::Error) -> axum::response::ErrorResponse {
627+
environment_validation_error(&error)
628+
.unwrap_or_else(|| bad_request(format!("Failed to create or update the database: {error}").into()))
629+
}
630+
598631
pub async fn environment_metadata<S>(
599632
State(ctx): State<S>,
600633
Extension(ResolvedDatabase(database)): Extension<ResolvedDatabase>,
@@ -925,7 +958,7 @@ pub async fn reset<S: NodeDelegate + ControlStateDelegate + Authorization>(
925958
},
926959
)
927960
.await
928-
.map_err(log_and_500)?;
961+
.map_err(publish_error)?;
929962

930963
Ok(axum::Json(PublishResult::Success {
931964
domain: name_or_identity.name().cloned(),
@@ -1164,9 +1197,7 @@ pub async fn publish<S: NodeDelegate + ControlStateDelegate + Authorization>(
11641197
Some(UpdateDatabaseResult::AutoMigrateError(errs)) => {
11651198
Err(bad_request(format!("Database update rejected: {errs}").into()))
11661199
}
1167-
Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request(
1168-
format!("Failed to create or update the database: {err}").into(),
1169-
)),
1200+
Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(publish_migration_error(err)),
11701201
None | Some(UpdateDatabaseResult::NoUpdateNeeded) => Ok(success()),
11711202
Some(
11721203
UpdateDatabaseResult::UpdatePerformed {
@@ -1821,6 +1852,59 @@ mod tests {
18211852
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
18221853
use tower::util::ServiceExt;
18231854

1855+
#[tokio::test]
1856+
async fn publish_environment_error_identifies_only_typed_missing_required_keys() {
1857+
use spacetimedb_lib::environment::{EnvironmentSchemaError, EnvironmentSchemaErrorKind};
1858+
let missing = || EnvironmentSchemaError {
1859+
key: Some("API_KEY".into()),
1860+
kind: EnvironmentSchemaErrorKind::MissingRequired,
1861+
};
1862+
for response in [
1863+
publish_error(anyhow::Error::new(missing()).context("publication failed")),
1864+
publish_migration_error(spacetimedb::db::environment::EnvironmentError::Schema(missing()).into()),
1865+
publish_error(
1866+
spacetimedb::host::module_host::InitDatabaseError::Other(
1867+
spacetimedb::db::environment::EnvironmentError::Schema(missing()).into(),
1868+
)
1869+
.into(),
1870+
),
1871+
publish_migration_error(
1872+
spacetimedb::host::module_host::InitDatabaseError::Other(
1873+
spacetimedb::db::environment::EnvironmentError::Schema(missing()).into(),
1874+
)
1875+
.into(),
1876+
),
1877+
] {
1878+
let response = Err::<(), _>(response).into_response();
1879+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1880+
assert_eq!(response.headers()[http::header::CONTENT_TYPE], "application/json");
1881+
let body = axum::body::to_bytes(response.into_body(), 1024).await.unwrap();
1882+
assert_eq!(
1883+
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
1884+
serde_json::json!({
1885+
"error": "missing_required_environment", "key": "API_KEY",
1886+
})
1887+
);
1888+
}
1889+
for error in [
1890+
anyhow::anyhow!("environment key API_KEY: required value is missing"),
1891+
EnvironmentSchemaError {
1892+
key: Some("API_KEY".into()),
1893+
kind: EnvironmentSchemaErrorKind::ConstraintMismatch,
1894+
}
1895+
.into(),
1896+
EnvironmentSchemaError {
1897+
key: Some("INVALID-KEY".into()),
1898+
kind: EnvironmentSchemaErrorKind::MissingRequired,
1899+
}
1900+
.into(),
1901+
] {
1902+
let response = Err::<(), _>(publish_migration_error(error)).into_response();
1903+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1904+
assert_ne!(response.headers()[http::header::CONTENT_TYPE], "application/json");
1905+
}
1906+
}
1907+
18241908
#[derive(Clone, Default)]
18251909
struct DummyValidator;
18261910

docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ Both publish endpoints accept `Content-Type: application/vnd.spacetimedb.publish
114114

115115
`module` uses standard padded Base64. `environment` supplies string overrides for declared or undeclared names. Unspecified stored values survive by default. The server validates the resulting environment against the module's declarations and installs both in one transaction: all required values must exist and all present declared values must satisfy their constraints. An empty or omitted map preserves stored values, including when publishing unchanged module bytes.
116116

117+
A missing required value returns HTTP 400 with `Content-Type: application/json` and `{"error":"missing_required_environment","key":"API_KEY"}` identifying the missing key. Clients can prompt for that value and retry the publish. Other validation and module failures do not use this error code.
118+
117119
Optional fields `environment_remove` (an array of keys) and `environment_replace` (a boolean, default `false`) request explicit deletion or complete replacement. A key cannot be both supplied and removed. Replacement uses only the supplied map, deleting every unspecified declared and undeclared key, and rejects any nonempty removal list. Invalid updates leave the database unchanged.
118120

119121
To update an existing database without a module, omit `module` and provide `expected_module_version` from `GET /v1/database/{name_or_identity}/environment`. That authorized endpoint returns `module_version`, `declarations`, and `stored_keys`, without secret values. Each declaration contains `name`, `optional`, and `constraint`: `"AnyString"`, `{"Literal":"value"}`, or `{"OneOf":["a","b"]}`. Metadata comes from one database version. For example:

0 commit comments

Comments
 (0)