From 5fefc4e1d51eb9c8a16905fda1b5c1475a63934a Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sun, 12 Jul 2026 20:58:10 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(teams):=20full=20[teams]=20section=20?= =?UTF-8?q?=E2=80=94=20credentials=20+=20connection=20config-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth platform slice of the config-first parity umbrella (#1375): [teams] graduates from the shared PlatformTrustConfig to a dedicated TeamsConfig carrying app_id, app_secret, allowed_tenants, oauth_endpoint, openid_metadata, webhook_path alongside the trust fields. Each field resolves config → TEAMS_* env → default. - core: TeamsConfig/ResolvedTeams + resolve(); trust_config() view - gateway: TeamsConfig::from_env refactored onto a from_reader shared with the new AppState::apply_teams_config (app_id + app_secret mandatory — incomplete section disables the adapter, matching env-only semantics); new teams_webhook_path state honored by both binaries' route mounts - unified: applies [teams] before warn_unenforceable_l1 - docs: config.toml.example, config-reference.md (full field table), msteams-selfhosted.md Closes #1380. Refs #1375. Stacked on #1383. --- config.toml.example | 4 + crates/openab-core/src/config.rs | 159 +++++++++++++++++++- crates/openab-gateway/src/adapters/teams.rs | 17 ++- crates/openab-gateway/src/lib.rs | 76 +++++++++- docs/config-reference.md | 17 +++ docs/msteams-selfhosted.md | 12 ++ src/main.rs | 23 ++- 7 files changed, 293 insertions(+), 15 deletions(-) diff --git a/config.toml.example b/config.toml.example index bfad9067a..2ad53a9ce 100644 --- a/config.toml.example +++ b/config.toml.example @@ -117,6 +117,10 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # allowed_users = ["users/123456789"] # Chat user resource names (users/) # # env fallback: GOOGLE_CHAT_ALLOWED_USERS (comma-separated) # [teams] +# app_id = "${TEAMS_APP_ID}" # env fallback: TEAMS_APP_ID +# app_secret = "${TEAMS_APP_SECRET}" # env fallback: TEAMS_APP_SECRET +# allowed_tenants = [""] # env fallback: TEAMS_ALLOWED_TENANTS (empty = all) +# webhook_path = "/webhook/teams" # env fallback: TEAMS_WEBHOOK_PATH # allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS # allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) # # env fallback: TEAMS_ALLOWED_USERS (comma-separated) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 9b05a4b2b..7ec418341 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -175,7 +175,7 @@ pub struct Config { pub line: Option, pub wecom: Option, pub googlechat: Option, - pub teams: Option, + pub teams: Option, pub agentcore: Option, #[serde(default)] pub agent: AgentConfig, @@ -1075,6 +1075,103 @@ impl GoogleChatConfig { } } +/// First-class `[teams]` section — credentials, connection, and L3 identity +/// trust for the MS Teams adapter. Config-first invariant (#1375): each field +/// resolves `[teams].field` (with `${}` expansion) → `TEAMS_*` env var → +/// default. Graduates from the shared [`PlatformTrustConfig`] (#1380). +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct TeamsConfig { + /// Azure AD app (bot) ID. Env fallback: `TEAMS_APP_ID`. + pub app_id: Option, + /// App client secret. Env fallback: `TEAMS_APP_SECRET`. + pub app_secret: Option, + /// Restrict to tenant IDs. Env fallback: `TEAMS_ALLOWED_TENANTS` + /// (comma-separated). Empty = all tenants. + pub allowed_tenants: Option>, + /// OAuth token endpoint. Env fallback: `TEAMS_OAUTH_ENDPOINT` + /// (default: Bot Framework endpoint). + pub oauth_endpoint: Option, + /// OpenID metadata URL for JWT validation. Env fallback: + /// `TEAMS_OPENID_METADATA` (default: Bot Framework metadata). + pub openid_metadata: Option, + /// Webhook mount path. Env fallback: `TEAMS_WEBHOOK_PATH` + /// (default `/webhook/teams`). + pub webhook_path: Option, + /// Explicit flag: true = allow all users, false = check `allowed_users`. + /// Defaults to `false` (deny-all). Env fallback: `TEAMS_ALLOW_ALL_USERS`. + pub allow_all_users: Option, + /// Bot Framework `activity.from.id` values (`29:…`) allowed to interact. + /// Env fallback: `TEAMS_ALLOWED_USERS` (comma-separated). + pub allowed_users: Option>, +} + +/// Fully resolved Teams settings (config → env → default applied). +#[derive(Debug, Clone)] +pub struct ResolvedTeams { + pub app_id: Option, + pub app_secret: Option, + pub allowed_tenants: Vec, + pub oauth_endpoint: String, + pub openid_metadata: String, + pub webhook_path: String, + pub allow_all_users: bool, + pub allowed_users: Vec, +} + +impl TeamsConfig { + /// Resolve every field: config value (if set) → `TEAMS_*` env → default. + pub fn resolve(&self) -> ResolvedTeams { + let opt_str = |cfg: &Option, env: &str| -> Option { + cfg.as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .or_else(|| std::env::var(env).ok()) + }; + let csv = |cfg: &Option>, env: &str| -> Vec { + match cfg { + Some(list) => list.clone(), + None => std::env::var(env) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + } + }; + ResolvedTeams { + app_id: opt_str(&self.app_id, "TEAMS_APP_ID"), + app_secret: opt_str(&self.app_secret, "TEAMS_APP_SECRET"), + allowed_tenants: csv(&self.allowed_tenants, "TEAMS_ALLOWED_TENANTS"), + oauth_endpoint: opt_str(&self.oauth_endpoint, "TEAMS_OAUTH_ENDPOINT").unwrap_or_else( + || "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token".into(), + ), + openid_metadata: opt_str(&self.openid_metadata, "TEAMS_OPENID_METADATA") + .unwrap_or_else(|| { + "https://login.botframework.com/v1/.well-known/openidconfiguration".into() + }), + webhook_path: opt_str(&self.webhook_path, "TEAMS_WEBHOOK_PATH") + .unwrap_or_else(|| "/webhook/teams".into()), + allow_all_users: self.allow_all_users.unwrap_or_else(|| { + std::env::var("TEAMS_ALLOW_ALL_USERS") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false) + }), + allowed_users: csv(&self.allowed_users, "TEAMS_ALLOWED_USERS"), + } + } + + /// Trust-fields view for the shared registry override path. + pub fn trust_config(&self) -> PlatformTrustConfig { + PlatformTrustConfig { + allow_all_users: self.allow_all_users, + allowed_users: self.allowed_users.clone(), + } + } +} + /// Shared first-class trust section for gateway platforms whose Phase 1 needs /// exactly the two L3 identity fields (identity-trust-none ADR): `[teams]`. /// Same shape and resolution order as @@ -2462,6 +2559,64 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] std::env::remove_var("GOOGLE_CHAT_AUDIENCE"); } + /// All `TEAMS_*` env scenarios in ONE test (env is process-global). + /// NOTE: uses only TEAMS_APP_ID/TEAMS_OAUTH_ENDPOINT to avoid racing + /// `platform_trust_resolve_all_scenarios` (TEAMS_ALLOW_ALL_USERS/USERS) + /// and main.rs's env tests (which touch TEAMS_APP_ID in the bin crate — + /// separate process, safe). + #[test] + fn teams_resolve_all_scenarios() { + for k in ["TEAMS_APP_ID", "TEAMS_OAUTH_ENDPOINT"] { + std::env::remove_var(k); + } + // --- defaults --- + let r = TeamsConfig::default().resolve(); + assert!(r.app_id.is_none()); + assert_eq!(r.webhook_path, "/webhook/teams"); + assert!(r.oauth_endpoint.contains("botframework.com")); + assert!(r.openid_metadata.contains("openidconfiguration")); + assert!(r.allowed_tenants.is_empty()); + + // --- config wins over env --- + std::env::set_var("TEAMS_APP_ID", "env-app"); + std::env::set_var("TEAMS_OAUTH_ENDPOINT", "https://env.example/token"); + let cfg = TeamsConfig { + app_id: Some("cfg-app".into()), + oauth_endpoint: Some("https://cfg.example/token".into()), + allowed_tenants: Some(vec!["t1".into(), "t2".into()]), + ..Default::default() + }; + let r = cfg.resolve(); + assert_eq!(r.app_id.as_deref(), Some("cfg-app")); + assert_eq!(r.oauth_endpoint, "https://cfg.example/token"); + assert_eq!(r.allowed_tenants, vec!["t1".to_string(), "t2".to_string()]); + + // --- empty-string ${} expansion falls through to env --- + let cfg = TeamsConfig { + app_id: Some("".into()), + ..Default::default() + }; + let r = cfg.resolve(); + assert_eq!(r.app_id.as_deref(), Some("env-app")); + assert_eq!(r.oauth_endpoint, "https://env.example/token"); + + // --- trust_config() view --- + let cfg = TeamsConfig { + allow_all_users: Some(true), + allowed_users: Some(vec!["29:abc".into()]), + ..Default::default() + }; + let t = cfg.trust_config(); + assert_eq!(t.allow_all_users, Some(true)); + assert_eq!( + t.allowed_users.as_deref(), + Some(&["29:abc".to_string()][..]) + ); + + std::env::remove_var("TEAMS_APP_ID"); + std::env::remove_var("TEAMS_OAUTH_ENDPOINT"); + } + #[test] fn platform_trust_sections_parse_from_toml() { let toml_str = r#" @@ -2478,6 +2633,7 @@ audience = "projects/p/..." allowed_users = ["users/123456789"] [teams] +app_id = "app-1" allow_all_users = true "#; let cfg = parse_config_str(toml_str, "test").unwrap(); @@ -2496,6 +2652,7 @@ allow_all_users = true Some(&["users/123456789".to_string()][..]) ); let teams = cfg.teams.expect("teams section"); + assert_eq!(teams.app_id.as_deref(), Some("app-1")); assert_eq!(teams.allow_all_users, Some(true)); // Absent sections → None (trust falls back to legacy GATEWAY_* seed). diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index c8dec8509..0ee438d11 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -125,18 +125,25 @@ pub struct TeamsConfig { impl TeamsConfig { pub fn from_env() -> Option { - let app_id = std::env::var("TEAMS_APP_ID").ok()?; - let app_secret = std::env::var("TEAMS_APP_SECRET").ok()?; + Self::from_reader(|k| std::env::var(k).ok()) + } + + /// Build config from an arbitrary string reader (#1380) — shared by + /// env-derived construction and `apply_teams_config`, so the same + /// mandatory-credential semantics apply to both paths. + pub(crate) fn from_reader Option>(read: F) -> Option { + let app_id = read("TEAMS_APP_ID")?; + let app_secret = read("TEAMS_APP_SECRET")?; Some(Self { app_id, app_secret, - oauth_endpoint: std::env::var("TEAMS_OAUTH_ENDPOINT").unwrap_or_else(|_| { + oauth_endpoint: read("TEAMS_OAUTH_ENDPOINT").unwrap_or_else(|| { "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token".into() }), - openid_metadata: std::env::var("TEAMS_OPENID_METADATA").unwrap_or_else(|_| { + openid_metadata: read("TEAMS_OPENID_METADATA").unwrap_or_else(|| { "https://login.botframework.com/v1/.well-known/openidconfiguration".into() }), - allowed_tenants: std::env::var("TEAMS_ALLOWED_TENANTS") + allowed_tenants: read("TEAMS_ALLOWED_TENANTS") .unwrap_or_default() .split(',') .map(|s| s.trim().to_string()) diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index f5f550a26..5b833b6c1 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -45,6 +45,9 @@ pub struct AppState { pub line_webhook_path: String, #[cfg(feature = "teams")] pub teams: Option, + /// Webhook mount path for Teams (env: `TEAMS_WEBHOOK_PATH`; config-first + /// via `apply_teams_config`, default `/webhook/teams`). + pub teams_webhook_path: String, pub teams_service_urls: Mutex>, #[cfg(feature = "feishu")] pub feishu: Option, @@ -85,6 +88,7 @@ impl AppState { line_webhook_path: "/webhook/line".into(), #[cfg(feature = "teams")] teams: None, + teams_webhook_path: "/webhook/teams".into(), teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu: None, @@ -132,6 +136,8 @@ impl AppState { info!("teams adapter configured"); adapters::teams::TeamsAdapter::new(config) }); + let teams_webhook_path = + std::env::var("TEAMS_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/teams".into()); // Feishu #[cfg(feature = "feishu")] @@ -179,6 +185,7 @@ impl AppState { line_webhook_path, #[cfg(feature = "teams")] teams, + teams_webhook_path, teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu, @@ -359,6 +366,25 @@ impl AppState { None }; } + + /// Apply resolved `[teams]` config values (#1380), rebuilding the adapter + /// through the same `from_reader` construction as env-only startup + /// (app_id + app_secret mandatory; incomplete section disables the + /// adapter, matching env-only semantics). + #[cfg(feature = "teams")] + pub fn apply_teams_config(&mut self, cfg: GatewayTeamsConfig) { + self.teams_webhook_path = cfg.webhook_path; + let tenants = cfg.allowed_tenants.join(","); + self.teams = adapters::teams::TeamsConfig::from_reader(|k| match k { + "TEAMS_APP_ID" => cfg.app_id.clone(), + "TEAMS_APP_SECRET" => cfg.app_secret.clone(), + "TEAMS_OAUTH_ENDPOINT" => Some(cfg.oauth_endpoint.clone()), + "TEAMS_OPENID_METADATA" => Some(cfg.openid_metadata.clone()), + "TEAMS_ALLOWED_TENANTS" => Some(tenants.clone()), + _ => None, + }) + .map(adapters::teams::TeamsAdapter::new); + } } /// Parameter object for passing resolved Telegram config across the crate @@ -408,6 +434,18 @@ pub struct GatewayGoogleChatConfig { pub webhook_path: String, } +/// Parameter object for passing resolved Teams config across the crate +/// boundary without introducing a dependency on `openab-core` (#1380). +#[derive(Debug, Clone)] +pub struct GatewayTeamsConfig { + pub app_id: Option, + pub app_secret: Option, + pub allowed_tenants: Vec, + pub oauth_endpoint: String, + pub openid_metadata: String, + pub webhook_path: String, +} + // --- Public serve() entry point --- /// Configuration for the standalone gateway server. @@ -498,12 +536,12 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { #[cfg(not(feature = "teams"))] let teams: Option<()> = None; + let teams_webhook_path = + std::env::var("TEAMS_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/teams".into()); #[cfg(feature = "teams")] if teams.is_some() { - let webhook_path = - std::env::var("TEAMS_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/teams".into()); - info!(path = %webhook_path, "teams webhook registered"); - app = app.route(&webhook_path, post(adapters::teams::webhook)); + info!(path = %teams_webhook_path, "teams webhook registered"); + app = app.route(&teams_webhook_path, post(adapters::teams::webhook)); } // Feishu adapter @@ -617,6 +655,7 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { line_webhook_path, #[cfg(feature = "teams")] teams, + teams_webhook_path, teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu, @@ -930,6 +969,35 @@ mod l1_audit_tests { assert!(flagged(&s).is_empty()); } + #[cfg(feature = "teams")] + #[test] + fn apply_teams_config_requires_credentials() { + use super::GatewayTeamsConfig; + let mut s = state(); + // Complete credentials → adapter built, path set. + s.apply_teams_config(GatewayTeamsConfig { + app_id: Some("app".into()), + app_secret: Some("sec".into()), + allowed_tenants: vec!["t1".into()], + oauth_endpoint: "https://x/token".into(), + openid_metadata: "https://x/oidc".into(), + webhook_path: "/hook/teams".into(), + }); + assert!(s.teams.is_some()); + assert_eq!(s.teams_webhook_path, "/hook/teams"); + + // Missing secret → adapter disabled (same as env-only semantics). + s.apply_teams_config(GatewayTeamsConfig { + app_id: Some("app".into()), + app_secret: None, + allowed_tenants: vec![], + oauth_endpoint: "https://x/token".into(), + openid_metadata: "https://x/oidc".into(), + webhook_path: "/hook/teams".into(), + }); + assert!(s.teams.is_none()); + } + #[cfg(feature = "googlechat")] #[test] fn apply_googlechat_config_builds_adapter_and_feeds_l1_warning() { diff --git a/docs/config-reference.md b/docs/config-reference.md index 82218d2b6..907014ef4 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -197,6 +197,21 @@ Full first-class Google Chat section (config-first parity, #1379) — credential ## `[teams]` +Full first-class Teams section (config-first parity, #1380) — credentials, connection, and L3 identity trust. Each field resolves: config → `TEAMS_*` env → default. `app_id` + `app_secret` are mandatory (after env fallback); an incomplete section disables the adapter. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `app_id` | string | — | Azure AD app (bot) ID. Env: `TEAMS_APP_ID`. | +| `app_secret` | string | — | App client secret. Env: `TEAMS_APP_SECRET`. | +| `allowed_tenants` | string[] | `[]` (all) | Restrict to tenant IDs. Env: `TEAMS_ALLOWED_TENANTS`. | +| `oauth_endpoint` | string | Bot Framework | Env: `TEAMS_OAUTH_ENDPOINT`. | +| `openid_metadata` | string | Bot Framework | Env: `TEAMS_OPENID_METADATA`. | +| `webhook_path` | string | `/webhook/teams` | Env: `TEAMS_WEBHOOK_PATH`. | +| `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | +| `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. | + +
Previous trust-only description + First-class L3 identity trust — same shape and semantics as `[line]`. Each section replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for its platform (deprecated: warns at startup, becomes an error in Phase 2). Platform credentials remain on the gateway env vars (`TEAMS_APP_ID`/`TEAMS_APP_SECRET`) until the Teams config-first parity slice lands (#1380). > **Mode scoping:** these sections (like `[line]`) take effect on the **embedded/unified adapter path**, where events pass the shared ingress trust gate. Deployments using the standalone `openab-gateway` companion over WebSocket enforce trust via `[gateway].allow_all_users` / `allowed_users` instead; Phase 1c consolidates the two paths. @@ -220,6 +235,8 @@ allowed_users = ["29:1abc..."] # allow_all_users = true # explicit opt-in only ``` +
+ --- ## `[agent]` diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 23ca49cc6..93c43ef6c 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -31,6 +31,18 @@ tables = "off" Set `TEAMS_APP_ID` and `TEAMS_APP_SECRET` on the container. No `[gateway]` needed. +### `[teams]` Section (credentials + trust) + +Since #1380 the `[teams]` section carries the full adapter configuration — config-first with `TEAMS_*` env fallback: + +```toml +[teams] +app_id = "${TEAMS_APP_ID}" +app_secret = "${TEAMS_APP_SECRET}" +allowed_tenants = [""] +allowed_users = ["29:1abc..."] +``` + ### User Trust (`[teams]` section) Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[teams]` section: diff --git a/src/main.rs b/src/main.rs index 498606ba5..beb3e0c27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -534,7 +534,7 @@ async fn main() -> anyhow::Result<()> { platform_trust_override( &mut reg, "teams", - &cfg.teams, + &cfg.teams.as_ref().map(|t| t.trust_config()), "TEAMS", cfg!(feature = "teams") && std::env::var("TEAMS_APP_ID").is_ok(), allow_all_channels, @@ -915,6 +915,21 @@ async fn main() -> anyhow::Result<()> { webhook_path: r.webhook_path, }); } + // First-class `[teams]` config overrides env-derived values + // (config-authoritative + ${} expansion + TEAMS_* env fallback, + // #1380). + #[cfg(feature = "teams")] + if let Some(ref t) = cfg.teams { + let r = t.resolve(); + gw_state_inner.apply_teams_config(openab_gateway::GatewayTeamsConfig { + app_id: r.app_id, + app_secret: r.app_secret, + allowed_tenants: r.allowed_tenants, + oauth_endpoint: r.oauth_endpoint, + openid_metadata: r.openid_metadata, + webhook_path: r.webhook_path, + }); + } let gw_state = Arc::new(gw_state_inner); // Phase 1 L1 audit (#1356): warn if any active webhook platform has @@ -986,11 +1001,9 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "teams")] if gw_state.teams.is_some() { - let path = - std::env::var("TEAMS_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/teams".into()); - info!(path = %path, "unified: teams adapter enabled"); + info!(path = %gw_state.teams_webhook_path, "unified: teams adapter enabled"); app = app.route( - &path, + &gw_state.teams_webhook_path, axum::routing::post(openab_gateway::adapters::teams::webhook), ); } From 323fec38bdd59fa63557ef154cf0690fa6390aab Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sun, 12 Jul 2026 21:05:46 -0400 Subject: [PATCH 2/2] docs(config): PlatformTrustConfig is now the shared trust-view type --- crates/openab-core/src/config.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 7ec418341..fdd5bd5b3 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1172,16 +1172,13 @@ impl TeamsConfig { } } -/// Shared first-class trust section for gateway platforms whose Phase 1 needs -/// exactly the two L3 identity fields (identity-trust-none ADR): `[teams]`. -/// Same shape and resolution order as -/// [`LineConfig`] / [`TelegramConfig`]: `[section].field` (with `${}` -/// expansion) → `{PREFIX}_*` env var → deny-all default. -/// -/// Trust-only by design — platform credentials stay on the gateway env vars -/// the webhook adapters read (`TEAMS_APP_ID`/`TEAMS_APP_SECRET`). -/// Also serves as the shared trust-fields view type returned by the -/// graduated sections' `trust_config()`. Platforms that later +/// Shared trust-fields view (L3 identity, identity-trust-none ADR) returned +/// by the platform sections' `trust_config()` for the registry override path. +/// All platform sections have graduated to dedicated structs (#1375); this +/// type remains as the common denominator the binary's +/// `platform_trust_override` consumes. Resolution order per field: +/// `[section].field` → `{PREFIX}_*` env var → deny-all default. +/// Platforms that later /// need extra trust fields (e.g. `trusted_bot_ids`) graduate to their own /// struct, as LINE will for group policy. #[derive(Debug, Clone, Default, Deserialize)]