diff --git a/.Codex/plans/cli-migration-behavior.md b/.Codex/plans/cli-migration-behavior.md new file mode 100644 index 00000000..279d4742 --- /dev/null +++ b/.Codex/plans/cli-migration-behavior.md @@ -0,0 +1,12 @@ +# CLI migration and operator behavior + +Apply Rust planner/author standards using existing domain and error types. + +1. Plan every desired schema before apply/migrate execution and reject any destructive noninteractive batch before migration, metadata, or revision preparation writes. Preserve interactive per-schema consent and dry-run plans. Test mixed safe/destructive batches with zero writes. +2. Preserve configured listener host/port with optional flags. Keep SchemaForge loopback as its unconfigured host, respecting the framework config search and ACTON environment layers. Test omitted flags, explicit default overrides, and config-file bind/port. +3. Keep the existing public RequiresConfirmation enum variant for source compatibility, but label it review in Display/serialized output, accept legacy serialized spelling, and document it as informational consistently. Introduce plan-aware step classification for fresh unique constraints and test both existing/new schema uniqueness. +4. Add bounded 429 retries to the entity HTTP client, with --max-retries and Retry-After seconds/date support. Rebuild identical requests only for explicit 429 responses, no transport or 5xx retries. Test exhaustion, eventual success, non-429 refusal, and delay parsing. +5. Correct the rule-ordering reference and document governor defaults, reverse-proxy trust and probe configuration. +6. Coordinate read-only CLI connections and webhook validation with owning agents. + +Validation: cargo nextest run for core and CLI with postgres feature, cargo clippy warnings denied, formatting. Root performs workspace integration and release. Semver recommendation: minor because migration machine-readable review labels change and CLI functionality is added; retain deserialization compatibility. diff --git a/.Codex/plans/site-field-authority.md b/.Codex/plans/site-field-authority.md new file mode 100644 index 00000000..8209474c --- /dev/null +++ b/.Codex/plans/site-field-authority.md @@ -0,0 +1,15 @@ +# Generated field types and authority, issues 191 and 192 + +Use the existing view-model and template architecture. Keep display types complete while filtering form authority separately. + +- Add duration (Go-style duration string validation), bytes (base64 text with decoded size constraint), and map (typed Record JSON textarea) mapping. Preserve recursive composite/array type projection. Format duration wire strings into readable units without altering submitted values. +- Project computed/read-role/write-role metadata on FieldView. Omit computed and derived fields from form controls and form validators. Use current auth roles at render/parse/submit time, not module initialization. Hide read-denied controls, render write-denied values inert, and ensure validation does not require denied fields. +- Use recursive metadata to filter initial and submitted state, stripping read-denied, computed/derived and unwritable payload fields including nested composites. Preserve readable, write-denied initial values for read-only display. Normalize JSON and composite values recursively so nested supported fields remain round-trippable. +- Update field-type and permissions documentation. Add generator regression checks plus Playwright behavior tests for serialization, validation and role changes. Fail generation for any remaining unsupported required field. +- Preserve existing generated styling and accessibility labels, avoiding controls which imply unavailable actions (UI design expert, interaction patterns). + +No new dependencies or error types required. Semver: fixes in release already planned by root. Run only targeted cargo check locally; root runs generation, TypeScript build/lint, and browser checks in CI. Sign conventional commits; no push. + +## CI follow-up: exact browser metadata + +Site CI reported TS2352 because full FieldView JSON contains display-only properties. Introduce a dedicated recursive FormFieldSpec serialization type; use it for all template metadata arguments and entity normalizer tables. Remove casts, preserve hidden-descendant/role flags, and test the exact serialized keys at every depth. diff --git a/.Codex/ui-design-review-site-field-authority.md b/.Codex/ui-design-review-site-field-authority.md new file mode 100644 index 00000000..2ada17c4 --- /dev/null +++ b/.Codex/ui-design-review-site-field-authority.md @@ -0,0 +1,3 @@ +# UI Design Review + +The generated forms imply users can edit values the server will discard. Apply Norman's affordance principle: omit computed inputs, hide unreadable fields, and render readable but unwritable values as inert text. Share gating across validation and payload construction so hidden required controls cannot block saving. Keep existing form styling and labels; provide duration and base64 format hints. Browser regression coverage should assert visible controls and actual submitted payloads for both permitted and denied roles. diff --git a/.github/workflows/site-e2e.yml b/.github/workflows/site-e2e.yml index 8020de9e..61024afc 100644 --- a/.github/workflows/site-e2e.yml +++ b/.github/workflows/site-e2e.yml @@ -56,3 +56,14 @@ jobs: crates/schema-forge-cli/tests/site_e2e/playwright/test-results if-no-files-found: ignore retention-days: 7 + + - name: Upload complete server logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: site-server-logs + path: | + target/site-e2e-*/backend.log + target/site-e2e-*/vite.log + if-no-files-found: ignore + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index a18fc5b7..277163dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,83 @@ is pre-1.0; breaking changes bump the **minor** version per ## [Unreleased] +## [0.46.0] - 2026-09-25 + +### Runtime and API behavior + +- Validate tenant declarations consistently across startup, CLI schema application, + and runtime schema changes. Applications with a tenant root must annotate every + application schema; built-in system schemas remain shared. +- Validate relation targets against tenant scope and read authorization before + create, PUT, or PATCH persists. Platform administrators retain their documented + cross-tenant capabilities. +- Apply hidden-field projection to relation display labels and webhook payloads. + Webhooks use a fixed conservative field policy and plain JSON payload version 2. +- Enforce configured webhook URL schemes and public destinations during + configuration and delivery. Delivery checks and pins DNS results, with redirects + and environment proxies disabled. +- Reject undeclared entity fields before persistence. Foreign-key errors include + machine-readable `error` and `message` fields; entity and schema JSON rejections + use the API error envelope. Internal storage diagnostics stay out of REST and + GraphQL error messages, and database connection errors omit credentials. +- Keep authorization resource attributes aligned with the generated Cedar schema, + so arrays of unsupported policy types do not incorrectly deny valid writes. +- Convert nested composite values using their declared field types before storage. + +### Database and operator fixes + +- PostgreSQL planning and inspection connections perform no bookkeeping DDL. + Fresh databases plan as empty registries, and read-only roles can inspect existing + metadata without schema creation privileges. +- PostgreSQL `contains` and `startswith` now match literal, case-sensitive text. + Unsupported array filter comparisons return validation errors before execution. +- `apply` and `migrate --execute` preflight all selected migration plans before + applying a noninteractive batch. Refusals identify every destructive schema and + step requiring `--force`. +- Migration warnings display `review` when they are informational. New-table unique + constraints are safe, and new schemas retain their `CREATE` label. +- Explicit listener flags override environment and file settings; omitted flags + preserve configuration. An unconfigured server defaults to `127.0.0.1:3000`. +- Entity CLI requests retry HTTP 429 with `Retry-After` support and bounded fallback + backoff, controlled by `--max-retries`. Transport failures are not retried. +- Document governor quotas, proxy configuration, probe routes, the full write-rule + order, and webhook delivery guarantees. + +### Generated sites + +- Carry the active tenant on entity, invitation, and file requests, including + requests retried after a token refresh. +- Show readable API errors and avoid duplicate global notifications when pages + handle errors locally. Projects can customize the preserved error-toast helper. +- Generate typed duration, map, and base64 bytes fields in forms, lists, and + details. Form validation and payload normalization respect computed fields and + role-based field access. Composites with protected children remain read-only. +- Configure the product name, title suffix, and SVG logos and favicon through + `[schema_forge.site]` or generation flags. Default marks are neutral, and CSS, + title helpers, and SVG assets support template overrides and drift checking. +- CI now builds and lints generated TypeScript before running browser tests. + +### Upgrade notes + +Webhook consumers must support `payload_version: 2` and plain JSON field values. +Hidden fields and fields with field-access annotations are excluded. Webhooks +remain best effort with no durable history or replay; applications must reconcile +current state separately when delivery gaps matter. See [webhooks](docs/webhooks.md). + +Before upgrading a tenanted deployment, annotate every application schema with its +intended tenant relationship and migrate existing ownership explicitly. Unannotated +application schemas are no longer implicitly shared when a tenant root exists. +See [tenant isolation](docs/tenant-isolation.md). + +Migration safety serialization emits `Review`; legacy `RequiresConfirmation` input +is still accepted. Rust embedders must update webhook event constructor calls to +pass schema definitions, and handler callers must use the new JSON extractor. +Workspace crate versions are coordinated for the updated public core/backend types. + +Regenerate sites to update owned API and branding helpers. Existing customized +page shells remain preserved; see the migration instructions for +[error feedback](docs/generated-site-errors.md) and [branding](docs/site-branding.md). + ## [0.45.0] - 2026-09-24 ### Security and correctness diff --git a/Cargo.lock b/Cargo.lock index 02767051..e0642afe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5163,6 +5163,7 @@ checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d" dependencies = [ "memo-map", "serde", + "serde_json", ] [[package]] @@ -7598,7 +7599,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.44.0" +version = "0.45.0" dependencies = [ "acton-service", "arc-swap", @@ -7655,7 +7656,7 @@ dependencies = [ [[package]] name = "schema-forge-backend" -version = "0.18.0" +version = "0.19.0" dependencies = [ "acton-service", "argon2", @@ -7670,7 +7671,7 @@ dependencies = [ [[package]] name = "schema-forge-cel" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "chrono", @@ -7686,7 +7687,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.45.0" +version = "0.46.0" dependencies = [ "acton-service", "assert_cmd", @@ -7695,8 +7696,10 @@ dependencies = [ "clap_complete", "console", "dialoguer", + "figment", "glob", "heck 0.5.0", + "httpdate", "indicatif", "miette", "mime_guess", @@ -7728,7 +7731,7 @@ dependencies = [ [[package]] name = "schema-forge-core" -version = "0.18.0" +version = "0.19.0" dependencies = [ "base64", "chrono", @@ -7742,7 +7745,7 @@ dependencies = [ [[package]] name = "schema-forge-dsl" -version = "0.14.0" +version = "0.15.0" dependencies = [ "logos 0.15.1", "proptest", @@ -7753,7 +7756,7 @@ dependencies = [ [[package]] name = "schema-forge-mssql" -version = "0.5.0" +version = "0.6.0" dependencies = [ "acton-service", "bb8", @@ -7769,7 +7772,7 @@ dependencies = [ [[package]] name = "schema-forge-postgres" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arc-swap", "argon2", @@ -7807,7 +7810,7 @@ dependencies = [ [[package]] name = "schema-forge-surrealdb" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "schema-forge-backend", diff --git a/README.md b/README.md index 2c77e341..e282a3d4 100644 --- a/README.md +++ b/README.md @@ -1056,3 +1056,7 @@ See the project repository for license information. Platform administrators can browse recorded audit events and verify bounded chain ranges through the [audit API](docs/audit-api-reference.md). Access is deployment-wide, uses the active framework audit store, and reports collection limits separately from local chain consistency. Available in v0.42.0. See [safe schema changes](docs/migrations/safe-schema-changes.md) for declared field renames, destructive migration opt-ins, PostgreSQL relation integrity, and explicit tenancy migrations. + +See the [webhook delivery contract](docs/webhooks.md) for delivery guarantees, payload format, and destination policy. + +Configure product names, title suffixes, and SVG marks with [generated-site branding](docs/site-branding.md). diff --git a/SECURITY.md b/SECURITY.md index f2f6cd09..0daab575 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,8 +31,8 @@ Security fixes go into the latest release. Older versions do not receive backpor | Version | Supported | |---|---| -| 0.45.x (latest release) | Yes | -| earlier than 0.45 | No, please upgrade | +| 0.46.x (latest release) | Yes | +| earlier than 0.46 | No, please upgrade | ## Scope diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 0cfdd659..e3f790f0 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.44.0" +version = "0.45.0" edition = "2021" [dependencies] @@ -44,7 +44,7 @@ aws-lc-rs = { version = "1", features = ["fips"], optional = true } rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "logging"] } schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" } lettre = { version = "0.11.22", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "builder", "pool", "hostname"] } -schema-forge-cel = { version = "0.11.0", path = "../schema-forge-cel" } +schema-forge-cel = { version = "0.12.0", path = "../schema-forge-cel" } rust_xlsxwriter = { version = "0.95.0", features = ["chrono"] } zip = "8.6.0" diff --git a/crates/schema-forge-acton/src/authz/adapters.rs b/crates/schema-forge-acton/src/authz/adapters.rs index ffb949ce..7149e437 100644 --- a/crates/schema-forge-acton/src/authz/adapters.rs +++ b/crates/schema-forge-acton/src/authz/adapters.rs @@ -220,13 +220,13 @@ pub fn build_resource_entity( let mut attrs: HashMap = HashMap::new(); for (field_name, value) in &entity.fields { - // `@hidden` fields are never declared as Cedar attributes, so - // including them here would fail strict-mode entity validation. - // The schema's field definition is the canonical source of the - // hidden flag — entities loaded from storage may still carry the - // value, but it must not leak into authorization context. + // Only types declared by the schema generator may enter Cedar. + // Unsupported arrays otherwise become undeclared set attributes, + // causing strict validation to reject even permitted operations. if let Some(field_def) = schema.field(field_name) { - if field_def.is_hidden() { + if field_def.is_hidden() + || crate::cedar::schema_gen::cedar_type_for(&field_def.field_type).is_none() + { continue; } // The Cedar schema declares file attributes as strings. Supply @@ -304,10 +304,11 @@ pub fn build_resource_placeholder(schema: &SchemaDefinition) -> Result = HashMap::new(); for field in &schema.fields { - if !field.is_required() || field.is_hidden() { - // Hidden fields are not declared in the Cedar schema, so the - // strict-mode entity validator would reject a placeholder that - // includes them. + if !field.is_required() + || field.is_hidden() + || crate::cedar::schema_gen::cedar_type_for(&field.field_type).is_none() + { + // Hidden and unsupported field types have no Cedar attribute. continue; } if let Some(expr) = default_cedar_expr(&field.field_type) { @@ -378,7 +379,7 @@ pub fn dynamic_to_cedar(value: &DynamicValue) -> Option { } DynamicValue::Array(items) => { let mapped: Vec = - items.iter().filter_map(dynamic_to_cedar).collect(); + items.iter().map(dynamic_to_cedar).collect::>()?; Some(RestrictedExpression::new_set(mapped)) } DynamicValue::Null | DynamicValue::Json(_) | DynamicValue::Composite(_) => None, @@ -424,6 +425,15 @@ mod principal_claim_tests { PrincipalClaimsConfig, }; + #[test] + fn array_projection_does_not_silently_drop_unrepresentable_members() { + let value = DynamicValue::Array(vec![ + DynamicValue::Integer(1), + DynamicValue::Json(serde_json::json!({"value": 2})), + ]); + assert!(dynamic_to_cedar(&value).is_none()); + } + fn claims_with(custom: HashMap) -> Claims { Claims { sub: "user:alice".into(), diff --git a/crates/schema-forge-acton/src/cedar/schema_gen.rs b/crates/schema-forge-acton/src/cedar/schema_gen.rs index c87d3f0e..3ba83426 100644 --- a/crates/schema-forge-acton/src/cedar/schema_gen.rs +++ b/crates/schema-forge-acton/src/cedar/schema_gen.rs @@ -231,7 +231,7 @@ fn write_per_field_actions( /// Returns `None` for types that have no clean Cedar representation /// (composites, arbitrary JSON). Such fields will not appear as resource /// attributes; policies cannot test them. -fn cedar_type_for(ft: &FieldType) -> Option { +pub(crate) fn cedar_type_for(ft: &FieldType) -> Option { match ft { FieldType::Text(_) | FieldType::RichText => Some("String".into()), FieldType::Integer(_) => Some("Long".into()), diff --git a/crates/schema-forge-acton/src/config.rs b/crates/schema-forge-acton/src/config.rs index 48accb80..da1d3776 100644 --- a/crates/schema-forge-acton/src/config.rs +++ b/crates/schema-forge-acton/src/config.rs @@ -88,6 +88,21 @@ pub struct SchemaForgeSettings { /// environment variables still override these values. #[serde(default)] pub client: ClientConfig, + + /// Branding for generated sites. + #[serde(default)] + pub site: SiteBrandingConfig, +} + +/// Optional generated-site identity and SVG assets. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SiteBrandingConfig { + pub name: Option, + /// Defaults to name; an empty string disables the suffix. + pub title_suffix: Option, + pub logo: Option, + pub logo_on_dark: Option, + pub favicon: Option, } /// `[schema_forge.client]` section of config.toml. @@ -165,6 +180,7 @@ impl Default for SchemaForgeSettings { authz: AuthzConfig::default(), signing: SigningConfig::default(), client: ClientConfig::default(), + site: SiteBrandingConfig::default(), } } } @@ -195,6 +211,7 @@ mod tests { authz: AuthzConfig::default(), signing: SigningConfig::default(), client: ClientConfig::default(), + site: SiteBrandingConfig::default(), }, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/crates/schema-forge-acton/src/error.rs b/crates/schema-forge-acton/src/error.rs index e7bcf871..89e0b1c1 100644 --- a/crates/schema-forge-acton/src/error.rs +++ b/crates/schema-forge-acton/src/error.rs @@ -193,6 +193,19 @@ impl ForgeError { } } +impl ForgeError { + /// Return a client-safe message, retaining backend diagnostics only in server logs. + pub(crate) fn client_message(&self) -> String { + match self { + Self::BackendUnavailable { message } | Self::Internal { message } => { + tracing::error!(error = %message, "API backend operation failed"); + "The server could not complete the operation".into() + } + _ => self.to_string(), + } + } +} + impl IntoResponse for ForgeError { fn into_response(self) -> Response { let status = self.status_code(); @@ -203,22 +216,22 @@ impl IntoResponse for ForgeError { "message": message, }), Self::ForeignKeyViolation { schema, constraint } => { - serde_json::json!({ "schema": schema, "constraint": constraint }) + serde_json::json!({ "error": self.error_kind(), "message": self.client_message(), "schema": schema, "constraint": constraint }) } Self::UniqueViolation { schema, field } => serde_json::json!({ "error": "unique_violation", "schema": schema, "field": field, - "message": self.to_string(), + "message": self.client_message(), }), Self::ExportTooLarge { max_rows, .. } => serde_json::json!({ "error": "export_too_large", "max_rows": max_rows, - "message": self.to_string(), + "message": self.client_message(), }), _ => serde_json::json!({ "error": self.error_kind(), - "message": self.to_string(), + "message": self.client_message(), }), }; (status, axum::Json(body)).into_response() @@ -298,6 +311,29 @@ impl From for ForgeError { } } +/// JSON request extractor using the SchemaForge validation error envelope. +pub struct JsonBody(pub T); + +impl axum::extract::FromRequest for JsonBody +where + S: Send + Sync, + T: serde::de::DeserializeOwned, +{ + type Rejection = ForgeError; + + async fn from_request( + request: axum::extract::Request, + state: &S, + ) -> Result { + as axum::extract::FromRequest>::from_request(request, state) + .await + .map(|axum::Json(value)| Self(value)) + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.body_text()], + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -624,6 +660,42 @@ mod tests { assert_eq!(json["message"], "msg"); } + #[tokio::test] + async fn foreign_key_response_has_standard_envelope() { + let response = ForgeError::ForeignKeyViolation { + schema: "Line".into(), + constraint: "Line_order_fkey".into(), + } + .into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["error"], "foreign_key_violation"); + assert_eq!(json["schema"], "Line"); + assert_eq!(json["constraint"], "Line_order_fkey"); + assert!(json["message"].as_str().unwrap().contains("Line")); + } + + #[tokio::test] + async fn backend_responses_do_not_expose_diagnostics() { + for error in [ + ForgeError::from(BackendError::QueryError { + message: "private SQL table secret".into(), + }), + ForgeError::Internal { + message: "private configuration".into(), + }, + ] { + let response = error.into_response(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + json["message"], + "The server could not complete the operation" + ); + } + } + #[tokio::test] async fn into_response_unique_violation_has_field_in_body() { let err = ForgeError::UniqueViolation { diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index db8f4e31..712841ee 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -38,7 +38,7 @@ pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { | ForgeError::InvalidEntityId { .. } => "BAD_REQUEST", _ => "INTERNAL_ERROR", }; - async_graphql::Error::new(err.to_string()).extend_with(|_, e| e.set("code", code)) + async_graphql::Error::new(err.client_message()).extend_with(|_, e| e.set("code", code)) } /// Resolve a single entity by ID. @@ -260,7 +260,7 @@ pub async fn resolve_create_entity<'a>( axum::extract::Path(schema_name.to_owned()), crate::access::OptionalClaims(gql_ctx.claims.clone()), axum::http::HeaderMap::new(), - axum::Json(mutation_request(ctx)?), + crate::error::JsonBody(mutation_request(ctx)?), ) .await .map_err(forge_error_to_gql)?; @@ -282,7 +282,7 @@ pub async fn resolve_update_entity<'a>( axum::extract::Path((schema_name.to_owned(), id)), crate::access::OptionalClaims(gql_ctx.claims.clone()), axum::http::HeaderMap::new(), - axum::Json(mutation_request(ctx)?), + crate::error::JsonBody(mutation_request(ctx)?), ) .await .map_err(forge_error_to_gql)? @@ -619,5 +619,6 @@ mod tests { }; let gql_err = forge_error_to_gql(err); assert_eq!(extension_code(&gql_err).as_deref(), Some("INTERNAL_ERROR")); + assert_eq!(gql_err.message, "The server could not complete the operation"); } } diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 95f5ea1b..4483c2f9 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -30,7 +30,7 @@ use crate::access::{ use crate::actor::ForgeActor; use crate::authz::{authorize, namespace::ActionVerb}; use crate::config::SchemaForgeConfig; -use crate::error::ForgeError; +use crate::error::{ForgeError, JsonBody}; use crate::hooks::{ run_before_hook, DispatchHook, HookDispatchActor, HookDispatcher, HookInvocation, HooksConfig, }; @@ -776,8 +776,14 @@ pub fn json_to_entity_fields_with_mode( let dynamic_value = if let Some(def) = field_def { convert_json_with_type_hint(value, &def.field_type) } else { - // Unknown field -- convert based on JSON type - convert_json_untyped(value) + if key != "_tenant" || !schema.is_tenanted() { + errors.push(format!("unknown field '{key}'")); + continue; + } + convert_json_with_type_hint( + value, + &FieldType::Text(schema_forge_core::types::TextConstraints::unconstrained()), + ) }; match dynamic_value { @@ -1074,6 +1080,28 @@ fn convert_json_with_type_hint( serde_json::Value::Null => Ok(DynamicValue::Null), _ => Err(format!("expected array, got {value}")), }, + FieldType::Composite(definitions) => { + match value { + serde_json::Value::Object(object) => { + let mut fields = BTreeMap::new(); + for (name, value) in object { + let definition = definitions + .iter() + .find(|field| field.name.as_str() == name) + .ok_or_else(|| format!("undeclared composite field '{name}'"))?; + if definition.is_hidden() { + return Err(format!("composite field '{name}' cannot be set via the API (marked @hidden)")); + } + let converted = convert_json_with_type_hint(value, &definition.field_type) + .map_err(|error| format!("{name}: {error}"))?; + fields.insert(name.clone(), converted); + } + Ok(DynamicValue::Composite(fields)) + } + serde_json::Value::Null => Ok(DynamicValue::Null), + _ => Err(format!("expected composite object, got {value}")), + } + } FieldType::Map { value: value_type, .. } => match value { @@ -1231,14 +1259,28 @@ fn coerce_dynamic_value_with_type_hint( DynamicValue::Null => Ok(DynamicValue::Null), other => Err(format!("expected array, got {other}")), }, - // Composite fields are passed through unchanged. Nested datetime - // coercion over composite structures is not exercised by any - // in-repo schema today; add recursion here if/when needed. - FieldType::Composite(_) => Ok(value), + FieldType::Composite(definitions) => match value { + DynamicValue::Composite(values) => { + let mut fields = BTreeMap::new(); + for (name, value) in values { + let definition = definitions + .iter() + .find(|field| field.name.as_str() == name) + .ok_or_else(|| format!("undeclared composite field '{name}'"))?; + let converted = + coerce_dynamic_value_with_type_hint(value, &definition.field_type) + .map_err(|error| format!("{name}: {error}"))?; + fields.insert(name, converted); + } + Ok(DynamicValue::Composite(fields)) + } + DynamicValue::Null => Ok(DynamicValue::Null), + other => Err(format!("expected composite, got {other}")), + }, FieldType::Map { value: value_type, .. } => match value { - DynamicValue::Map(map) => { + DynamicValue::Map(map) | DynamicValue::Composite(map) => { let mut out = BTreeMap::new(); for (k, v) in map { out.insert(k, coerce_dynamic_value_with_type_hint(v, value_type)?); @@ -1754,6 +1796,9 @@ async fn resolve_relation_displays( let mut targets: HashMap> = HashMap::new(); for field in &parent_schema.fields { + if field.is_hidden() { + continue; + } if let FieldType::Relation { target, .. } = &field.field_type { targets .entry(target.as_str().to_string()) @@ -1945,6 +1990,86 @@ fn collect_relation_ids(value: &DynamicValue, out: &mut HashSet) { // Cross-entity reads in @require: prefetch-and-bind (#95) // --------------------------------------------------------------------------- +/// Validate the final relation values before any write reaches storage. +async fn validate_relation_targets( + forge: &acton_service::prelude::ActorHandle, + policy_store: &Arc, + schema: &SchemaDefinition, + fields: &BTreeMap, + claims: Option<&Claims>, + tenant_config: &Option, +) -> Result<(), ForgeError> { + if !schema.is_tenanted() + || !tenant_config + .as_ref() + .is_some_and(|config| config.is_enabled()) + || claims.is_some_and(|claims| claims.has_role("platform_admin")) + { + return Ok(()); + } + for field in &schema.fields { + let FieldType::Relation { target, .. } = &field.field_type else { + continue; + }; + if field.is_derived() { + continue; + } + let Some(value) = fields.get(field.name.as_str()) else { + continue; + }; + let mut ids = HashSet::new(); + collect_relation_ids(value, &mut ids); + if ids.is_empty() { + continue; + } + let definitions = fetch_schemas_batch(forge, vec![target.as_str().to_string()]).await?; + let failure = || ForgeError::ValidationFailed { + details: vec![format!( + "field '{}': relation target is unavailable", + field.name.as_str() + )], + }; + let target_schema = definitions.get(target.as_str()).ok_or_else(failure)?; + if !target_schema.is_tenanted() { + continue; + } + let mut query = schema_forge_core::query::Query::new(target_schema.id.clone()) + .with_filter(Filter::In { + path: FieldPath::single("id"), + values: ids.iter().cloned().map(DynamicValue::Text).collect(), + }) + .without_total_count(); + inject_tenant_scope(&mut query, claims, tenant_config, target_schema); + let (tx, rx) = oneshot::channel(); + forge + .send(QueryEntities { + query, + reply: ReplyChannel::new(tx), + }) + .await; + let result = ask_forge(rx).await?.map_err(ForgeError::from)?; + let visible: HashSet<_> = result + .entities + .iter() + .filter(|entity| { + require_entity_action( + policy_store, + target_schema, + claims, + entity, + ActionVerb::Read, + ) + .is_ok() + }) + .map(|entity| entity.id.as_str().to_string()) + .collect(); + if !ids.is_subset(&visible) { + return Err(failure()); + } + } + Ok(()) +} + /// Run `@require` validation with cross-entity-read (`related..`) /// support (#95). /// @@ -2415,6 +2540,9 @@ fn apply_relation_displays( display_map: &HashMap>, ) { for field in &parent_schema.fields { + if field.is_hidden() { + continue; + } let FieldType::Relation { cardinality, .. } = &field.field_type else { continue; }; @@ -2509,7 +2637,7 @@ pub async fn create_entity( Path(schema): Path, OptionalClaims(claims): OptionalClaims, headers: HeaderMap, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let schema_name = validate_schema_name(&schema)?; let forge = state @@ -2684,6 +2812,26 @@ pub async fn create_entity( .await?; } + validate_relation_targets( + &forge, + &policy_store, + &schema_def, + &fields, + claims.as_ref(), + &tenant_config, + ) + .await?; + + crate::webhook::validate_subscription_fields( + &schema_def, + &fields, + &state.config().custom.schema_forge.webhooks, + ) + .await + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.to_string()], + })?; + // Create the entity, filtering write-restricted fields let entity = Entity::with_id(supplied.id, schema_name, fields); validate_required_fields(&schema_def, &entity.fields)?; @@ -2762,7 +2910,7 @@ pub async fn create_entity( // Webhook: fire notifications let webhook_event = crate::webhook::WebhookEvent::from_create( - &schema, + &schema_def, &created, claims.as_ref().map(|c| c.sub.as_str()), ); @@ -2943,7 +3091,7 @@ pub async fn query_entities( Path(schema): Path, OptionalClaims(claims): OptionalClaims, headers: HeaderMap, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let schema_name = validate_schema_name(&schema)?; let forge = state @@ -3304,7 +3452,7 @@ pub async fn update_entity( Path((schema, id)): Path<(String, String)>, OptionalClaims(claims): OptionalClaims, headers: HeaderMap, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let schema_name = validate_schema_name(&schema)?; let forge = state @@ -3555,6 +3703,26 @@ pub async fn update_entity( .await?; } + validate_relation_targets( + &forge, + &policy_store, + &schema_def, + &fields, + claims.as_ref(), + &tenant_config, + ) + .await?; + + crate::webhook::validate_subscription_fields( + &schema_def, + &fields, + &state.config().custom.schema_forge.webhooks, + ) + .await + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.to_string()], + })?; + // Build entity with specific ID, filtering write-restricted fields let entity = Entity::with_id(entity_id, schema_name, fields); validate_required_fields(&schema_def, &entity.fields)?; @@ -3608,7 +3776,7 @@ pub async fn update_entity( // Webhook: fire notifications let webhook_event = crate::webhook::WebhookEvent::from_update( - &schema, + &schema_def, &updated, claims.as_ref().map(|c| c.sub.as_str()), ); @@ -3636,7 +3804,7 @@ pub async fn patch_entity( Path((schema, id)): Path<(String, String)>, OptionalClaims(claims): OptionalClaims, headers: HeaderMap, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let schema_name = validate_schema_name(&schema)?; let forge = state @@ -3863,6 +4031,26 @@ pub async fn patch_entity( validate_required_fields(&schema_def, &merged)?; check_field_constraints(&schema_def, &merged)?; + validate_relation_targets( + &forge, + &policy_store, + &schema_def, + &merged, + claims.as_ref(), + &tenant_config, + ) + .await?; + + crate::webhook::validate_subscription_fields( + &schema_def, + &merged, + &state.config().custom.schema_forge.webhooks, + ) + .await + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.to_string()], + })?; + // Compute the delta: only keys whose final value differs from the // loaded baseline go to the backend. This keeps PATCH's SQL UPDATE // actually partial, which makes the whole class of "null column @@ -3936,7 +4124,7 @@ pub async fn patch_entity( // Webhook: fire notifications let webhook_event = crate::webhook::WebhookEvent::from_update( - &schema, + &schema_def, &updated, claims.as_ref().map(|c| c.sub.as_str()), ); @@ -4442,17 +4630,16 @@ mod tests { } #[test] - fn json_to_entity_fields_unknown_field_accepted() { + fn json_to_entity_fields_unknown_field_rejected() { let schema = make_test_schema(); let mut json_fields = serde_json::Map::new(); json_fields.insert("name".into(), serde_json::json!("Alice")); json_fields.insert("extra".into(), serde_json::json!("extra value")); - let result = json_to_entity_fields(&schema, &json_fields).unwrap(); - assert_eq!( - result.get("extra"), - Some(&DynamicValue::Text("extra value".into())) - ); + for mode in [ConversionMode::Replace, ConversionMode::Merge] { + let errors = json_to_entity_fields_with_mode(&schema, &json_fields, mode).unwrap_err(); + assert_eq!(errors, vec!["unknown field 'extra'"]); + } } #[test] @@ -4561,6 +4748,50 @@ mod tests { ); } + #[test] + fn apply_relation_displays_omits_hidden_relation() { + use schema_forge_core::types::{ + Cardinality, FieldDefinition, FieldName, FieldType, SchemaId, SchemaName, + }; + + // Schema with one Relation(One) field "agency". + let mut schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Opportunity").unwrap(), + vec![FieldDefinition::new( + FieldName::new("agency").unwrap(), + FieldType::Relation { + target: SchemaName::new("Agency").unwrap(), + cardinality: Cardinality::One, + }, + )], + Vec::new(), + ) + .unwrap(); + + schema.fields[0].annotations.push(FieldAnnotation::Hidden); + let mut fields = BTreeMap::new(); + fields.insert( + "agency".to_string(), + DynamicValue::Text("entity_01abcd".into()), + ); + let entity = Entity::new(schema.name.clone(), fields); + let mut response = entity_to_response(&entity, &schema); + + let mut id_to_display = HashMap::new(); + id_to_display.insert( + "entity_01abcd".to_string(), + "Department of Homeland Security".to_string(), + ); + let mut display_map = HashMap::new(); + display_map.insert("agency".to_string(), id_to_display); + + apply_relation_displays(&mut response, &schema, &entity, &display_map); + + assert!(!response.fields.contains_key("agency")); + assert!(!response.fields.contains_key("agency__display")); + } + #[test] fn apply_relation_displays_many_preserves_order_and_nulls() { use schema_forge_core::types::{ @@ -4685,6 +4916,79 @@ mod tests { assert!(matches!(result, DynamicValue::Composite(map) if map.len() == 1)); } + #[test] + fn composite_api_and_hook_conversion_preserve_nested_types() { + let schema = schema_forge_dsl::parse( + r#"schema Job { + settings: composite { + delay: duration checksum: bytes at: datetime + counters: map delays: duration[] + nested: composite { delay: duration } + } + }"#, + ) + .unwrap() + .remove(0); + let field_type = &schema.fields[0].field_type; + let json = serde_json::json!({ + "delay": "2m", "checksum": "aGVsbG8=", "at": "2026-01-01T00:00:00Z", + "counters": {"success": 2}, "delays": ["1s"], "nested": {"delay": "3s"} + }); + let api = convert_json_with_type_hint(&json, field_type).unwrap(); + let hook = + coerce_dynamic_value_with_type_hint(convert_json_untyped(&json).unwrap(), field_type) + .unwrap(); + assert_eq!(api, hook); + let DynamicValue::Composite(fields) = api else { + panic!("expected composite") + }; + assert_eq!( + fields["delay"], + DynamicValue::Duration(chrono::TimeDelta::seconds(120)) + ); + assert_eq!(fields["checksum"], DynamicValue::Bytes(b"hello".to_vec())); + assert!(matches!(fields["at"], DynamicValue::DateTime(_))); + assert!(matches!(fields["counters"], DynamicValue::Map(_))); + assert_eq!( + fields["delays"], + DynamicValue::Array(vec![DynamicValue::Duration(chrono::TimeDelta::seconds(1))]) + ); + let DynamicValue::Composite(nested) = &fields["nested"] else { + panic!("expected nested composite") + }; + assert_eq!( + nested["delay"], + DynamicValue::Duration(chrono::TimeDelta::seconds(3)) + ); + let error = convert_json_with_type_hint( + &serde_json::json!({"nested": {"delay": "invalid"}}), + field_type, + ) + .unwrap_err(); + assert!(error.contains("nested: delay:")); + } + + #[test] + fn composite_conversion_rejects_wrong_shape_and_undeclared_or_hidden_input() { + let schema = schema_forge_dsl::parse( + "schema Job { settings: composite { visible: text secret: text @hidden } }", + ) + .unwrap() + .remove(0); + let field_type = &schema.fields[0].field_type; + for input in [ + serde_json::json!("text"), + serde_json::json!({"unknown": 1}), + serde_json::json!({"secret": "value"}), + ] { + assert!(convert_json_with_type_hint(&input, field_type).is_err()); + } + assert_eq!( + convert_json_with_type_hint(&serde_json::Value::Null, field_type).unwrap(), + DynamicValue::Null + ); + } + fn map_string_integer_type() -> FieldType { FieldType::Map { key: Box::new(FieldType::Text( diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 935a5481..cedf4e62 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -22,7 +22,7 @@ use crate::access::{ }; use crate::actor::ForgeActor; use crate::config::SchemaForgeConfig; -use crate::error::ForgeError; +use crate::error::{ForgeError, JsonBody}; use crate::messages::{ApplyPreparedSchemaChange, GetSchema, ListSchemas, ReplyChannel}; // --------------------------------------------------------------------------- @@ -601,7 +601,7 @@ fn schema_to_response(schema: &SchemaDefinition) -> SchemaResponse { pub async fn create_schema( State(state): State>, OptionalClaims(claims): OptionalClaims, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let claims = require_auth(&claims)?; if let Err(e) = require_admin(claims) { @@ -675,6 +675,14 @@ pub async fn create_schema( // 4a. Run the inverse-relation pairing pass across the full registry so // any `-> X[]` field paired with an FK from an existing schema is marked // as derived before the migration plan is generated. + crate::webhook::validate_schema_webhooks( + &definition, + &state.config().custom.schema_forge.webhooks, + ) + .await + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.to_string()], + })?; let paired_registry = pair_with_registry(&forge, &mut definition).await?; // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB @@ -806,7 +814,7 @@ pub async fn update_schema( State(state): State>, Path(name): Path, OptionalClaims(claims): OptionalClaims, - Json(body): Json, + JsonBody(body): JsonBody, ) -> Result { let claims = require_auth(&claims)?; if let Err(e) = require_admin(claims) { @@ -892,6 +900,14 @@ pub async fn update_schema( // 4a. Run the inverse-relation pairing pass before diffing, so newly // added `-> X[]` fields are classified as derived (and therefore // produce no AddRelation step for a physical column). + crate::webhook::validate_schema_webhooks( + &new_definition, + &state.config().custom.schema_forge.webhooks, + ) + .await + .map_err(|error| ForgeError::ValidationFailed { + details: vec![error.to_string()], + })?; let paired_registry = pair_with_registry(&forge, &mut new_definition).await?; // 4b. Dry-run the Cedar bundle for the proposed registry state so an diff --git a/crates/schema-forge-acton/src/webhook.rs b/crates/schema-forge-acton/src/webhook.rs index eb6673d5..36b245c8 100644 --- a/crates/schema-forge-acton/src/webhook.rs +++ b/crates/schema-forge-acton/src/webhook.rs @@ -1,4 +1,4 @@ -use std::net::IpAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; @@ -37,6 +37,8 @@ pub const VALID_EVENTS: &[&str] = &["created", "updated", "deleted"]; /// The JSON payload delivered to webhook subscribers. #[derive(Debug, Clone, Serialize)] pub struct WebhookEvent { + /// Wire payload format version (plain JSON fields). + pub payload_version: u8, /// Unique delivery ID (UUID v4). pub event_id: String, /// Event type: `entity.created`, `entity.updated`, or `entity.deleted`. @@ -55,34 +57,37 @@ pub struct WebhookEvent { impl WebhookEvent { /// Build an event from a create operation. - pub fn from_create(schema: &str, entity: &Entity, actor: Option<&str>) -> Self { + pub fn from_create(schema: &SchemaDefinition, entity: &Entity, actor: Option<&str>) -> Self { Self { + payload_version: 2, event_id: uuid::Uuid::new_v4().to_string(), event_type: "entity.created".to_string(), - schema: schema.to_string(), + schema: schema.name.to_string(), entity_id: entity.id.as_str().to_string(), timestamp: now_iso8601(), actor: actor.map(String::from), - payload: Some(entity_fields_to_json(entity)), + payload: Some(entity_fields_to_json(entity, schema)), } } /// Build an event from an update operation. - pub fn from_update(schema: &str, entity: &Entity, actor: Option<&str>) -> Self { + pub fn from_update(schema: &SchemaDefinition, entity: &Entity, actor: Option<&str>) -> Self { Self { + payload_version: 2, event_id: uuid::Uuid::new_v4().to_string(), event_type: "entity.updated".to_string(), - schema: schema.to_string(), + schema: schema.name.to_string(), entity_id: entity.id.as_str().to_string(), timestamp: now_iso8601(), actor: actor.map(String::from), - payload: Some(entity_fields_to_json(entity)), + payload: Some(entity_fields_to_json(entity, schema)), } } /// Build an event from a delete operation (no payload — entity is gone). pub fn from_delete(schema: &str, entity_id: &str, actor: Option<&str>) -> Self { Self { + payload_version: 2, event_id: uuid::Uuid::new_v4().to_string(), event_type: "entity.deleted".to_string(), schema: schema.to_string(), @@ -95,8 +100,15 @@ impl WebhookEvent { } /// Convert entity fields to a JSON value. -fn entity_fields_to_json(entity: &Entity) -> serde_json::Value { - serde_json::to_value(&entity.fields).unwrap_or(serde_json::Value::Null) +fn entity_fields_to_json(entity: &Entity, schema: &SchemaDefinition) -> serde_json::Value { + let mut fields = crate::conversions::entity_to_response(entity, schema).fields; + // Subscribers have no caller claims. Use a fixed conservative field policy. + fields.retain(|name, _| { + schema + .field(name) + .is_none_or(|field| field.field_access().is_none()) + }); + serde_json::Value::Object(fields) } /// Get current UTC time as RFC 3339 string. @@ -190,7 +202,6 @@ pub struct ResolvedSubscription { /// exponential backoff. Never blocks the calling HTTP handler. #[derive(Clone)] pub struct WebhookDispatcher { - client: reqwest::Client, config: WebhookConfig, semaphore: Arc, } @@ -201,11 +212,7 @@ impl WebhookDispatcher { let semaphore = Arc::new(tokio::sync::Semaphore::new( config.max_concurrent_deliveries, )); - Self { - client: reqwest::Client::new(), - config, - semaphore, - } + Self { config, semaphore } } /// Fire-and-forget: spawn a background delivery task for each subscription. @@ -213,7 +220,6 @@ impl WebhookDispatcher { /// Returns immediately — webhook delivery never blocks the API response. pub fn dispatch(&self, event: WebhookEvent, subscriptions: Vec) { for sub in subscriptions { - let client = self.client.clone(); let event = event.clone(); let config = self.config.clone(); let semaphore = self.semaphore.clone(); @@ -225,7 +231,7 @@ impl WebhookDispatcher { return; } }; - deliver_with_retry(&client, &event, &sub, &config).await; + deliver_with_retry(&event, &sub, &config).await; }); } } @@ -281,7 +287,6 @@ impl WebhookDispatcher { /// Deliver a webhook event with exponential backoff retry. async fn deliver_with_retry( - client: &reqwest::Client, event: &WebhookEvent, subscription: &ResolvedSubscription, config: &WebhookConfig, @@ -307,10 +312,24 @@ async fn deliver_with_retry( for attempt in 0..=max_retries { if attempt > 0 { - let backoff = Duration::from_millis(500 * 2u64.pow(attempt - 1)); + let backoff = Duration::from_millis( + 500_u64 + .saturating_mul(2_u64.saturating_pow(attempt - 1)) + .min(60_000), + ); tokio::time::sleep(backoff).await; } + let client = match checked_client(&subscription.url, config, timeout).await { + Ok(client) => client, + Err(error) => { + warn!(%error, event_id = %event.event_id, "webhook destination refused"); + if matches!(error, WebhookUrlError::Resolution) { + continue; + } + return; + } + }; let mut request = client .post(&subscription.url) .header("Content-Type", "application/json") @@ -471,40 +490,137 @@ async fn query_webhook_subscriptions( /// /// Rejects private/loopback IPs and enforces allowed URL schemes. pub fn validate_webhook_url(url: &str, allowed_schemes: &[String]) -> Result<(), WebhookUrlError> { - // Basic URL parsing without the `url` crate - let (scheme, rest) = url.split_once("://").ok_or(WebhookUrlError::InvalidUrl)?; - - if !allowed_schemes.iter().any(|s| s == scheme) { - return Err(WebhookUrlError::DisallowedScheme(scheme.to_string())); + let parsed = reqwest::Url::parse(url).map_err(|_| WebhookUrlError::InvalidUrl)?; + if !matches!(parsed.scheme(), "http" | "https") + || !allowed_schemes.iter().any(|s| s == parsed.scheme()) + { + return Err(WebhookUrlError::DisallowedScheme(parsed.scheme().into())); } - - // Extract host (before any port or path) - let host_part = rest.split('/').next().unwrap_or(rest); - let host = host_part.split(':').next().unwrap_or(host_part); - - if host.is_empty() { + if !parsed.username().is_empty() || parsed.password().is_some() || parsed.fragment().is_some() { return Err(WebhookUrlError::InvalidUrl); } - - if host == "localhost" { + let host = parsed.host_str().ok_or(WebhookUrlError::InvalidUrl)?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + if host.trim_end_matches('.').eq_ignore_ascii_case("localhost") + || host.parse::().is_ok_and(|ip| is_private_ip(&ip)) + { return Err(WebhookUrlError::PrivateIp); } + Ok(()) +} - if let Ok(ip) = host.parse::() { - if is_private_ip(&ip) { - return Err(WebhookUrlError::PrivateIp); +/// Conservative globally routable address policy, including mapped IPv4. +fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let [a, b, c, _] = v4.octets(); + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || a == 0 + || a >= 224 + || (a == 100 && (64..=127).contains(&b)) + || (a == 192 && b == 0 && (c == 0 || c == 2)) + || (a == 192 && b == 88 && c == 99) + || (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) + || (a == 203 && b == 0 && c == 113) + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return is_private_ip(&IpAddr::V4(v4)); + } + let s = v6.segments(); + // Permit ordinary global unicast only, excluding special allocations, + // documentation ranges and transition mechanisms. + s[0] & 0xe000 != 0x2000 + || (s[0] == 0x2001 && s[1] < 0x200) + || (s[0] == 0x2001 && s[1] == 0xdb8) + || s[0] == 0x2002 + || (s[0] == 0x3fff && s[1] < 0x1000) } } +} +fn validate_addresses(addresses: &[SocketAddr]) -> Result<(), WebhookUrlError> { + if addresses.is_empty() { + return Err(WebhookUrlError::Resolution); + } + if addresses.iter().any(|address| is_private_ip(&address.ip())) { + return Err(WebhookUrlError::PrivateIp); + } Ok(()) } -/// Check whether an IP address is private/loopback. -fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), - IpAddr::V6(v6) => v6.is_loopback(), +async fn resolved_destination( + url: &str, + config: &WebhookConfig, + timeout: Duration, +) -> Result<(reqwest::Url, Vec), WebhookUrlError> { + validate_webhook_url(url, &config.allowed_url_schemes)?; + let parsed = reqwest::Url::parse(url).map_err(|_| WebhookUrlError::InvalidUrl)?; + let host = parsed + .host_str() + .ok_or(WebhookUrlError::InvalidUrl)? + .trim_start_matches('[') + .trim_end_matches(']'); + let port = parsed + .port_or_known_default() + .ok_or(WebhookUrlError::InvalidUrl)?; + let addresses = tokio::time::timeout(timeout, tokio::net::lookup_host((host, port))) + .await + .map_err(|_| WebhookUrlError::Resolution)? + .map_err(|_| WebhookUrlError::Resolution)? + .collect::>(); + validate_addresses(&addresses)?; + Ok((parsed, addresses)) +} + +async fn checked_client( + url: &str, + config: &WebhookConfig, + timeout: Duration, +) -> Result { + let (parsed, addresses) = resolved_destination(url, config, timeout).await?; + pinned_client(&parsed, &addresses) +} + +fn pinned_client( + parsed: &reqwest::Url, + addresses: &[SocketAddr], +) -> Result { + let host = parsed.host_str().ok_or(WebhookUrlError::InvalidUrl)?; + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve_to_addrs(host, addresses) + .build() + .map_err(|_| WebhookUrlError::Transport) +} + +/// Validate the effective fields before persisting a webhook subscription. +pub async fn validate_subscription_fields( + schema: &SchemaDefinition, + fields: &std::collections::BTreeMap, + config: &WebhookConfig, +) -> Result<(), WebhookUrlError> { + if schema.name.as_str() == "WebhookSubscription" { + let Some(DynamicValue::Text(url)) = fields.get("url") else { + return Err(WebhookUrlError::InvalidUrl); + }; + resolved_destination(url, config, Duration::from_secs(10)).await?; } + Ok(()) +} + +/// Validate an inline webhook destination before applying a schema. +pub async fn validate_schema_webhooks( + schema: &SchemaDefinition, + config: &WebhookConfig, +) -> Result<(), WebhookUrlError> { + if let Some(Annotation::Webhook { url: Some(url), .. }) = schema.webhook_annotation() { + resolved_destination(url, config, Duration::from_secs(10)).await?; + } + Ok(()) } /// Errors from webhook URL validation. @@ -513,11 +629,15 @@ pub enum WebhookUrlError { InvalidUrl, DisallowedScheme(String), PrivateIp, + Resolution, + Transport, } impl std::fmt::Display for WebhookUrlError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::Resolution => write!(f, "webhook destination DNS resolution failed"), + Self::Transport => write!(f, "webhook HTTP client initialization failed"), Self::InvalidUrl => write!(f, "invalid URL"), Self::DisallowedScheme(s) => write!(f, "disallowed URL scheme: {s}"), Self::PrivateIp => write!(f, "private or loopback IP addresses are not allowed"), @@ -535,6 +655,176 @@ impl std::error::Error for WebhookUrlError {} mod tests { use super::*; + #[test] + fn event_projection_omits_nonpublic_fields_and_uses_plain_json() { + let schema = schema_forge_dsl::parse( + r#"schema Note { + title: text + secret: text @hidden + salary: integer @field_access(read: ["hr"], write: ["hr"]) + }"#, + ) + .unwrap() + .remove(0); + let entity = Entity::new( + schema.name.clone(), + std::collections::BTreeMap::from([ + ("title".into(), DynamicValue::Text("hello".into())), + ("secret".into(), DynamicValue::Text("private".into())), + ("salary".into(), DynamicValue::Integer(100)), + ]), + ); + for event in [ + WebhookEvent::from_create(&schema, &entity, None), + WebhookEvent::from_update(&schema, &entity, None), + ] { + assert_eq!(event.payload_version, 2); + assert_eq!(event.payload, Some(serde_json::json!({"title": "hello"}))); + } + } + + #[test] + fn destination_policy_covers_special_address_ranges() { + for host in [ + "127.1", + "2130706433", + "0.0.0.0", + "100.64.1.2", + "192.0.0.8", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "[::1]", + "[::]", + "[fc00::1]", + "[fe80::1]", + "[::ffff:127.0.0.1]", + "[2002:7f00:1::]", + "[2001:db8::1]", + "[64:ff9b::7f00:1]", + "localhost.", + ] { + assert!( + validate_webhook_url(&format!("https://{host}/hook"), &["https".into()]).is_err(), + "{host}" + ); + } + for host in ["8.8.8.8", "[2606:4700:4700::1111]", "example.com"] { + assert!( + validate_webhook_url(&format!("https://{host}/hook"), &["https".into()]).is_ok(), + "{host}" + ); + } + for url in [ + "https://user:pass@example.com/", + "https://example.com/#fragment", + "file:///tmp/test", + ] { + assert!(validate_webhook_url(url, &["https".into(), "file".into()]).is_err()); + } + } + + #[test] + fn mixed_public_private_dns_results_are_refused() { + let public = "8.8.8.8:443".parse().unwrap(); + let private = "10.0.0.1:443".parse().unwrap(); + assert!(validate_addresses(&[public]).is_ok()); + assert!(validate_addresses(&[public, private]).is_err()); + assert!(validate_addresses(&[]).is_err()); + } + + #[tokio::test] + async fn pinned_transport_uses_checked_address_and_does_not_follow_redirects() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let initial = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let destination = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let checked_address = initial.local_addr().unwrap(); + let location = format!("http://{}/private", destination.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut connection, _) = initial.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let count = connection.read(&mut request).await.unwrap(); + assert!(String::from_utf8_lossy(&request[..count]).contains("POST /hook")); + connection.write_all(format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap(); + }); + // Inject a fixture address at the transport boundary, after the policy + // boundary tested separately. This hostname cannot resolve via DNS. + let url = reqwest::Url::parse(&format!( + "http://webhook.invalid:{}/hook", + checked_address.port() + )) + .unwrap(); + let client = pinned_client(&url, &[checked_address]).unwrap(); + let response = client + .post(url) + .timeout(Duration::from_secs(2)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + server.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(50), destination.accept()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn write_validation_rejects_inline_and_subscription_destinations() { + let inline = schema_forge_dsl::parse( + r#"@webhook(url: "https://[::1]/hook") schema Note { title: text }"#, + ) + .unwrap() + .remove(0); + let config = WebhookConfig::default(); + assert!(validate_schema_webhooks(&inline, &config).await.is_err()); + let subscription = schema_forge_dsl::parse("schema WebhookSubscription { url: text }") + .unwrap() + .remove(0); + for url in ["http://example.com/hook", "https://127.0.0.1/hook"] { + let fields = + std::collections::BTreeMap::from([("url".into(), DynamicValue::Text(url.into()))]); + assert!( + validate_subscription_fields(&subscription, &fields, &config) + .await + .is_err() + ); + } + assert!( + validate_subscription_fields(&subscription, &Default::default(), &config) + .await + .is_err() + ); + assert!( + validate_subscription_fields(&inline, &Default::default(), &config) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn delivery_rejects_unsafe_stored_subscription_without_connecting() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let subscription = ResolvedSubscription { + url: format!("http://{}/hook", listener.local_addr().unwrap()), + secret: None, + retry_count: Some(0), + timeout_seconds: Some(1), + }; + let config = WebhookConfig { + allowed_url_schemes: vec!["http".into()], + ..Default::default() + }; + let event = WebhookEvent::from_delete("Note", "note_test", None); + deliver_with_retry(&event, &subscription, &config).await; + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } + #[test] fn compute_signature_with_secret() { let sub = ResolvedSubscription { diff --git a/crates/schema-forge-acton/tests/auth_demo.rs b/crates/schema-forge-acton/tests/auth_demo.rs index a57d2877..ce84a3f7 100644 --- a/crates/schema-forge-acton/tests/auth_demo.rs +++ b/crates/schema-forge-acton/tests/auth_demo.rs @@ -609,7 +609,7 @@ async fn demo_multi_tenancy_isolation() { .unwrap(); register_schema(&org_schema, &backend, &mut registry).await; - // Declare Project as a tenant child; unannotated schemas are shared. + // Declare Project as a tenant child; all application schemas require tenancy. // @access with empty lists = all authenticated users permitted (testing tenancy, not schema-level) let project_schema = SchemaDefinition::new( SchemaId::new(), diff --git a/crates/schema-forge-acton/tests/authorization_context.rs b/crates/schema-forge-acton/tests/authorization_context.rs index 7c4b2a34..fcff4db6 100644 --- a/crates/schema-forge-acton/tests/authorization_context.rs +++ b/crates/schema-forge-acton/tests/authorization_context.rs @@ -418,3 +418,73 @@ async fn guarded_read_policy_preserves_http_preflight_and_record_denials() { } } } + +#[test] +fn unsupported_array_attributes_do_not_break_resource_authorization() { + let mut schema = schema_forge_dsl::parse( + r#" + @access(read: ["reviewer"], write: ["reviewer"]) + schema Job { + name: text required + delays: duration[] required + documents: json[] required + nested: duration[] required + numbers: integer[] required + } + "#, + ) + .unwrap() + .remove(0); + // The DSL accepts one array suffix; exercise nested core types directly. + let nested = schema + .fields + .iter_mut() + .find(|field| field.name.as_str() == "nested") + .unwrap(); + nested.field_type = + schema_forge_core::types::FieldType::Array(Box::new(nested.field_type.clone())); + let snapshot = PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&schema), + None, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + let store = Arc::new(PolicyStore::new(snapshot)); + let entity = Entity::with_id( + EntityId::new("job"), + schema.name.clone(), + BTreeMap::from([ + ("name".into(), DynamicValue::Text("Example".into())), + ( + "delays".into(), + DynamicValue::Array(vec![DynamicValue::Duration(chrono::TimeDelta::seconds(90))]), + ), + ( + "documents".into(), + DynamicValue::Array(vec![DynamicValue::Json( + serde_json::json!({"key": "value"}), + )]), + ), + ( + "nested".into(), + DynamicValue::Array(vec![DynamicValue::Array(vec![])]), + ), + ( + "numbers".into(), + DynamicValue::Array(vec![DynamicValue::Integer(7)]), + ), + ]), + ); + for role in ["platform_admin", "reviewer"] { + let mut caller = claims(); + caller.roles = vec![role.into()]; + for resource in [None, Some(&entity)] { + for action in [ActionVerb::Create, ActionVerb::Read] { + let decision = authorize(&store, Some(&caller), action, &schema, resource).unwrap(); + assert!(decision.is_allow(), "{role} must retain permitted access"); + assert!(decision.errors.is_empty()); + } + } + } +} diff --git a/crates/schema-forge-acton/tests/cross_entity_reads.rs b/crates/schema-forge-acton/tests/cross_entity_reads.rs index a99ad775..dcef92b8 100644 --- a/crates/schema-forge-acton/tests/cross_entity_reads.rs +++ b/crates/schema-forge-acton/tests/cross_entity_reads.rs @@ -635,3 +635,202 @@ async fn multi_hop_related_read_is_rejected_with_clear_error() { "body should mention multi-hop: {body}" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn relation_writes_validate_tenant_targets_and_request_fields() { + let backend = Arc::new( + SurrealBackend::connect_memory("test", "relation_writes") + .await + .unwrap(), + ); + let mut registry = HashMap::new(); + let access = Annotation::Access { + read: vec!["member".into()], + write: vec!["member".into()], + delete: vec!["member".into()], + cross_tenant_read: vec![], + }; + for (name, fields, tenant) in [ + ("Organization", vec![text_field("name")], TenantKind::Root), + ( + "Approval", + vec![text_field("name")], + TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }, + ), + ( + "Document", + vec![ + text_field("title"), + relation_field("approval", "Approval", Cardinality::One), + relation_field("reviewers", "Approval", Cardinality::Many), + ], + TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }, + ), + ] { + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new(name).unwrap(), + fields, + vec![access.clone(), Annotation::Tenant(tenant)], + ) + .unwrap(); + apply_and_register(&backend, &mut registry, schema).await; + } + let tenant_config = + TenantConfig::from_schemas(®istry.values().cloned().collect::>()).unwrap(); + let state = build_state(backend, registry, Some(tenant_config)).await; + let app_a = app_with_claims(state.clone(), claims_in_tenant(&["member"], "org-a")); + let app_b = app_with_claims(state.clone(), claims_in_tenant(&["member"], "org-b")); + let (status, own) = json_request( + &app_a, + Method::POST, + "/schemas/Approval/entities", + Some(serde_json::json!({"fields":{"name":"own"}})), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{own}"); + let (status, other) = json_request( + &app_b, + Method::POST, + "/schemas/Approval/entities", + Some(serde_json::json!({"fields":{"name":"other"}})), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{other}"); + let (status, document) = json_request( + &app_a, + Method::POST, + "/schemas/Document/entities", + Some(serde_json::json!({"fields":{"title":"own","approval":own["id"]}})), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + let entity_path = format!( + "/schemas/Document/entities/{}", + document["id"].as_str().unwrap() + ); + let missing = schema_forge_core::types::EntityId::new("approval"); + for method in [Method::POST, Method::PUT, Method::PATCH] { + let path = if method == Method::POST { + "/schemas/Document/entities" + } else { + &entity_path + }; + for field in ["approval", "reviewers"] { + let mut failures = Vec::new(); + for id in [other["id"].as_str().unwrap(), missing.as_str()] { + let value = if field == "reviewers" { + serde_json::json!([own["id"], id]) + } else { + serde_json::json!(id) + }; + let (status, body) = json_request( + &app_a, + method.clone(), + path, + Some(serde_json::json!({"fields":{"title":"candidate",field:value}})), + ) + .await; + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "{method} {field}: {body}" + ); + failures.push(body); + } + assert_eq!( + failures[0], failures[1], + "missing and inaccessible targets must be indistinguishable" + ); + } + let (status, body) = json_request( + &app_a, + method.clone(), + path, + Some(serde_json::json!({"fields":{"title":"candidate","bogus_key":"x"}})), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{method}: {body}"); + assert_eq!(body["error"], "validation_failed"); + assert!(body["message"].as_str().unwrap().contains("bogus_key")); + let (status, body) = json_request( + &app_a, + method.clone(), + path, + Some(serde_json::json!({"title":"missing wrapper"})), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["error"], "validation_failed"); + } + let (status, unchanged) = json_request(&app_a, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged["fields"]["approval"], own["id"]); + let admin = app_with_claims(state, claims(&["platform_admin"])); + let (status, body) = json_request( + &admin, + Method::PATCH, + &entity_path, + Some(serde_json::json!({"fields":{"approval":other["id"]}})), + ) + .await; + assert_eq!(status, StatusCode::OK, "administrator override: {body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subscription_writes_reject_unsafe_urls_before_persistence() { + use schema_forge_backend::{Entity, EntityStore}; + let backend = Arc::new( + SurrealBackend::connect_memory("test", "subscription_urls") + .await + .unwrap(), + ); + let mut registry = HashMap::new(); + let schema = + schema_forge_dsl::parse("@system schema WebhookSubscription { url: text required }") + .unwrap() + .remove(0); + apply_and_register(&backend, &mut registry, schema.clone()).await; + let existing = Entity::new( + schema.name, + std::collections::BTreeMap::from([( + "url".into(), + schema_forge_core::types::DynamicValue::Text("https://8.8.8.8/hook".into()), + )]), + ); + EntityStore::create(backend.as_ref(), &existing) + .await + .unwrap(); + let state = build_state(backend, registry, None).await; + let app = app_with_claims(state, claims(&["platform_admin"])); + let entity_path = format!("/schemas/WebhookSubscription/entities/{}", existing.id); + for method in [Method::POST, Method::PUT, Method::PATCH] { + let path = if method == Method::POST { + "/schemas/WebhookSubscription/entities" + } else { + &entity_path + }; + for url in ["https://127.0.0.1/hook", "http://8.8.8.8/hook"] { + let (status, body) = json_request( + &app, + method.clone(), + path, + Some(serde_json::json!({"fields":{"url":url}})), + ) + .await; + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "{method} {url}: {body}" + ); + assert_eq!(body["error"], "validation_failed"); + } + } + let (status, body) = json_request(&app, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["fields"]["url"], "https://8.8.8.8/hook"); +} diff --git a/crates/schema-forge-acton/tests/file_authorization.rs b/crates/schema-forge-acton/tests/file_authorization.rs index 745cf83e..5f381e37 100644 --- a/crates/schema-forge-acton/tests/file_authorization.rs +++ b/crates/schema-forge-acton/tests/file_authorization.rs @@ -28,11 +28,15 @@ use schema_forge_core::types::{ use schema_forge_surrealdb::SurrealBackend; use std::{ collections::{BTreeMap, HashMap}, - sync::Arc, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, time::Duration, }; use tokio::sync::oneshot; use tower::ServiceExt; +use tracing::instrument::WithSubscriber; fn claims(tenant: Option<&str>, roles: &[&str]) -> Claims { let mut custom = HashMap::new(); @@ -221,6 +225,36 @@ async fn fixture_with_options( ) } +/// Observe the server-side storage diagnostic without exposing it to the client. +#[derive(Clone, Default)] +struct StorageLookupObserver(Arc); + +impl tracing::field::Visit for StorageLookupObserver { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "error" + && format!("{value:?}").contains("storage backend 'documents' not configured") + { + self.0.store(true, Ordering::Relaxed); + } + } +} + +impl tracing::Subscriber for StorageLookupObserver { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + event.record(&mut self.clone()); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} +} + async fn status(app: &Router, path: &str, operation: &str) -> StatusCode { status_with_tenant(app, path, operation, None).await } @@ -253,18 +287,32 @@ async fn status_with_tenant( if let Some(tenant) = tenant { request = request.header("x-active-tenant", tenant); } + let observer = StorageLookupObserver::default(); let response = app .clone() .oneshot(request.body(Body::from(body)).unwrap()) + .with_subscriber(observer.clone()) .await .unwrap(); let status = response.status(); if status == StatusCode::INTERNAL_SERVER_ERROR { let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let body = String::from_utf8(bytes.to_vec()).unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "error": "internal_error", + "message": "The server could not complete the operation", + }) + ); + assert!( + observer.0.load(Ordering::Relaxed), + "authorized request must reach storage lookup" + ); + } else if status == StatusCode::FORBIDDEN { assert!( - body.contains("storage backend 'documents' not configured"), - "unexpected internal error: {body}" + !observer.0.load(Ordering::Relaxed), + "denied request must stop before storage lookup" ); } status diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index 43e985ca..0dab4559 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -31,6 +31,10 @@ async fn app() -> (Router, String, String) { } async fn app_with_relations(relations: bool) -> (Router, String, String) { + app_with_options(relations, true).await +} + +async fn app_with_options(relations: bool, tenancy: bool) -> (Router, String, String) { let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "tenants", None, None) .await .unwrap(); @@ -40,6 +44,7 @@ async fn app_with_relations(relations: bool) -> (Router, String, String) { @access(read: ["member"], write: ["member"], delete: ["member"]) schema Org { name: text required } @access(read: ["member"], write: ["member"]) + @tenant(parent: "Org") schema Catalog { name: text required secret: text hidden_value: text } "#, ) @@ -49,12 +54,20 @@ async fn app_with_relations(relations: bool) -> (Router, String, String) { schema_forge_dsl::parse( r#" @access(read: ["member"], write: ["member"]) + @tenant(parent: "Org") schema Link { one: -> Catalog many: -> Catalog[] } "#, ) .unwrap(), ); } + if !tenancy { + for schema in &mut schemas { + schema.annotations.retain(|annotation| { + !matches!(annotation, schema_forge_core::types::Annotation::Tenant(_)) + }); + } + } for schema in &schemas { backend .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) @@ -148,7 +161,7 @@ async fn query(app: &Router, query: &str, tenant: &str) -> Value { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { +async fn graphql_scopes_legacy_roots_and_application_catalog() { let (app, own, foreign) = app().await; let get = query( &app, @@ -186,9 +199,11 @@ async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { .await; assert!(created.get("errors").is_none(), "{created}"); let catalog = query(&app, "{ catalogs { items { name } } }", &foreign).await; + assert_eq!(catalog["data"]["catalogs"]["items"], json!([]), "{catalog}"); + let own_catalog = query(&app, "{ catalogs { items { name } } }", &own).await; assert_eq!( - catalog["data"]["catalogs"]["items"][0]["name"], "shared", - "{catalog}" + own_catalog["data"]["catalogs"]["items"][0]["name"], + "shared" ); // These routes intentionally have no tenant middleware. A permissive // operator policy must never replace concrete Cedar tenant authorization. @@ -246,7 +261,7 @@ async fn administer_catalog(app: &Router, method: Method, body: Value) { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn graphql_uses_live_field_security_and_refuses_removed_schemas() { - let (app, tenant, _) = app().await; + let (app, tenant, _) = app_with_options(false, false).await; let created = query(&app, "mutation { createCatalog(input: {name: \"visible\", secret: \"restricted\", hidden_value: \"private\"}) { id } }", &tenant).await; diff --git a/crates/schema-forge-acton/tests/graphql_writes.rs b/crates/schema-forge-acton/tests/graphql_writes.rs index 5da47910..c1fc1add 100644 --- a/crates/schema-forge-acton/tests/graphql_writes.rs +++ b/crates/schema-forge-acton/tests/graphql_writes.rs @@ -27,6 +27,7 @@ async fn app() -> Router { let schemas = schema_forge_dsl::parse( r#" @access(read: ["staff", "manager"], write: ["staff", "manager"]) + @tenant(parent: "Org") schema Line { label: text required number: text @field_access(read: ["staff", "manager"], write: ["manager"]) @@ -99,7 +100,13 @@ async fn app() -> Router { } async fn query(app: &Router, query: &str, role: &str) -> Value { - query_with_tenant(app, query, role, None).await + query_with_tenant( + app, + query, + role, + (role != "platform_admin").then_some("org_graphql"), + ) + .await } async fn query_with_tenant(app: &Router, query: &str, role: &str, tenant: Option<&str>) -> Value { diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index f59c493d..77ac0a3a 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-backend" -version = "0.18.0" +version = "0.19.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-backend/src/tenant.rs b/crates/schema-forge-backend/src/tenant.rs index 4d87068b..4fc09cdb 100644 --- a/crates/schema-forge-backend/src/tenant.rs +++ b/crates/schema-forge-backend/src/tenant.rs @@ -31,6 +31,8 @@ pub struct TenantLevel { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum TenantConfigError { + /// An application schema omitted its tenant annotation. + MissingAnnotation { schema: String }, /// Multiple schemas have `@tenant(root)`. MultipleRoots { first: String, second: String }, /// A `@tenant(child: "X")` references a non-existent schema. @@ -42,6 +44,10 @@ pub enum TenantConfigError { impl fmt::Display for TenantConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::MissingAnnotation { schema } => write!( + f, + "schema '{schema}' must declare @tenant when a tenant root exists" + ), Self::MultipleRoots { first, second } => { write!( f, @@ -119,6 +125,16 @@ impl TenantConfig { }); } + if root.is_some() { + for schema in schemas { + if !schema.is_tenanted() && !schema.is_system() { + return Err(TenantConfigError::MissingAnnotation { + schema: schema.name.as_str().to_string(), + }); + } + } + } + // Collect all tenant schema names for validation let tenant_schemas: HashSet = levels .iter() @@ -294,6 +310,20 @@ mod tests { assert!(config.hierarchy.is_empty()); } + #[test] + fn tenant_root_rejects_unannotated_application_schema() { + let root = make_schema("Org", vec![Annotation::Tenant(TenantKind::Root)]); + let note = make_schema("Note", vec![]); + assert_eq!( + TenantConfig::from_schemas(&[root.clone(), note]).unwrap_err(), + TenantConfigError::MissingAnnotation { + schema: "Note".into() + } + ); + let system = make_schema("Account", vec![Annotation::System]); + assert!(TenantConfig::from_schemas(&[root, system]).is_ok()); + } + #[test] fn from_schemas_empty_returns_disabled() { let config = TenantConfig::from_schemas(&[]).unwrap(); diff --git a/crates/schema-forge-cel/Cargo.toml b/crates/schema-forge-cel/Cargo.toml index d1c4e141..0834d158 100644 --- a/crates/schema-forge-cel/Cargo.toml +++ b/crates/schema-forge-cel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cel" -version = "0.11.0" +version = "0.12.0" edition = "2021" description = "Minimal, owned CEL (Common Expression Language) evaluator over SchemaForge DynamicValue." @@ -17,7 +17,7 @@ chrono-tz = "0.10.4" # catastrophic backtracking (no ReDoS), matching the evaluator's # guaranteed-terminating / DoS-hardened posture. regex = "1.12.3" -schema-forge-core = { version = "0.18.0", path = "../schema-forge-core" } +schema-forge-core = { version = "0.19.0", path = "../schema-forge-core" } serde_json = "1.0.150" tracing = "0.1" diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index 53a75695..2dda8f7f 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.45.0" +version = "0.46.0" edition = "2021" [[bin]] @@ -29,7 +29,7 @@ glob = "0.3" axum = { version = "0.8" } acton-service = { version = "0.43.1", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "windows-auth"] } heck = "0.5.0" -minijinja = "2.19.0" +minijinja = { version = "2.19.0", features = ["json"] } tracing = "0.1.44" schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" } sigstore-trust-root = { version = "0.7.0", default-features = false, features = ["tuf"] } @@ -38,7 +38,9 @@ rust-embed = { version = "8.11.0", features = ["interpolate-folder-path"], optio mime_guess = { version = "2.0.5" } sha2 = "0.11.0" tokio-util = { version = "0.7.18", features = ["io"] } -schema-forge-mssql = { version = "0.5.0", path = "../schema-forge-mssql", optional = true } +schema-forge-mssql = { version = "0.6.0", path = "../schema-forge-mssql", optional = true } +httpdate = "1.0.3" +figment = { version = "0.10.19", features = ["env", "toml"] } [features] default = ["surrealdb"] diff --git a/crates/schema-forge-cli/src/cli.rs b/crates/schema-forge-cli/src/cli.rs index b38101fc..6d1a2e54 100644 --- a/crates/schema-forge-cli/src/cli.rs +++ b/crates/schema-forge-cli/src/cli.rs @@ -395,6 +395,26 @@ pub struct SiteGenerateArgs { #[arg(short = 'o', long, default_value = "site")] pub out_dir: PathBuf, + /// Product name shown in the generated site (overrides config). + #[arg(long)] + pub name: Option, + + /// Browser-title suffix; pass an empty string to disable. + #[arg(long)] + pub title_suffix: Option, + + /// SVG logo for light surfaces (overrides config). + #[arg(long)] + pub logo: Option, + + /// SVG logo for dark surfaces (defaults to logo). + #[arg(long)] + pub logo_on_dark: Option, + + /// SVG favicon (defaults to logo). + #[arg(long)] + pub favicon: Option, + /// Pick a single schema by name. Defaults to the first schema in the directory. #[arg(long)] pub schema: Option, @@ -710,13 +730,13 @@ pub struct ServeArgs { #[arg(long)] pub allow_destructive_migrations: bool, - /// Host address to bind - #[arg(short = 'H', long = "host", default_value = "127.0.0.1")] - pub host: IpAddr, + /// Host address to bind (default: configuration, then 127.0.0.1) + #[arg(short = 'H', long = "host")] + pub host: Option, - /// Port to listen on - #[arg(short = 'p', long = "port", default_value = "3000")] - pub port: u16, + /// Port to listen on (default: configuration, then 3000) + #[arg(short = 'p', long = "port")] + pub port: Option, /// Schema files to load on startup #[arg(long = "schemas", default_value = "schemas/")] @@ -941,6 +961,10 @@ pub struct EntityConnectionArgs { /// Per-request timeout in seconds. #[arg(long = "timeout")] pub timeout: Option, + + /// Maximum retries after HTTP 429 (0 disables retries). + #[arg(long, default_value_t = 3, value_parser = clap::value_parser!(u32).range(0..=10))] + pub max_retries: u32, } /// Field input shared by `create`, `replace`, and `patch`. @@ -1302,6 +1326,23 @@ mod tests { use super::*; use clap::CommandFactory; + #[test] + fn absent_listener_flags_do_not_override_configuration() { + let cli = Cli::try_parse_from(["schemaforge", "serve"]).unwrap(); + let Commands::Serve(args) = cli.command else { + panic!("expected serve") + }; + assert_eq!(args.host, None); + assert_eq!(args.port, None); + let cli = + Cli::try_parse_from(["schemaforge", "serve", "-H", "127.0.0.1", "-p", "3000"]).unwrap(); + let Commands::Serve(args) = cli.command else { + panic!("expected serve") + }; + assert_eq!(args.host, Some(std::net::Ipv4Addr::LOCALHOST.into())); + assert_eq!(args.port, Some(3000)); + } + #[test] fn verify_cli_structure() { // This validates the derive macros produce a valid clap command. @@ -1514,8 +1555,8 @@ mod tests { ]) .unwrap(); if let Commands::Serve(args) = cli.command { - assert_eq!(args.host, std::net::Ipv4Addr::UNSPECIFIED); - assert_eq!(args.port, 8080); + assert_eq!(args.host, Some(std::net::Ipv4Addr::UNSPECIFIED.into())); + assert_eq!(args.port, Some(8080)); assert!(args.watch); } else { panic!("expected Serve command"); diff --git a/crates/schema-forge-cli/src/commands/apply.rs b/crates/schema-forge-cli/src/commands/apply.rs index ee15fa61..94011b8c 100644 --- a/crates/schema-forge-cli/src/commands/apply.rs +++ b/crates/schema-forge-cli/src/commands/apply.rs @@ -21,9 +21,24 @@ pub async fn run( output.status(&format!(" {} schemas parsed.", schemas.len())); let svc_config = load_svc_config(global)?; + for schema in &schemas { + schema_forge_acton::webhook::validate_schema_webhooks( + schema, + &svc_config.custom.schema_forge.webhooks, + ) + .await + .map_err(|error| CliError::Config { + message: format!("invalid webhook on {}: {error}", schema.name), + })?; + } + let db_params = resolve_db_params(&svc_config)?; - let backend = super::connect_backend(&db_params, output).await?; + let backend = if args.dry_run { + super::connect_backend_read_only(&db_params, output).await? + } else { + super::connect_backend(&db_params, output).await? + }; apply_to_backend(&args, &schemas, backend.as_ref(), output).await } @@ -43,10 +58,22 @@ pub(super) async fn apply_to_backend( let mut applied_schemas = 0usize; let mut metadata_only_updates = 0usize; + let mut updates = Vec::with_capacity(schemas.len()); for schema in schemas { let existing = backend.load_schema_metadata(&schema.name).await?; let update = SchemaUpdate::plan(existing.as_ref(), schema)?; + updates.push(update); + } + super::schema_update::preflight_destructive_batch( + &updates, + !args.dry_run, + args.force, + Term::stderr().is_term(), + )?; + + for update in &updates { + let schema = &update.schema; let plan = &update.migration; if update.is_empty() { output.status(&format!(" {} .... no changes", schema.name.as_str())); @@ -70,7 +97,7 @@ pub(super) async fn apply_to_backend( schema.name.as_str() )); for (i, step) in plan.steps.iter().enumerate() { - let safety = step.safety(); + let safety = plan.step_safety(step); output.status(&format!(" {}. {} [{}]", i + 1, step, safety)); } @@ -98,12 +125,10 @@ pub(super) async fn apply_to_backend( " {:<16} METADATA UPDATE (0 migration steps)", schema.name.as_str() )); - } else if plan.steps.len() == 1 - && matches!( - &plan.steps[0], - schema_forge_core::migration::MigrationStep::CreateSchema { .. } - ) - { + } else if matches!( + &plan.steps[0], + schema_forge_core::migration::MigrationStep::CreateSchema { .. } + ) { output.status(&format!( " {:<16} CREATE ({} fields){}", schema.name.as_str(), @@ -190,7 +215,7 @@ pub(super) async fn apply_to_backend( fn format_safety_tag(safety: MigrationSafety) -> String { match safety { MigrationSafety::Safe => " [safe]".to_string(), - MigrationSafety::RequiresConfirmation => " [requires_confirmation]".to_string(), + MigrationSafety::RequiresConfirmation => " [review]".to_string(), MigrationSafety::Destructive => " [destructive]".to_string(), _ => String::new(), } diff --git a/crates/schema-forge-cli/src/commands/inspect.rs b/crates/schema-forge-cli/src/commands/inspect.rs index 6929af68..078b679c 100644 --- a/crates/schema-forge-cli/src/commands/inspect.rs +++ b/crates/schema-forge-cli/src/commands/inspect.rs @@ -14,7 +14,7 @@ pub async fn run( let svc_config = load_svc_config(global)?; let db_params = resolve_db_params(&svc_config)?; - let backend = super::connect_backend(&db_params, output).await?; + let backend = super::connect_backend_read_only(&db_params, output).await?; let all_schemas = backend.list_schema_metadata().await?; diff --git a/crates/schema-forge-cli/src/commands/migrate.rs b/crates/schema-forge-cli/src/commands/migrate.rs index db1e5d9d..849a652b 100644 --- a/crates/schema-forge-cli/src/commands/migrate.rs +++ b/crates/schema-forge-cli/src/commands/migrate.rs @@ -19,9 +19,24 @@ pub async fn run( let schemas = parse_all_schemas_with_global(&args.paths, global, output)?; let svc_config = load_svc_config(global)?; + for schema in &schemas { + schema_forge_acton::webhook::validate_schema_webhooks( + schema, + &svc_config.custom.schema_forge.webhooks, + ) + .await + .map_err(|error| CliError::Config { + message: format!("invalid webhook on {}: {error}", schema.name), + })?; + } + let db_params = resolve_db_params(&svc_config)?; - let backend = super::connect_backend(&db_params, output).await?; + let backend = if args.execute { + super::connect_backend(&db_params, output).await? + } else { + super::connect_backend_read_only(&db_params, output).await? + }; migrate_on_backend(&args, &schemas, backend.as_ref(), output).await } @@ -70,6 +85,13 @@ pub(super) async fn migrate_on_backend( plans.push(update); } + super::schema_update::preflight_destructive_batch( + &plans, + args.execute, + args.force, + Term::stderr().is_term(), + )?; + // Render plan match output.mode { OutputMode::Human => { @@ -96,7 +118,7 @@ pub(super) async fn migrate_on_backend( plan.overall_safety() ); for (i, step) in plan.steps.iter().enumerate() { - println!(" {}. {} [{}]", i + 1, step, step.safety()); + println!(" {}. {} [{}]", i + 1, step, plan.step_safety(step)); } println!(); } @@ -118,7 +140,7 @@ pub(super) async fn migrate_on_backend( .map(|s| { serde_json::json!({ "description": s.to_string(), - "safety": s.safety().to_string(), + "safety": plan.step_safety(s).to_string(), }) }) .collect(); @@ -148,7 +170,12 @@ pub(super) async fn migrate_on_backend( println!("{}\tmetadata update\tsafe", schema.name.as_str()); } for step in &plan.steps { - println!("{}\t{}\t{}", schema.name.as_str(), step, step.safety()); + println!( + "{}\t{}\t{}", + schema.name.as_str(), + step, + plan.step_safety(step) + ); } } } diff --git a/crates/schema-forge-cli/src/commands/mod.rs b/crates/schema-forge-cli/src/commands/mod.rs index d435718e..f1ebd7bf 100644 --- a/crates/schema-forge-cli/src/commands/mod.rs +++ b/crates/schema-forge-cli/src/commands/mod.rs @@ -38,6 +38,22 @@ use crate::progress; pub async fn connect_backend( db_params: &DbParams, output: &OutputContext, +) -> Result, CliError> { + connect_backend_with_mode(db_params, output, false).await +} + +/// Connect without PostgreSQL bookkeeping DDL for inspection and planning. +pub async fn connect_backend_read_only( + db_params: &DbParams, + output: &OutputContext, +) -> Result, CliError> { + connect_backend_with_mode(db_params, output, true).await +} + +async fn connect_backend_with_mode( + db_params: &DbParams, + output: &OutputContext, + read_only: bool, ) -> Result, CliError> { let spinner = if output.show_progress() { Some(progress::create_spinner("Connecting to backend...")) @@ -45,7 +61,7 @@ pub async fn connect_backend( None }; - let result = connect_backend_inner(db_params).await; + let result = connect_backend_inner(db_params, read_only).await; match result { Ok(backend) => { @@ -63,7 +79,12 @@ pub async fn connect_backend( } } -async fn connect_backend_inner(db_params: &DbParams) -> Result, CliError> { +async fn connect_backend_inner( + db_params: &DbParams, + read_only: bool, +) -> Result, CliError> { + // Other backends do not bootstrap PostgreSQL bookkeeping tables. + let _ = read_only; match db_params { #[cfg(feature = "surrealdb")] DbParams::Surrealdb(p) => { @@ -81,7 +102,7 @@ async fn connect_backend_inner(db_params: &DbParams) -> Result { eprintln!( "Warning: Could not connect to {}; falling back to in-memory backend: {remote_err}", - p.url + db_params.redacted_url() ); let b = schema_forge_surrealdb::SurrealBackend::connect_memory( &p.namespace, @@ -95,9 +116,12 @@ async fn connect_backend_inner(db_params: &DbParams) -> Result { - let b = schema_forge_postgres::PgBackend::connect(&p.url) - .await - .map_err(CliError::Backend)?; + let b = if read_only { + schema_forge_postgres::PgBackend::connect_read_only(&p.url).await + } else { + schema_forge_postgres::PgBackend::connect(&p.url).await + } + .map_err(CliError::Backend)?; Ok(Arc::new(b)) } #[cfg(feature = "mssql")] @@ -111,7 +135,7 @@ async fn connect_backend_inner(db_params: &DbParams) -> Result Err(CliError::Config { message: format!( "backend '{}' is not enabled in this build (check Cargo features)", - other.url() + other.redacted_url() ), }), } diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index 34253c17..1b999118 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -43,6 +43,36 @@ pub(super) async fn preflight_schema_batch( validate_tenant_hierarchy(&merge_schema_definitions(existing, desired)) } +/// Refuse a known destructive batch before any schema or revision writes. +pub(super) fn preflight_destructive_batch( + updates: &[SchemaUpdate], + execute: bool, + force: bool, + interactive: bool, +) -> Result<(), CliError> { + if execute && !force && !interactive { + let destructive_steps: Vec<_> = updates + .iter() + .flat_map(|update| { + update + .migration + .steps + .iter() + .filter(|step| { + step.safety() == schema_forge_core::migration::MigrationSafety::Destructive + }) + .map(|step| format!(" {}: {step}", update.schema.name)) + }) + .collect(); + if !destructive_steps.is_empty() { + return Err(CliError::RequiresForceBatch { + details: destructive_steps.join("\n"), + }); + } + } + Ok(()) +} + pub(super) struct SchemaUpdate { pub schema: SchemaDefinition, pub migration: MigrationPlan, @@ -180,7 +210,7 @@ mod tests { } fn load_schema_metadata<'a>( &'a self, - _: &'a SchemaName, + name: &'a SchemaName, ) -> Pin< Box< dyn Future, BackendError>> @@ -189,7 +219,15 @@ mod tests { + 'a, >, > { - Box::pin(async move { Ok(self.stored.lock().unwrap().schema.clone()) }) + Box::pin(async move { + Ok(self + .stored + .lock() + .unwrap() + .schema + .clone() + .filter(|schema| &schema.name == name)) + }) } fn list_schema_metadata( &self, @@ -264,6 +302,99 @@ mod tests { } } + #[tokio::test] + async fn destructive_batch_is_refused_before_safe_schema_or_revision_writes() { + let desired = [ + schema("schema Aaa { label: text }"), + schema("schema Note { title: text }"), + ]; + for command in [Command::Apply, Command::Migrate] { + let mut backend = Backend::seeded(schema("schema Note { title: text extra: text }")); + backend.revisions_supported = true; + let result = match command { + Command::Apply => { + super::super::apply::apply_to_backend( + &ApplyArgs { + paths: vec![], + dry_run: false, + force: false, + with_policies: false, + prepare_record_revisions: true, + }, + &desired, + &backend, + &output(), + ) + .await + } + Command::Migrate => { + super::super::migrate::migrate_on_backend( + &MigrateArgs { + paths: vec![], + execute: true, + force: false, + schema: None, + }, + &desired, + &backend, + &output(), + ) + .await + } + }; + assert!( + matches!(result, Err(CliError::RequiresForceBatch { .. })), + "{result:?}" + ); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.preparations, 0); + } + } + + #[test] + fn destructive_preflight_reports_every_schema_and_step() { + let original = schema("schema Note { title: text extra: text other: text }"); + let task = schema("schema Task { title: text obsolete: text }"); + let updates = [ + SchemaUpdate::plan(Some(&original), &schema("schema Note { title: text }")).unwrap(), + SchemaUpdate::plan(Some(&task), &schema("schema Task { title: text }")).unwrap(), + ]; + let error = preflight_destructive_batch(&updates, true, false, false).unwrap_err(); + assert_eq!( + error.exit_code() as i32, + CliError::RequiresForce.exit_code() as i32 + ); + let message = error.to_string(); + for part in [ + "Note", + "extra", + "other", + "Task", + "obsolete", + "no schemas were applied", + ] { + assert!(message.contains(part), "missing {part}: {message}"); + } + } + + #[test] + fn destructive_preflight_preserves_dry_run_force_and_interactive_modes() { + let original = schema("schema Note { title: text extra: text }"); + let updates = [ + SchemaUpdate::plan(Some(&original), &schema("schema Note { title: text }")).unwrap(), + ]; + for (execute, force, interactive) in [ + (false, false, false), + (true, true, false), + (true, false, true), + ] { + assert!(preflight_destructive_batch(&updates, execute, force, interactive).is_ok()); + } + assert!(preflight_destructive_batch(&updates, true, false, false).is_err()); + } + #[tokio::test] async fn tenancy_changes_never_write_even_with_force() { for force in [false, true] { @@ -354,7 +485,7 @@ mod tests { let backend = Backend::seeded(original.clone()); let result = command.run(&backend, schema(new), true).await; assert!( - matches!(result, Err(CliError::RequiresForce)), + matches!(result, Err(CliError::RequiresForceBatch { .. })), "{command:?}: {result:?}" ); let stored = backend.stored.lock().unwrap(); diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 50811d9d..f890c1a7 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -74,6 +74,17 @@ pub async fn run( Err(e) => return Err(e), }; + for schema in &schemas { + schema_forge_acton::webhook::validate_schema_webhooks( + schema, + &svc_config.custom.schema_forge.webhooks, + ) + .await + .map_err(|error| CliError::Config { + message: format!("invalid webhook on {}: {error}", schema.name), + })?; + } + // 4. Connect to database (try remote, fail explicitly for production) let connected = connect_with_retries(&db_params, output).await?; let backend_arc = connected.backend.clone(); @@ -321,8 +332,12 @@ pub async fn run( // fields here. Database/SurrealDB sections are not touched here — they // were resolved up-front by `load_svc_config` so acton-service's pool // and the schema-forge backend pool see the same URL by construction. - svc_config.service.bind = args.host; - svc_config.service.port = args.port; + if let Some(host) = args.host { + svc_config.service.bind = host; + } + if let Some(port) = args.port { + svc_config.service.port = port; + } svc_config.service.name = "schemaforge".to_string(); // Token auth public paths: both endpoints must be reachable without a diff --git a/crates/schema-forge-cli/src/commands/site/branding.rs b/crates/schema-forge-cli/src/commands/site/branding.rs new file mode 100644 index 00000000..91ab09c1 --- /dev/null +++ b/crates/schema-forge-cli/src/commands/site/branding.rs @@ -0,0 +1,127 @@ +//! Resolve product identity independently of output directory names. + +use std::path::Path; + +use heck::{ToKebabCase, ToTitleCase}; +use schema_forge_acton::config::SiteBrandingConfig; +use schema_forge_core::types::{Annotation, SchemaDefinition, TenantKind}; +use serde::Serialize; + +use crate::cli::{GlobalOpts, SiteGenerateArgs}; +use crate::error::CliError; + +#[derive(Debug, Clone, Serialize)] +pub struct Branding { + pub name: String, + pub name_json: String, + pub title_suffix_json: String, + pub theme_key_json: String, + pub logo: Option, + pub logo_on_dark: Option, + pub favicon: Option, +} + +impl Branding { + pub fn resolve( + args: &SiteGenerateArgs, + config: &SiteBrandingConfig, + global: &GlobalOpts, + schemas: &[SchemaDefinition], + ) -> Result { + let name = args + .name + .clone() + .or_else(|| config.name.clone()) + .unwrap_or_else(|| { + schemas + .iter() + .find(|schema| { + schema.annotations.iter().any(|annotation| { + matches!(annotation, Annotation::Tenant(TenantKind::Root)) + }) + }) + .map(|schema| schema.name.as_str().to_title_case()) + .or_else(|| { + args.schema_dir.canonicalize().ok().and_then(|path| { + path.parent() + .and_then(Path::file_name) + .map(|name| name.to_string_lossy().to_title_case()) + }) + }) + .unwrap_or_else(|| "Application".into()) + }); + if name.trim().is_empty() || name.chars().any(char::is_control) { + return Err(CliError::Config { + message: "site name must be nonempty and contain no control characters".into(), + }); + } + let title_suffix = args + .title_suffix + .as_ref() + .or(config.title_suffix.as_ref()) + .unwrap_or(&name); + let config_base = global + .config + .as_deref() + .and_then(Path::parent) + .unwrap_or(Path::new(".")); + let logo = read_asset(args.logo.as_deref(), config.logo.as_deref(), config_base)?; + let logo_on_dark = read_asset( + args.logo_on_dark.as_deref(), + config.logo_on_dark.as_deref(), + config_base, + )? + .or_else(|| logo.clone()); + let favicon = read_asset( + args.favicon.as_deref(), + config.favicon.as_deref(), + config_base, + )? + .or_else(|| logo.clone()); + let theme_key = format!("{}.theme", name.to_kebab_case()); + Ok(Self { + name_json: json_string(&name)?, + title_suffix_json: json_string(title_suffix)?, + theme_key_json: json_string(&theme_key)?, + name, + logo, + logo_on_dark, + favicon, + }) + } +} + +fn json_string(value: &str) -> Result { + serde_json::to_string(value).map_err(|error| CliError::Config { + message: format!("failed to encode site branding: {error}"), + }) +} + +fn read_asset( + flag: Option<&Path>, + configured: Option<&Path>, + config_base: &Path, +) -> Result, CliError> { + let Some(path) = flag + .map(Path::to_path_buf) + .or_else(|| configured.map(|path| config_base.join(path))) + else { + return Ok(None); + }; + if !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + { + return Err(CliError::Config { + message: format!("site branding asset {} must be an SVG file", path.display()), + }); + } + let contents = + std::fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?; + if !contents.contains(" String { /// `entity` without reaching through the list. #[derive(Debug, Clone, Serialize)] pub struct SiteContext { + pub branding: super::branding::Branding, /// Kebab-cased project name (for `package.json`, ``, etc.). pub project_name: String, /// Every non-system schema, projected into a generator-friendly view. @@ -134,6 +135,8 @@ pub struct EntityView { pub schema_name: String, /// v0-supported fields only. Unsupported fields are dropped with a stderr warning. pub fields: Vec<FieldView>, + /// Exact browser normalization contract, without display/template metadata. + pub form_fields: Vec<FormFieldSpec>, /// The field nominated by `@display("...")`, if any. Used for /// breadcrumbs and list-view "headline" rendering. pub display_field: Option<String>, @@ -161,6 +164,10 @@ pub struct EntityView { /// components when the entity actually has a file field, otherwise /// `noUnusedLocals` rejects the generated file. pub has_file_field: bool, + /// Writable file controls require an entity identifier. + pub has_form_file_field: bool, + pub has_form_fields: bool, + pub has_form_controls: bool, } impl EntityView { @@ -179,8 +186,11 @@ impl EntityView { match field_to_view(f, catalog) { Ok(v) => fields.push(v), Err(FieldMapError::Unsupported { field, reason }) => { + if f.is_required() { + return Err(CliError::Config { message: format!("cannot generate a usable create form: required field {name}.{field} is unsupported: {reason}") }); + } output.warn(&format!( - "site v0: skipping field `{name}.{field}` — {reason}" + "site: skipping optional field `{name}.{field}`: {reason}" )); } } @@ -192,6 +202,7 @@ impl EntityView { }); let has_json_field = fields.iter().any(|f| f.kind == "json"); let has_file_field = fields.iter().any(|f| f.kind == "file"); + let has_form_file_field = fields.iter().any(has_form_file); let display_field = def.display_field().map(|s| s.to_string()); // `@display("field")` auto-promotes to `primary` when no explicit @@ -207,6 +218,8 @@ impl EntityView { } } + let has_form_fields = fields.iter().any(|field| !field.computed && !field.derived); + let has_form_controls = fields.iter().any(has_form_control); let pascal = name.to_pascal_case(); Ok(Self { pascal_plural: pluralize(&pascal), @@ -215,20 +228,38 @@ impl EntityView { kebab: name.to_kebab_case(), title: name.to_title_case(), schema_name: name.to_string(), + form_fields: fields.iter().map(FormFieldSpec::from).collect(), fields, display_field, has_relation_one, has_relation_link, has_json_field, has_file_field, + has_form_file_field, + has_form_fields, + has_form_controls, }) } } +fn has_form_file(field: &FieldView) -> bool { + !field.computed + && !field.derived + && (field.kind == "file" || field.sub_fields.iter().any(has_form_file)) +} + +fn has_form_control(field: &FieldView) -> bool { + !field.computed + && !field.derived + && (field.kind != "composite" || field.sub_fields.iter().any(has_form_control)) +} + /// Recursive check: does this field or any nested composite sub-field /// contain a `relation_one`? fn has_relation_one_field(f: &FieldView) -> bool { - f.kind == "relation_one" || f.sub_fields.iter().any(has_relation_one_field) + !f.computed + && !f.derived + && (f.kind == "relation_one" || f.sub_fields.iter().any(has_relation_one_field)) } /// One schema field projected into a TS/Zod-aware view model. @@ -281,6 +312,8 @@ pub struct FieldView { /// For `kind == "composite"`: the flattened sub-fields, each with /// `name` set to its dot-path. Empty for non-composite fields. pub sub_fields: Vec<FieldView>, + /// Narrow browser contract for descendants. + pub form_sub_fields: Vec<FormFieldSpec>, /// For `kind == "enum"`: map from variant name to its `@enum_colors` /// color token (one of `neutral|gray|red|amber|green|blue|purple|violet|teal|rose`). /// Empty when the field carries no `@enum_colors` annotation; variants @@ -304,6 +337,13 @@ pub struct FieldView { /// the detail view. Reads already flow through the standard relation /// envelope, populated by the backend's inverse-collection pass. pub derived: bool, + /// Server-computed values are read-only and excluded from forms. + pub computed: bool, + /// A partial replacement would erase a descendant hidden by the schema. + pub has_hidden_children: bool, + /// Declarative role hints; Cedar remains authoritative. + pub read_roles: Vec<String>, + pub write_roles: Vec<String>, /// For `kind == "file"`: metadata the template needs to render the /// upload widget (accept attribute, max-size guard, proxied vs. presigned /// behavior). `None` for non-file fields. @@ -311,6 +351,42 @@ pub struct FieldView { pub file_meta: Option<FileMetaView>, } +/// Serialized contract consumed by the generated form normalizers. Keep this +/// shape aligned with FormFieldSpec in zod-schemas.ts; display hints and Zod +/// source expressions never belong in browser form metadata. +#[derive(Debug, Clone, Serialize)] +pub struct FormFieldSpec { + pub leaf: String, + pub name: String, + pub kind: String, + pub item_kind: Option<String>, + pub required: bool, + pub computed: bool, + pub has_hidden_children: bool, + pub derived: bool, + pub read_roles: Vec<String>, + pub write_roles: Vec<String>, + pub sub_fields: Vec<FormFieldSpec>, +} + +impl From<&FieldView> for FormFieldSpec { + fn from(field: &FieldView) -> Self { + Self { + leaf: field.leaf.clone(), + name: field.name.clone(), + kind: field.kind.clone(), + item_kind: field.item_kind.clone(), + required: field.required, + computed: field.computed, + has_hidden_children: field.has_hidden_children, + derived: field.derived, + read_roles: field.read_roles.clone(), + write_roles: field.write_roles.clone(), + sub_fields: field.form_sub_fields.clone(), + } + } +} + /// File-field metadata projected to the site template layer. #[derive(Debug, Clone, Serialize)] pub struct FileMetaView { @@ -373,6 +449,7 @@ pub fn make_field_view( item_kind: None, item_enum_variants: Vec::new(), sub_fields: Vec::new(), + form_sub_fields: Vec::new(), file_meta: None, enum_colors: field .enum_colors() @@ -390,6 +467,39 @@ pub fn make_field_view( None => default_list_placement(kind).to_string(), }, derived: field.is_derived(), + has_hidden_children: has_hidden_descendants(&field.field_type), + computed: field.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::FieldAnnotation::Compute { .. } + ) + }), + read_roles: match field.field_access() { + Some(schema_forge_core::types::FieldAnnotation::FieldAccess { read, .. }) => { + read.clone() + } + _ => Vec::new(), + }, + write_roles: match field.field_access() { + Some(schema_forge_core::types::FieldAnnotation::FieldAccess { write, .. }) => { + write.clone() + } + _ => Vec::new(), + }, + } +} + +/// Inspect original definitions before hidden fields are removed from the view. +fn has_hidden_descendants(field_type: &schema_forge_core::types::FieldType) -> bool { + use schema_forge_core::types::FieldType; + match field_type { + FieldType::Composite(fields) => fields + .iter() + .any(|field| field.is_hidden() || has_hidden_descendants(&field.field_type)), + FieldType::Array(inner) | FieldType::Map { value: inner, .. } => { + has_hidden_descendants(inner) + } + _ => false, } } diff --git a/crates/schema-forge-cli/src/commands/site/mapping.rs b/crates/schema-forge-cli/src/commands/site/mapping.rs index 8c1adcfe..4c9f4345 100644 --- a/crates/schema-forge-cli/src/commands/site/mapping.rs +++ b/crates/schema-forge-cli/src/commands/site/mapping.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use schema_forge_core::types::{Cardinality, FieldDefinition, FieldType}; -use super::context::{make_field_view, FieldView, FileMetaView, SchemaMeta}; +use super::context::{make_field_view, FieldView, FileMetaView, FormFieldSpec, SchemaMeta}; /// Reason a field could not be projected into the v0 site view model. #[derive(Debug, Clone)] @@ -64,6 +64,47 @@ fn field_to_view_with_prefix( Vec::new(), )) } + FieldType::Duration => { + let zod = optional_form_zod("durationSchema".into(), required); + Ok(make_field_view( + field, + "string".into(), + zod, + "duration", + false, + None, + Vec::new(), + )) + } + FieldType::Bytes(constraints) => { + let mut zod = "base64Schema".to_string(); + if let Some(max) = constraints.max_size { + zod.push_str(&format!( + ".refine(value => decodedBase64Size(value) <= {max}, \"Maximum {max} bytes\")" + )); + } + Ok(make_field_view( + field, + "string".into(), + optional_form_zod(zod, required), + "bytes", + false, + None, + Vec::new(), + )) + } + FieldType::Map { value, .. } => { + let validator = format!("jsonTextSchema(z.record({}))", wire_zod(value)); + Ok(make_field_view( + field, + format!("Record<string, {}>", ts_type_for_field_type(value)), + optional_form_zod(validator, required), + "json", + false, + None, + Vec::new(), + )) + } FieldType::Integer(_) => { let mut zod = "z.coerce.number().int()".to_string(); if !required { @@ -295,7 +336,17 @@ fn field_to_view_with_prefix( for sv in &sub_fields { let opt = if sv.required { "" } else { "?" }; ts_parts.push(format!("{}{}: {}", sv.leaf, opt, sv.ts_type)); - zod_parts.push(format!("{}: {}", sv.leaf, sv.zod)); + if !sv.derived && !sv.computed { + zod_parts.push(format!( + "{}: formFieldSchema({}, {}, {}, {}, {})", + sv.leaf, + sv.zod, + serde_json::to_string(&sv.read_roles).unwrap_or_default(), + serde_json::to_string(&sv.write_roles).unwrap_or_default(), + serde_json::to_string(&sv.form_sub_fields).unwrap_or_default(), + sv.has_hidden_children + )); + } } let ts_type = format!("{{ {} }}", ts_parts.join(", ")); let mut zod = format!("z.object({{ {} }})", zod_parts.join(", ")); @@ -341,8 +392,7 @@ fn field_to_view_with_prefix( mime_allowlist, access: constraints.access.as_str().to_string(), }; - let mut view = - make_field_view(field, ts_type, zod, "file", false, None, Vec::new()); + let mut view = make_field_view(field, ts_type, zod, "file", false, None, Vec::new()); view.file_meta = Some(file_meta); Ok(view) } @@ -355,10 +405,57 @@ fn field_to_view_with_prefix( if !prefix.is_empty() && v.kind != "composite" { v.name = format!("{prefix}.{}", v.leaf); } + v.form_sub_fields = v.sub_fields.iter().map(FormFieldSpec::from).collect(); v }) } +/// Optional text controls treat a blank input as absent before validation. +fn optional_form_zod(zod: String, required: bool) -> String { + if required { + zod + } else { + format!("z.preprocess(value => value === \"\" ? undefined : value, {zod}.nullish())") + } +} + +/// JSON wire validators used for homogeneous map values. +fn wire_zod(field_type: &FieldType) -> String { + match field_type { + FieldType::Text(_) + | FieldType::RichText + | FieldType::DateTime + | FieldType::Relation { .. } => "z.string()".into(), + FieldType::Duration => "durationSchema".into(), + FieldType::Bytes(_) => "base64Schema".into(), + FieldType::Integer(_) => "z.number().int()".into(), + FieldType::Float(_) => "z.number()".into(), + FieldType::Boolean => "z.boolean()".into(), + FieldType::Enum(variants) => format!( + "z.enum({} as [string, ...string[]])", + serde_json::to_string(variants.as_slice()).unwrap_or_default() + ), + FieldType::Array(inner) => format!("z.array({})", wire_zod(inner)), + FieldType::Map { value, .. } => format!("z.record({})", wire_zod(value)), + FieldType::Composite(fields) => { + let entries: Vec<_> = fields + .iter() + .filter(|field| !field.is_hidden()) + .map(|field| { + let optional = if field.is_required() { + "" + } else { + ".nullish()" + }; + format!("{}: {}{optional}", field.name, wire_zod(&field.field_type)) + }) + .collect(); + format!("z.object({{ {} }})", entries.join(", ")) + } + _ => "z.unknown()".into(), + } +} + /// Format a byte count as a short human-readable string for template labels. /// /// Uses base-1024 (matching the DSL parser) and always drops unnecessary @@ -414,20 +511,26 @@ fn with_relation_metadata( /// `unknown` since the JSON wire shape is opaque to the generator. fn ts_type_for_field_type(ft: &FieldType) -> String { match ft { - FieldType::Text(_) | FieldType::RichText | FieldType::DateTime => "string".to_string(), + FieldType::Text(_) + | FieldType::RichText + | FieldType::DateTime + | FieldType::Duration + | FieldType::Bytes(_) => "string".to_string(), FieldType::Integer(_) | FieldType::Float(_) => "number".to_string(), FieldType::Boolean => "boolean".to_string(), FieldType::Enum(v) => { - let parts: Vec<String> = - v.as_slice().iter().map(|s| format!("\"{s}\"")).collect(); + let parts: Vec<String> = v.as_slice().iter().map(|s| format!("\"{s}\"")).collect(); format!("({})", parts.join(" | ")) } FieldType::Json => "unknown".to_string(), FieldType::Relation { .. } => "string".to_string(), FieldType::Array(inner) => format!("{}[]", ts_type_for_field_type(inner)), + FieldType::Map { value, .. } => { + format!("Record<string, {}>", ts_type_for_field_type(value)) + } FieldType::Composite(sub_defs) => { let mut parts = Vec::with_capacity(sub_defs.len()); - for sub in sub_defs { + for sub in sub_defs.iter().filter(|field| !field.is_hidden()) { let opt = if sub.is_required() { "" } else { "?" }; parts.push(format!( "{}{opt}: {}", @@ -508,6 +611,107 @@ mod tests { field_to_view(field, &empty_catalog()) } + #[test] + fn form_metadata_has_only_the_browser_contract_at_every_depth() { + let schemas = schema_forge_dsl::parse("schema Job { settings: composite { visible: text nested: composite { delay: duration } } }").unwrap(); + let field = project(&schemas[0].fields[0]).unwrap(); + let spec = serde_json::to_value(FormFieldSpec::from(&field)).unwrap(); + let expected = [ + "leaf", + "name", + "kind", + "item_kind", + "required", + "computed", + "has_hidden_children", + "derived", + "read_roles", + "write_roles", + "sub_fields", + ]; + fn verify(spec: &serde_json::Value, expected: &[&str]) { + let object = spec.as_object().unwrap(); + assert_eq!(object.len(), expected.len()); + for key in expected { + assert!(object.contains_key(*key), "missing {key}"); + } + for child in spec["sub_fields"].as_array().unwrap() { + verify(child, expected); + } + } + verify(&spec, &expected); + assert_eq!( + spec["sub_fields"][1]["sub_fields"][0]["name"], + "settings.nested.delay" + ); + } + + #[test] + fn hidden_descendants_protect_ancestors_without_exposing_hidden_metadata() { + let schemas = schema_forge_dsl::parse(r#"schema Job { + settings: composite { visible: text nested: composite { secret_storage: text @hidden public_note: text } } + }"#).unwrap(); + let settings = project(&schemas[0].fields[0]).unwrap(); + assert!(settings.has_hidden_children); + let nested = settings + .sub_fields + .iter() + .find(|field| field.leaf == "nested") + .unwrap(); + assert!(nested.has_hidden_children); + let serialized = serde_json::to_string(&settings).unwrap(); + assert!(!serialized.contains("secret_storage")); + assert!(serialized.contains("public_note")); + } + + #[test] + fn duration_bytes_and_maps_have_complete_form_and_read_types() { + use schema_forge_core::types::BytesConstraints; + let duration = project(&field("timeout", FieldType::Duration, true)).unwrap(); + assert_eq!(duration.kind, "duration"); + assert_eq!(duration.ts_type, "string"); + assert_eq!(duration.zod, "durationSchema"); + let bytes = project(&field( + "checksum", + FieldType::Bytes(BytesConstraints::with_max_size(32)), + true, + )) + .unwrap(); + assert_eq!(bytes.kind, "bytes"); + assert_eq!(bytes.ts_type, "string"); + assert!(bytes.zod.contains("decodedBase64Size(value) <= 32")); + let schemas = schema_forge_dsl::parse("schema Job { labels: map<text, integer> }").unwrap(); + let map = project(&schemas[0].fields[0]).unwrap(); + assert_eq!(map.kind, "json"); + assert_eq!(map.ts_type, "Record<string, number>"); + assert!(map + .zod + .contains("jsonTextSchema(z.record(z.number().int()))")); + let array = project(&field( + "delays", + FieldType::Array(Box::new(FieldType::Duration)), + true, + )) + .unwrap(); + assert_eq!(array.ts_type, "string[]"); + } + + #[test] + fn computed_and_field_roles_are_projected_into_form_authority() { + let schemas = schema_forge_dsl::parse( + r#"schema Invoice { + tax: float @compute("1.0") + secret: text @field_access(read: ["finance"], write: ["lead"]) + }"#, + ) + .unwrap(); + let computed = project(&schemas[0].fields[0]).unwrap(); + assert!(computed.computed); + let restricted = project(&schemas[0].fields[1]).unwrap(); + assert_eq!(restricted.read_roles, vec!["finance"]); + assert_eq!(restricted.write_roles, vec!["lead"]); + } + #[test] fn text_with_max_required() { let v = project(&field( @@ -804,7 +1008,7 @@ mod tests { assert_eq!(v.sub_fields[0].ts_type, "boolean[][]"); } -#[test] + #[test] fn derived_relation_many_marks_view_as_derived() { let mut fd = field( "documents", diff --git a/crates/schema-forge-cli/src/commands/site/mod.rs b/crates/schema-forge-cli/src/commands/site/mod.rs index 0c905bc8..ad870789 100644 --- a/crates/schema-forge-cli/src/commands/site/mod.rs +++ b/crates/schema-forge-cli/src/commands/site/mod.rs @@ -6,6 +6,7 @@ //! route manifest) and per-entity pages. `--schema NAME` narrows generation //! to a single schema for debugging or partial regen. +mod branding; mod context; mod mapping; mod render; @@ -70,12 +71,21 @@ fn generate( output.status(&format!(" target: {}", def.name.as_str())); } - let project_name = args - .out_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("schema-forge-site") - .to_kebab_case(); + let config = crate::config::load_svc_config(global)?; + let branding = + branding::Branding::resolve(&args, &config.custom.schema_forge.site, global, &schemas)?; + let slug: String = branding + .name + .to_kebab_case() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-') + .collect(); + let slug = slug.trim_matches('-').to_string(); + let project_name = if slug.is_empty() { + "application".into() + } else { + slug + }; // Build a catalog of every known schema so mapping can resolve // relation targets (display field, kebab slug) even when the target @@ -111,6 +121,7 @@ fn generate( } let ctx = SiteContext { + branding, project_name: project_name.clone(), entities, accessibility_contact: args.accessibility_contact.clone(), @@ -255,7 +266,10 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result<Vec<FilePlan vendor::TSCONFIG_NODE_JSON.to_string(), )); plan.push(owned(".gitignore", vendor::GITIGNORE.to_string())); - plan.push(owned("eslint.config.js", vendor::ESLINT_CONFIG_JS.to_string())); + plan.push(owned( + "eslint.config.js", + vendor::ESLINT_CONFIG_JS.to_string(), + )); // Brand marks. Vite serves `public/` at the URL root, so the templates // can reference `/logo-mark-white.svg` and `/logo-mark.svg` directly @@ -263,17 +277,41 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result<Vec<FilePlan // and login left panel; the ink mark is the favicon. plan.push(owned( "public/logo-mark-white.svg", - vendor::LOGO_MARK_WHITE_SVG.to_string(), + ctx.branding + .logo_on_dark + .clone() + .map(Ok) + .unwrap_or_else(|| renderer.render("public/logo-mark-white.svg", ctx))?, )); plan.push(owned( "public/logo-mark.svg", - vendor::LOGO_MARK_INK_SVG.to_string(), + ctx.branding + .logo + .clone() + .map(Ok) + .unwrap_or_else(|| renderer.render("public/logo-mark.svg", ctx))?, + )); + + plan.push(owned( + "public/favicon.svg", + ctx.branding + .favicon + .clone() + .map(Ok) + .unwrap_or_else(|| renderer.render("public/favicon.svg", ctx))?, )); // ---- src/ scaffolding ---- plan.push(owned("src/main.tsx", renderer.render("src/main.tsx", ctx)?)); plan.push(owned("src/App.tsx", renderer.render("src/App.tsx", ctx)?)); - plan.push(owned("src/index.css", vendor::INDEX_CSS.to_string())); + plan.push(owned( + "src/index.css", + renderer.render("src/index.css", ctx)?, + )); + plan.push(owned( + "src/lib/branding.ts", + renderer.render("src/lib/branding.ts", ctx)?, + )); plan.push(owned( "src/lib/utils.ts", vendor::SHADCN_UTILS_TS.to_string(), @@ -288,7 +326,12 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result<Vec<FilePlan )); plan.push(owned( "src/lib/use-document-title.ts", - vendor::USE_DOCUMENT_TITLE.to_string(), + renderer.render("src/lib/use-document-title.ts", ctx)?, + )); + + plan.push(preserve( + "src/lib/error-toast.ts", + renderer.render("src/lib/error-toast.ts", ctx)?, )); // ---- shadcn primitives (vendored, owned, unmodified) ---- @@ -517,12 +560,8 @@ mod tests { FieldDefinition::with_annotations( FieldName::new("stage").unwrap(), FieldType::Enum( - EnumVariants::new(vec![ - "qualifying".into(), - "won".into(), - "lost".into(), - ]) - .unwrap(), + EnumVariants::new(vec!["qualifying".into(), "won".into(), "lost".into()]) + .unwrap(), ), vec![FieldModifier::Required], vec![FieldAnnotation::EnumColors { colors }], @@ -540,10 +579,7 @@ mod tests { let schema = opportunity_schema_with_enum_colors(); let mut catalog = BTreeMap::new(); - catalog.insert( - "Opportunity".to_string(), - SchemaMeta::from_schema(&schema), - ); + catalog.insert("Opportunity".to_string(), SchemaMeta::from_schema(&schema)); let output = crate::output::OutputContext { mode: crate::output::OutputMode::Plain, verbose: 0, @@ -595,19 +631,14 @@ mod tests { // Explicit column hint. FieldDefinition::with_annotations( FieldName::new("stage").unwrap(), - FieldType::Enum( - EnumVariants::new(vec!["new".into(), "won".into()]).unwrap(), - ), + FieldType::Enum(EnumVariants::new(vec!["new".into(), "won".into()]).unwrap()), vec![FieldModifier::Required], vec![FieldAnnotation::List { hint: ListHint::Column, }], ), // Rich text auto-hides by default. - FieldDefinition::new( - FieldName::new("description").unwrap(), - FieldType::RichText, - ), + FieldDefinition::new(FieldName::new("description").unwrap(), FieldType::RichText), // Unannotated integer -> column. FieldDefinition::new( FieldName::new("pwin").unwrap(), @@ -808,8 +839,7 @@ mod tests { "FileUpload meta must carry the byte limit" ); assert!( - rendered.contains("\"application/pdf\"") - && rendered.contains("\"image/*\""), + rendered.contains("\"application/pdf\"") && rendered.contains("\"image/*\""), "FileUpload meta must carry the mime allowlist" ); @@ -819,12 +849,20 @@ mod tests { "FormFields must accept entityId for file-bearing entities" ); - // Entity update payload must NOT carry the file attachment — uploads - // go through the dedicated 3-endpoint flow, not entity PUT. - assert!( - rendered.contains(r#"delete payload["attachment"]"#), - "normalize<Pascal>Payload must strip file fields from entity PUT body" - ); + // Entity updates delegate to the shared normalizer, which excludes + // file fields handled by the dedicated upload endpoints. + assert!(rendered.contains("return normalizeFormPayload(")); + assert!(document_entity() + .form_fields + .iter() + .any(|field| field.leaf == "attachment" && field.kind == "file")); + let validators = include_str!("../../../templates/site/src/generated/zod-schemas.ts.jinja"); + let payload_normalizer = validators + .split("export function normalizeFormPayload(") + .nth(1) + .expect("shared payload normalizer must exist"); + assert!(payload_normalizer.contains(r#"field.kind === "file""#)); + assert!(payload_normalizer.contains("continue")); // The pre-fix stub must be gone. assert!( diff --git a/crates/schema-forge-cli/src/commands/site/render.rs b/crates/schema-forge-cli/src/commands/site/render.rs index f7d8ba9e..d83e8974 100644 --- a/crates/schema-forge-cli/src/commands/site/render.rs +++ b/crates/schema-forge-cli/src/commands/site/render.rs @@ -7,13 +7,9 @@ //! 2. Fall back to the slice baked into the binary at build time //! (`EMBEDDED_SITE_TEMPLATES`, emitted by `build.rs`). //! -//! This lets framework users iterate on generator output without a CLI -//! rebuild: drop an override tree next to your schemas, tweak `.jinja` -//! files, re-run `schema-forge site generate`, and only the overridden -//! files swap — every other template still comes from the embedded -//! defaults. When the overrides look right, copy them back into -//! `crates/schema-forge-cli/templates/site/` and they become the new -//! baked-in default. +//! Projects can keep persistent customizations in this override tree. Overrides +//! participate in generation and drift checking, including CSS, title helpers, +//! and brand SVGs that otherwise use embedded vendor defaults. //! //! Logical template names (`"src/App.tsx"`, `"package.json"`, …) are the //! post-`.jinja`-strip relative paths, which is also the final output @@ -38,17 +34,24 @@ impl SiteRenderer { /// /// If `override_dir` is `Some`, the loader checks that directory for /// `<logical_name>.jinja` before falling back to the embedded defaults. - /// Read errors on an override file are treated as "not overridden" and - /// silently fall through to the embedded template. + /// Unreadable override files fail generation rather than silently falling back. pub fn new(override_dir: Option<PathBuf>) -> Result<Self, CliError> { let mut env = Environment::new(); env.set_loader(move |name: &str| { if let Some(ref dir) = override_dir { let candidate = dir.join(format!("{name}.jinja")); if candidate.is_file() { - if let Ok(content) = std::fs::read_to_string(&candidate) { - return Ok(Some(content)); - } + return std::fs::read_to_string(&candidate) + .map(Some) + .map_err(|error| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + format!( + "cannot read template override {}: {error}", + candidate.display() + ), + ) + }); } } for (logical, content) in EMBEDDED_SITE_TEMPLATES { @@ -56,7 +59,16 @@ impl SiteRenderer { return Ok(Some((*content).to_string())); } } - Ok(None) + let vendor = match name { + "public/logo-mark.svg" | "public/favicon.svg" => { + Some(super::vendor::LOGO_MARK_INK_SVG) + } + "public/logo-mark-white.svg" => Some(super::vendor::LOGO_MARK_WHITE_SVG), + "src/index.css" => Some(super::vendor::INDEX_CSS), + "src/lib/use-document-title.ts" => Some(super::vendor::USE_DOCUMENT_TITLE), + _ => None, + }; + Ok(vendor.map(str::to_string)) }); Ok(Self { env }) } diff --git a/crates/schema-forge-cli/src/commands/site/vendor.rs b/crates/schema-forge-cli/src/commands/site/vendor.rs index a6e94ffb..460b8490 100644 --- a/crates/schema-forge-cli/src/commands/site/vendor.rs +++ b/crates/schema-forge-cli/src/commands/site/vendor.rs @@ -718,61 +718,25 @@ dist .DS_Store "#; -/// Govcraft brand mark, white fill — used on the inked sidebar rail and -/// the dark login left panel. Vendored from the Govcraft DS (paths only, -/// no rasterized stroke). Vite's `public/` dir serves these at the URL -/// root, so the React templates can `<img src="/logo-mark-white.svg" />` -/// without bundler involvement. -pub const LOGO_MARK_WHITE_SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg version="1.1" id="svg1" width="886.4729" height="720.97095" viewBox="0 0 886.47289 720.97095" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - <defs id="defs1"></defs> - <g id="layer-MC0" transform="translate(9.3134156e-4,-75.917999)"> - <path id="path1" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.4512,284.1136)"></path> - <path id="path2" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.45107,400.17613)"></path> - <path id="path3" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,474.93093,400.17613)"></path> - <path id="path4" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.4108,400.17627)"></path> - <path id="path5" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.68787,515.8356)"></path> - <path id="path6" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.68787,631.89813)"></path> - <path id="path7" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,475.24653,631.89827)"></path> - <path id="path8" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,543.4328,631.89827)"></path> - <path id="path9" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.80533,631.89827)"></path> - <path id="path10" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.80533,515.83573)"></path> - <path id="path11" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,543.19613,284.1136)"></path> - </g> -</svg> +/// Neutral four-square application mark on dark surfaces. +pub const LOGO_MARK_WHITE_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#fff" d="M2 2h12v12H2zM18 2h12v12H18zM2 18h12v12H2zM18 18h12v12H18z"/></svg> "##; -/// Govcraft brand mark, ink fill — used as the favicon and on light surfaces. -pub const LOGO_MARK_INK_SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg version="1.1" id="svg1" width="886.4729" height="720.97095" viewBox="0 0 886.47289 720.97095" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - <defs id="defs1"></defs> - <g id="layer-MC0" transform="translate(9.3134156e-4,-75.917999)"> - <path id="path1" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.4512,284.1136)"></path> - <path id="path2" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.45107,400.17613)"></path> - <path id="path3" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,474.93093,400.17613)"></path> - <path id="path4" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.4108,400.17627)"></path> - <path id="path5" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.68787,515.8356)"></path> - <path id="path6" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,270.68787,631.89813)"></path> - <path id="path7" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,475.24653,631.89827)"></path> - <path id="path8" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,543.4328,631.89827)"></path> - <path id="path9" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.80533,631.89827)"></path> - <path id="path10" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,679.80533,515.83573)"></path> - <path id="path11" d="M 0,0 H -46.026 V 78.342 H 0 Z" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,-1.3333333,543.19613,284.1136)"></path> - </g> -</svg> +/// Neutral application mark on light surfaces. +pub const LOGO_MARK_INK_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#171717" d="M2 2h12v12H2zM18 2h12v12H18zM2 18h12v12H2zM18 18h12v12H18z"/></svg> "##; pub const INDEX_CSS: &str = r#"@import "tailwindcss"; /* ========================================================= - SchemaForge generated-site baseline — Govcraft Design System + Application design tokens Two type families (IBM Plex Sans + Mono), one signal accent (Signal Orange), paper ground + ink in light, console in dark. - shadcn primitive tokens are rebound onto these Govcraft tokens + shadcn primitive tokens are rebound onto these application tokens so Button / Input / Card render in-brand without changes. ========================================================= */ -/* ---- Govcraft palette + DS tokens (light is default) ---- */ +/* ---- Application palette and tokens (light is default) ---- */ :root { --gc-ink: #0A0A0A; --gc-ink-2: #1F1F1F; @@ -870,7 +834,7 @@ pub const INDEX_CSS: &str = r#"@import "tailwindcss"; --link: #91A8C8; } -/* ---- Bind shadcn v4 tokens onto Govcraft DS so primitives render in-brand ---- */ +/* ---- Bind shadcn v4 tokens onto application tokens so primitives render in-brand ---- */ @theme inline { --color-background: var(--app-bg); --color-foreground: var(--app-fg-1); @@ -1646,6 +1610,7 @@ table.tbl .row-actions:focus-within { opacity: 1; } /// so failed loads don't require a full-page reload. pub const ERROR_BLOCK: &str = r#"// Generated by schema-forge — edit freely. import type { ReactNode } from "react" +import { formatApiError } from "@/generated/api-client" import { Button } from "@/components/ui/button" type ErrorBlockProps = { @@ -1665,7 +1630,7 @@ export function ErrorBlock({ onRetry, children, }: ErrorBlockProps) { - const message = error instanceof Error ? error.message : String(error) + const message = formatApiError(error) return ( <div role="alert" @@ -1718,7 +1683,7 @@ pub const FILE_UPLOAD: &str = r##"// Generated by schema-forge — edit freely. // React Hook Form `<FormField>`. `AttachmentDownload` renders the read-only // view used on detail pages, honoring the field's `access` mode. import { useState } from "react" -import { tokenStore } from "@/lib/auth" +import { authenticatedHeaders } from "@/lib/auth" const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "" const FORGE_API_PREFIX = "/api/v1/forge" @@ -1772,8 +1737,7 @@ function entityUrlOrNull(schema: string, entityId: string | undefined): string | } function authHeaders(): Record<string, string> { - const token = tokenStore.get() - return token ? { Authorization: `Bearer ${token}` } : {} + return Object.fromEntries(authenticatedHeaders()) } async function safeBody(res: Response): Promise<string> { @@ -1970,8 +1934,8 @@ type AttachmentDownloadProps = { * short-TTL URL, then opens it in a new tab. Avoids the auto-302 path so * the bearer token can be attached to the metadata fetch (browsers strip * `Authorization` across cross-origin redirects to S3). - * * `proxied` — links the field endpoint directly. The runtime streams - * bytes through the daemon with auth applied; no extra round trip needed. + * * `proxied` — fetches bytes with session and tenant headers, then starts + * a browser download from a temporary object URL. */ export function AttachmentDownload({ schema, @@ -1990,16 +1954,32 @@ export function AttachmentDownload({ schema, )}/entities/${encodeURIComponent(entityId)}/fields/${encodeURIComponent(fieldName)}` - async function openPresigned(e: React.MouseEvent) { + async function openAttachment(e: React.MouseEvent) { e.preventDefault() if (!available || busy) return setBusy(true) setErr(null) try { - const res = await fetch(`${fieldUrl}?redirect=false`, { headers: authHeaders() }) - if (!res.ok) throw new Error(`download failed: ${res.status}`) - const body = (await res.json()) as { url: string } - window.open(body.url, "_blank", "noopener,noreferrer") + const url = access === "proxied" ? fieldUrl : `${fieldUrl}?redirect=false` + const res = await fetch(url, { headers: authHeaders() }) + if (!res.ok) throw new Error(`Unable to download this file (HTTP ${res.status}).`) + if (access === "proxied") { + const blobUrl = URL.createObjectURL(await res.blob()) + const link = document.createElement("a") + link.href = blobUrl + link.download = downloadFilename(res.headers.get("Content-Disposition"), filename) + try { + document.body.appendChild(link) + link.click() + } finally { + link.remove() + // Give the browser time to start consuming the blob before releasing it. + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000) + } + } else { + const body = (await res.json()) as { url: string } + window.open(body.url, "_blank", "noopener,noreferrer") + } } catch (e2) { setErr(e2 instanceof Error ? e2.message : String(e2)) } finally { @@ -2007,38 +1987,18 @@ export function AttachmentDownload({ } } - // Proxied access: link the field endpoint directly. The runtime applies - // auth via the bearer cookie / header chain on the daemon side and streams - // the response. - if (access === "proxied") { - return ( - <a - href={available ? fieldUrl : "#"} - target="_blank" - rel="noopener noreferrer" - aria-disabled={!available} - className={compact ? "text-xs underline" : "btn"} - style={!available ? { opacity: 0.5, pointerEvents: "none" } : undefined} - > - {compact ? "Download" : `Download ${filename}`} - <span className="sr-only"> (opens in a new tab)</span> - </a> - ) - } - - // Presigned access: round-trip through ?redirect=false so we can attach - // the bearer token on the metadata fetch. + // Both modes need a fetch first so tenant selection and bearer auth apply. return ( <span style={{ display: "inline-flex", alignItems: "baseline", gap: 8 }}> <button type="button" - onClick={openPresigned} + onClick={openAttachment} disabled={!available || busy} className={compact ? "text-xs underline" : "btn"} style={!available || busy ? { opacity: 0.5 } : undefined} > {busy ? "Opening…" : compact ? "Download" : `Download ${filename}`} - <span className="sr-only"> (opens in a new tab)</span> + <span className="sr-only">{access === "proxied" ? " (downloads file)" : " (opens in a new tab)"}</span> </button> {err ? ( <span role="alert" aria-live="assertive" className="err"> @@ -2050,6 +2010,19 @@ export function AttachmentDownload({ ) } +function downloadFilename(disposition: string | null, fallback: string): string { + const extended = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + let name: string | undefined + if (extended) { + try { name = decodeURIComponent(extended.trim()) } catch { /* Use the plain filename. */ } + } + if (!name) { + const plain = disposition?.match(/filename\s*=\s*(?:"((?:\\.|[^"])*)"|([^;]+))/i) + name = plain?.[1]?.replace(/\\(.)/g, "$1") ?? plain?.[2] + } + return name?.split(/[/\\]/).pop()?.trim() || fallback +} + function attachmentLabel(a: FileAttachment): string { const tail = a.key.split("/").pop() || a.key return `${tail} (${formatBytes(a.size)})` @@ -2090,8 +2063,8 @@ function StatusChip({ status }: { status: FileAttachment["status"] }) { /// history, and screen-magnification users all rely on a unique, /// descriptive title per page. Restores the title on unmount so cross- /// route navigation doesn't leak the previous page's name. -pub const USE_DOCUMENT_TITLE: &str = r#"// Generated by schema-forge — edit freely. -import { useEffect } from "react" +pub const USE_DOCUMENT_TITLE: &str = r#"import { useEffect } from "react" +import { SITE_NAME, TITLE_SUFFIX } from "@/lib/branding" /** * Set the document title for the lifetime of the current page. @@ -2100,12 +2073,12 @@ import { useEffect } from "react" * useDocumentTitle(`${schema} · ${recordName}`) * * The previous title is restored on unmount. The product suffix - * (· SchemaForge) is appended automatically so callers stay terse. + * is configured by the project. An empty suffix disables it. */ export function useDocumentTitle(title: string): void { useEffect(() => { const previous = document.title - document.title = title ? `${title} · SchemaForge` : "SchemaForge" + document.title = title ? (TITLE_SUFFIX ? `${title} · ${TITLE_SUFFIX}` : title) : SITE_NAME return () => { document.title = previous } diff --git a/crates/schema-forge-cli/src/config.rs b/crates/schema-forge-cli/src/config.rs index 019a780a..d61271e3 100644 --- a/crates/schema-forge-cli/src/config.rs +++ b/crates/schema-forge-cli/src/config.rs @@ -19,6 +19,10 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use acton_service::config::Config; +use figment::{ + providers::{Env, Format, Toml}, + Figment, +}; use schema_forge_acton::config::ClientConfig; use schema_forge_acton::SchemaForgeConfig; use schema_forge_signing::{SigningConfig, SigningMode, VerifyPolicy}; @@ -110,7 +114,9 @@ impl std::fmt::Display for DbParams { write!( f, "surrealdb {}/{}@{} (user={user}, pass={masked_pass})", - p.namespace, p.database, self.redacted_url() + p.namespace, + p.database, + self.redacted_url() ) } DbParams::Postgres(_) => write!(f, "postgres {}", self.redacted_url()), @@ -140,10 +146,69 @@ pub fn load_svc_config(global: &GlobalOpts) -> Result<Config<SchemaForgeConfig>, } })?, }; + // acton-service defaults to all interfaces; SchemaForge defaults to loopback. + // Preserve an explicitly configured unspecified address, including 0.0.0.0. + if !service_bind_is_configured(global.config.as_deref()) { + svc.service.bind = std::net::Ipv4Addr::LOCALHOST.into(); + } apply_cli_overrides(&mut svc, global)?; Ok(svc) } +/// Match acton-service's user config discovery, without recommended_path's +/// advisory relative fallback (which the framework does not actually load). +fn user_service_config_path() -> Option<PathBuf> { + #[cfg(unix)] + let root = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))); + #[cfg(windows)] + let root = std::env::var_os("APPDATA").map(PathBuf::from).or_else(|| { + std::env::var_os("USERPROFILE") + .map(|home| PathBuf::from(home).join("AppData").join("Roaming")) + }); + #[cfg(not(any(unix, windows)))] + let root: Option<PathBuf> = None; + root.map(|root| { + root.join("acton-service") + .join("schemaforge") + .join("config.toml") + }) +} + +fn service_config_paths(explicit: Option<&Path>) -> Vec<PathBuf> { + if let Some(path) = explicit { + return vec![path.to_path_buf()]; + } + let mut paths = vec![PathBuf::from("config.toml")]; + if let Some(path) = user_service_config_path().filter(|path| path.is_file()) { + paths.push(path); + } + #[cfg(unix)] + paths.push(PathBuf::from("/etc/acton-service/schemaforge/config.toml")); + #[cfg(windows)] + if let Some(root) = std::env::var_os("PROGRAMDATA") { + paths.push(PathBuf::from(root).join("acton-service/schemaforge/config.toml")); + } + paths +} + +/// Inspect the framework's actual providers, without its default values. +/// Env handles case-insensitive names and structured ACTON_SERVICE dictionaries. +/// The framework has already validated and loaded the complete configuration. +fn service_bind_is_configured(explicit: Option<&Path>) -> bool { + let mut configured = Figment::new(); + for path in service_config_paths(explicit).iter().rev() { + if path.exists() { + configured = configured.merge(Toml::file(path)); + } + } + configured + .merge(Env::prefixed("ACTON_").split("_")) + .contains("service.bind") +} + fn load_svc_config_from_path(path: &Path) -> Result<Config<SchemaForgeConfig>, CliError> { let path_str = path.to_str().ok_or_else(|| CliError::Config { message: format!("config path is not valid UTF-8: {}", path.display()), @@ -434,6 +499,8 @@ pub struct ResolvedClient { pub insecure: bool, /// Per-request timeout. pub timeout: Duration, + /// Maximum retries after an explicit HTTP 429 response. + pub max_retries: u32, } impl std::fmt::Debug for ResolvedClient { @@ -445,6 +512,7 @@ impl std::fmt::Debug for ResolvedClient { .field("ca_cert", &self.ca_cert) .field("insecure", &self.insecure) .field("timeout", &self.timeout) + .field("max_retries", &self.max_retries) .finish() } } @@ -487,6 +555,7 @@ pub fn resolve_client_config( ca_cert, insecure: conn.insecure, timeout: Duration::from_secs(timeout_secs), + max_retries: conn.max_retries, }) } @@ -571,6 +640,109 @@ mod tests { } } + #[test] + fn listener_environment_provider_respects_case_and_dictionary_overrides() { + const CHILD: &str = "SCHEMAFORGE_LISTENER_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + let global = GlobalOpts { + config: Some(PathBuf::from("config.toml")), + ..empty_global() + }; + let config = load_svc_config(&global).unwrap(); + assert_eq!(config.service.bind, std::net::Ipv4Addr::UNSPECIFIED); + assert_eq!(config.service.port, 3899); + return; + } + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("config.toml"), + "[service]\nport = 3899\n", + ) + .unwrap(); + for (key, value) in [ + ("ACTON_SERVICE_BIND", "0.0.0.0"), + ("ACTON_SERVICE_bind", "0.0.0.0"), + ("ACTON_SERVICE", "{bind=\"0.0.0.0\"}"), + ] { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child.args(["--exact", "config::tests::listener_environment_provider_respects_case_and_dictionary_overrides", "--nocapture"]) + .current_dir(directory.path()).env(CHILD, "1"); + for (name, _) in std::env::vars_os() { + if name + .to_string_lossy() + .to_ascii_uppercase() + .starts_with("ACTON_") + { + child.env_remove(name); + } + } + let output = child.env(key, value).output().unwrap(); + assert!( + output.status.success(), + "{key}: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + + #[test] + fn missing_home_does_not_discover_the_recommended_relative_fallback() { + const CHILD: &str = "SCHEMAFORGE_CONFIG_PATH_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + assert!(user_service_config_path().is_none()); + assert!(!service_config_paths(None) + .contains(&PathBuf::from("acton-service/schemaforge/config.toml"))); + return; + } + let directory = tempfile::tempdir().unwrap(); + let fallback = directory.path().join("acton-service/schemaforge"); + std::fs::create_dir_all(&fallback).unwrap(); + std::fs::write( + fallback.join("config.toml"), + "[service]\nbind = \"0.0.0.0\"\n", + ) + .unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "config::tests::missing_home_does_not_discover_the_recommended_relative_fallback", + "--nocapture", + ]) + .current_dir(directory.path()) + .env(CHILD, "1") + .env_remove("HOME") + .env_remove("XDG_CONFIG_HOME") + .env_remove("APPDATA") + .env_remove("USERPROFILE") + .output() + .unwrap(); + assert!( + output.status.success(), + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn listener_file_configuration_and_loopback_fallback() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let global = GlobalOpts { + config: Some(path.clone()), + ..empty_global() + }; + std::fs::write(&path, "[service]\nport = 3899\n").unwrap(); + let config = load_svc_config(&global).unwrap(); + assert_eq!(config.service.port, 3899); + assert_eq!(config.service.bind, std::net::Ipv4Addr::LOCALHOST); + std::fs::write(&path, "[service]\nport = 8080\nbind = \"0.0.0.0\"\n").unwrap(); + let config = load_svc_config(&global).unwrap(); + assert_eq!(config.service.port, 8080); + assert_eq!(config.service.bind, std::net::Ipv4Addr::UNSPECIFIED); + } + #[test] fn is_postgres_url_recognizes_both_schemes() { assert!(is_postgres_url("postgres://user:pass@host/db")); @@ -863,11 +1035,20 @@ mod connection_redaction_tests { #[test] fn connection_labels_do_not_disclose_uri_or_dsn_credentials() { let params = DbParams::Postgres(PostgresParams { - url: "postgresql://operator:SECRET@localhost:5432/example?password=SECRET#SECRET".into(), + url: "postgresql://operator:SECRET@localhost:5432/example?password=SECRET#SECRET" + .into(), }); assert_eq!(params.redacted_url(), "postgresql://localhost:5432/example"); assert!(!params.to_string().contains("SECRET")); - assert_eq!(redact_connection_url("Server=localhost;User ID=operator;Password=SECRET;Database=example"), "(configured database)"); - assert_eq!(redact_connection_url("not a URL with SECRET"), "(configured database)"); + assert_eq!( + redact_connection_url( + "Server=localhost;User ID=operator;Password=SECRET;Database=example" + ), + "(configured database)" + ); + assert_eq!( + redact_connection_url("not a URL with SECRET"), + "(configured database)" + ); } } diff --git a/crates/schema-forge-cli/src/error.rs b/crates/schema-forge-cli/src/error.rs index c079a28d..3cd4f57a 100644 --- a/crates/schema-forge-cli/src/error.rs +++ b/crates/schema-forge-cli/src/error.rs @@ -84,6 +84,10 @@ pub enum CliError { #[error("destructive changes require --force in non-interactive mode; for field renames declare @renamed_from(\"old_name\") to preserve data")] RequiresForce, + /// Noninteractive migration preflight reports every destructive step. + #[error("destructive changes require --force in non-interactive mode; no schemas were applied:\n{details}\nFor field renames declare @renamed_from(\"old_name\") to preserve data")] + RequiresForceBatch { details: String }, + /// HTTP server errors. #[error("server error: {message}")] Server { message: String }, @@ -171,6 +175,7 @@ impl CliError { | Self::SchemaNotFound { .. } | Self::DirectoryExists { .. } | Self::RequiresForce + | Self::RequiresForceBatch { .. } | Self::Other(_) => ExitCode::GeneralError, } } diff --git a/crates/schema-forge-cli/src/http.rs b/crates/schema-forge-cli/src/http.rs index 75f3f37d..44d481b4 100644 --- a/crates/schema-forge-cli/src/http.rs +++ b/crates/schema-forge-cli/src/http.rs @@ -182,11 +182,31 @@ pub fn classify_http_error(status: u16, body: &str) -> CliError { } } +/// Honor Retry-After seconds or HTTP dates; malformed/missing headers use +/// exponential backoff from 1 to 30 seconds. The retry count remains bounded. +fn retry_delay( + header: Option<&str>, + retry: u32, + now: std::time::SystemTime, +) -> std::time::Duration { + use std::time::Duration; + if let Some(header) = header { + if let Ok(seconds) = header.trim().parse::<u64>() { + return Duration::from_secs(seconds); + } + if let Ok(date) = httpdate::parse_http_date(header) { + return date.duration_since(now).unwrap_or_default(); + } + } + Duration::from_secs(1u64.checked_shl(retry).unwrap_or(30).min(30)) +} + /// HTTP client bound to one running instance and (optionally) one token. pub struct ForgeClient { http: Client, base: String, token: Option<String>, + max_retries: u32, } impl ForgeClient { @@ -229,6 +249,7 @@ impl ForgeClient { http, base: forge_base(&rc.server, &rc.api_version), token: rc.token.clone(), + max_retries: rc.max_retries, }) } @@ -247,6 +268,39 @@ impl ForgeClient { Ok(url) } + /// Retry only explicit rate-limit refusals. Transport errors and server + /// failures have ambiguous write outcomes and must never be replayed here. + async fn send_with_retry( + &self, + request: reqwest::RequestBuilder, + ) -> Result<reqwest::Response, CliError> { + let mut retries = 0; + loop { + let attempt = request.try_clone().ok_or_else(|| CliError::Config { + message: "cannot replay a streaming request".into(), + })?; + let response = attempt.send().await.map_err(|error| CliError::Connection { + message: error.to_string(), + })?; + if response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS + || retries >= self.max_retries + { + return Ok(response); + } + let delay = retry_delay( + response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + retries, + std::time::SystemTime::now(), + ); + drop(response); + tokio::time::sleep(delay).await; + retries += 1; + } + } + /// Send a request and decode the response. /// /// Returns `Ok(None)` for a 2xx with no body (e.g. 204 on delete), @@ -272,9 +326,7 @@ impl ForgeClient { req = req.json(b); } - let resp = req.send().await.map_err(|e| CliError::Connection { - message: e.to_string(), - })?; + let resp = self.send_with_retry(req).await?; let status = resp.status(); let text = resp.text().await.map_err(|e| CliError::Connection { @@ -506,9 +558,7 @@ impl ForgeClient { if let Some(tok) = &self.token { req = req.bearer_auth(tok); } - let mut resp = req.send().await.map_err(|e| CliError::Connection { - message: e.to_string(), - })?; + let mut resp = self.send_with_retry(req).await?; let status = resp.status(); let final_url = resp.url().to_string(); @@ -570,9 +620,7 @@ impl ForgeClient { req = req.bearer_auth(tok); } - let resp = req.send().await.map_err(|e| CliError::Connection { - message: e.to_string(), - })?; + let resp = self.send_with_retry(req).await?; let status = resp.status(); let content_type = resp @@ -693,6 +741,88 @@ fn content_disposition_filename(header: Option<&str>) -> Option<String> { mod tests { use super::*; + #[test] + fn rate_limit_delays_honor_seconds_dates_and_bounded_fallback() { + use std::time::{Duration, UNIX_EPOCH}; + let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + assert_eq!(retry_delay(Some("7"), 0, now), Duration::from_secs(7)); + assert_eq!( + retry_delay( + Some(&httpdate::fmt_http_date(now + Duration::from_secs(9))), + 0, + now + ), + Duration::from_secs(9) + ); + assert_eq!( + retry_delay( + Some(&httpdate::fmt_http_date(now - Duration::from_secs(9))), + 0, + now + ), + Duration::ZERO + ); + assert_eq!(retry_delay(None, 0, now), Duration::from_secs(1)); + assert_eq!(retry_delay(Some("invalid"), 2, now), Duration::from_secs(4)); + assert_eq!(retry_delay(None, u32::MAX, now), Duration::from_secs(30)); + } + + #[tokio::test] + async fn rate_limit_retries_only_429_and_preserves_request_body() { + schema_forge_acton::crypto::install_default_crypto_provider(); + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + for (status, failures, max_retries, expected_attempts, success) in [ + (429, 2, 3, 3, true), + (429, 5, 2, 3, false), + (429, 1, 0, 1, false), + (503, 1, 3, 1, false), + ] { + let attempts = Arc::new(AtomicUsize::new(0)); + let counter = attempts.clone(); + let app = axum::Router::new().route( + "/api/v1/forge/schemas/Note/entities", + axum::routing::post(move |axum::Json(body): axum::Json<Value>| { + let counter = counter.clone(); + async move { + assert_eq!(body, serde_json::json!({"fields": {"title": "hello"}})); + let attempt = counter.fetch_add(1, Ordering::SeqCst); + let response_status = if attempt < failures { status } else { 200 }; + ( + axum::http::StatusCode::from_u16(response_status).unwrap(), + [("retry-after", "0")], + axum::Json(serde_json::json!({"id": "note_example", "fields": {}})), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = ForgeClient { + http: Client::new(), + base: forge_base(&format!("http://{address}"), "v1"), + token: None, + max_retries, + }; + let result = client + .send( + Method::POST, + client.url(&["schemas", "Note", "entities"]).unwrap(), + &[], + Some(&serde_json::json!({"fields": {"title": "hello"}})), + ) + .await; + assert_eq!(result.is_ok(), success, "{result:?}"); + assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts); + server.abort(); + } + } + #[test] fn forge_base_joins_versioned_path() { assert_eq!( diff --git a/crates/schema-forge-cli/templates/site/index.html.jinja b/crates/schema-forge-cli/templates/site/index.html.jinja index 61adc0f2..8baa0e4d 100644 --- a/crates/schema-forge-cli/templates/site/index.html.jinja +++ b/crates/schema-forge-cli/templates/site/index.html.jinja @@ -11,7 +11,8 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="theme-color" content="#0A0A0A" /> - <title>{{ project_name }} + {{ branding.name | escape }} + alt="" style={ { width: 26, height: 22, display: "block" }} /> -
{{ project_name }}
+
{SITE_NAME}
diff --git a/crates/schema-forge-cli/templates/site/src/app/pages/detail.tsx.jinja b/crates/schema-forge-cli/templates/site/src/app/pages/detail.tsx.jinja index 07e9b50c..4574a66a 100644 --- a/crates/schema-forge-cli/templates/site/src/app/pages/detail.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/app/pages/detail.tsx.jinja @@ -7,7 +7,7 @@ // through automatically while your layout and surrounding widgets stay // put in this preserve file. // -// Layout follows the Govcraft "spec sheet": a 56px § margin column +// Layout follows the "spec sheet": a 56px § margin column // alongside mono uppercase labels and the rendered value, with hairline // row dividers. Edit/Back actions live in the page header. import { Link, useParams } from "react-router-dom" @@ -21,6 +21,7 @@ export function {{ entity.pascal }}Detail() { const { id } = useParams<{ id: string }>() useDocumentTitle("{{ entity.title }} record") const query = useQuery({ + meta: { suppressGlobalError: true }, queryKey: ["{{ entity.snake }}", "detail", id], queryFn: () => get{{ entity.pascal }}(id!), enabled: Boolean(id), diff --git a/crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinja b/crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinja index a15dadf1..6179d261 100644 --- a/crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinja @@ -6,15 +6,23 @@ // page, so users can customize layout, section headers, and intercepts // there without ever touching the schema-shaped data here. import type { UseFormReturn } from "react-hook-form" +{%- if entity.has_form_controls %} import { FormField } from "@/components/ui/form" +{%- endif %} {%- if entity.has_relation_one %} import { RelationSelect } from "@/components/ui/relation-select" {%- endif %} -{%- if entity.has_file_field %} +{%- if entity.has_form_file_field %} import { FileUpload, type FileAttachment } from "@/components/ui/file-upload" {%- endif %} import type { {{ entity.pascal }} } from "@/generated/entity-types" -import type { {{ entity.pascal }}FormValues } from "@/generated/zod-schemas" +import { +{%- if entity.has_form_fields %} + canReadFormField, canWriteFormField, canWriteFormChildren, +{%- endif %} + normalizeFormValues, normalizeFormPayload, + type FormFieldSpec, type {{ entity.pascal }}FormValues, +} from "@/generated/zod-schemas" {#- -- Shared macros ---------------------------------------------------------- @@ -23,6 +31,8 @@ import type { {{ entity.pascal }}FormValues } from "@/generated/zod-schemas" single `.form-row` block that fits inside the parent `.form-grid`. -#} {% macro control(f) -%} +{%- if not f.derived and not f.computed %} + {canReadFormField({{ f.read_roles | tojson }}) ? (
+ {!{{ f.has_hidden_children | tojson }} && canWriteFormField({{ f.read_roles | tojson }}, {{ f.write_roles | tojson }}) && canWriteFormChildren({{ f.form_sub_fields | tojson }}) ? ( + <> {%- if f.kind == "boolean" %}