Skip to main content

meerkat_mobkit/runtime/
console_ingress.rs

1//! Console ingress types and JSON request/response structures.
2
3use super::*;
4use crate::access::{AccessController, AccessResource, AccessView, AgentResourceAttributes};
5use crate::rpc::MOBKIT_CONTRACT_VERSION;
6
7/// Console-facing view of a single mob member.
8///
9/// Narrow projection of meerkat's roster enriched with cached diagnostics
10/// such as the current bridge session id when the console read model has one.
11/// Wire field names match the console JSON — `agent_identity`, `role`,
12/// `state` — so the admin UI reads them unchanged from the live snapshot.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ConsoleMember {
15    pub agent_identity: String,
16    pub role: String,
17    pub state: String,
18    #[serde(default)]
19    pub model_capabilities: ConsoleModelCapabilities,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub runtime_mode: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub session_id: Option<String>,
24    pub wired_to: Vec<String>,
25    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
26    pub labels: std::collections::BTreeMap<String, String>,
27    /// Machine-owned liveness projection (meerkat 0.7.29+, ask 14):
28    /// `{run_state, in_flight_work, last_progress_at_ms, last_progress_event,
29    /// health}`. Kept as raw wire JSON — the console reads it, mobkit does
30    /// not interpret it. `None` when the member has no snapshot (final
31    /// members, or rosters over the progress projection cap).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub progress: Option<serde_json::Value>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ConsoleModelCapabilities {
38    #[serde(default)]
39    pub image_input: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ConsoleRestJsonRequest {
44    pub method: String,
45    pub path: String,
46    pub auth: Option<ConsoleAccessRequest>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ConsoleRestJsonResponse {
51    pub status: u16,
52    pub body: Value,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ConsoleAgentLiveSnapshot {
57    pub agent_id: String,
58    pub member_id: String,
59    pub label: String,
60    pub kind: String,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub identity: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub role: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub state: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub session_id: Option<String>,
69    #[serde(default)]
70    pub model_capabilities: ConsoleModelCapabilities,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub response_phase: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub watched: Option<bool>,
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        rename = "alertLevel"
79    )]
80    pub alert_level: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub degraded: Option<bool>,
83    #[serde(
84        default,
85        skip_serializing_if = "Option::is_none",
86        rename = "degradedReason"
87    )]
88    pub degraded_reason: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ConsoleLiveSnapshot {
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub runtime_id: Option<String>,
95    pub running: bool,
96    pub loaded_modules: Vec<String>,
97    #[serde(default)]
98    pub agents: Vec<ConsoleAgentLiveSnapshot>,
99    pub members: Vec<ConsoleMember>,
100    pub has_mob_runtime: bool,
101}
102
103fn console_member_console_identity(member: &ConsoleMember) -> &str {
104    crate::member_comms_id::durable_identity_label(&member.labels)
105        .unwrap_or(member.agent_identity.as_str())
106}
107
108impl ConsoleLiveSnapshot {
109    pub fn new(
110        runtime_id: Option<String>,
111        running: bool,
112        loaded_modules: Vec<String>,
113        agents: Vec<ConsoleAgentLiveSnapshot>,
114        members: Vec<ConsoleMember>,
115        has_mob_runtime: bool,
116    ) -> Self {
117        let mut seen = BTreeSet::new();
118        let mut deduped_modules = Vec::new();
119        for module_id in loaded_modules {
120            if seen.insert(module_id.clone()) {
121                deduped_modules.push(module_id);
122            }
123        }
124        let mut seen_agents = BTreeSet::new();
125        let mut deduped_agents = Vec::new();
126        for agent in agents {
127            if seen_agents.insert(agent.agent_id.clone()) {
128                deduped_agents.push(agent);
129            }
130        }
131        Self {
132            runtime_id,
133            running,
134            loaded_modules: deduped_modules,
135            agents: deduped_agents,
136            members,
137            has_mob_runtime,
138        }
139    }
140}
141
142pub fn handle_console_rest_json_route(
143    decisions: &RuntimeDecisionState,
144    request: &ConsoleRestJsonRequest,
145) -> ConsoleRestJsonResponse {
146    handle_console_rest_json_route_with_snapshot(decisions, request, None)
147}
148
149pub fn handle_console_rest_json_route_with_snapshot(
150    decisions: &RuntimeDecisionState,
151    request: &ConsoleRestJsonRequest,
152    live_snapshot: Option<&ConsoleLiveSnapshot>,
153) -> ConsoleRestJsonResponse {
154    handle_console_rest_json_route_with_snapshot_and_access(decisions, request, live_snapshot, None)
155}
156
157/// Access-aware variant of [`handle_console_rest_json_route_with_snapshot`].
158///
159/// When an [`AccessController`] is supplied and enabled, the experience
160/// projection is filtered per authenticated principal: agents the caller
161/// may not view disappear from every section, per-agent affordances are
162/// intersected with the caller's grants, and an `access` section describing
163/// the caller's standing is appended for the console UI.
164pub fn handle_console_rest_json_route_with_snapshot_and_access(
165    decisions: &RuntimeDecisionState,
166    request: &ConsoleRestJsonRequest,
167    live_snapshot: Option<&ConsoleLiveSnapshot>,
168    access: Option<&AccessController>,
169) -> ConsoleRestJsonResponse {
170    handle_console_rest_json_route_with_snapshot_access_and_memory(
171        decisions,
172        request,
173        live_snapshot,
174        access,
175        false,
176    )
177}
178
179/// Memory-panel-aware variant: `memory_panel_available` reports whether the
180/// runtime wired a Memory-panel store, so the experience can project the
181/// `memory` affordance section (§9.3) the console nav gate consumes.
182pub fn handle_console_rest_json_route_with_snapshot_access_and_memory(
183    decisions: &RuntimeDecisionState,
184    request: &ConsoleRestJsonRequest,
185    live_snapshot: Option<&ConsoleLiveSnapshot>,
186    access: Option<&AccessController>,
187    memory_panel_available: bool,
188) -> ConsoleRestJsonResponse {
189    handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
190        decisions,
191        request,
192        live_snapshot,
193        access,
194        memory_panel_available,
195        false,
196    )
197}
198
199/// Workgraph-aware variant: `workgraph_available` reports whether the runtime
200/// wired a WorkGraph service, so the experience can project the `workgraph`
201/// affordance section the console consumes (`available` + ABAC-intersected
202/// `can_view`/`can_manage`).
203pub fn handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
204    decisions: &RuntimeDecisionState,
205    request: &ConsoleRestJsonRequest,
206    live_snapshot: Option<&ConsoleLiveSnapshot>,
207    access: Option<&AccessController>,
208    memory_panel_available: bool,
209    workgraph_available: bool,
210) -> ConsoleRestJsonResponse {
211    let (base_path, query_params) = split_path_and_query(&request.path);
212    if request.method != "GET"
213        || (base_path != CONSOLE_MODULES_ROUTE && base_path != CONSOLE_EXPERIENCE_ROUTE)
214    {
215        return ConsoleRestJsonResponse {
216            status: 404,
217            body: serde_json::json!({"error":"not_found"}),
218        };
219    }
220
221    let resolved_auth = match resolve_console_auth(decisions, request.auth.as_ref(), &query_params)
222    {
223        Ok(auth) => auth,
224        Err(error) => {
225            return ConsoleRestJsonResponse {
226                status: 401,
227                body: serde_json::json!({
228                    "error":"unauthorized",
229                    "reason": console_auth_error_reason(&error),
230                }),
231            };
232        }
233    };
234
235    match &resolved_auth {
236        Some(auth) => {
237            if let Err(error) =
238                enforce_console_route_access(&decisions.auth, &decisions.console, auth)
239            {
240                return ConsoleRestJsonResponse {
241                    status: 401,
242                    body: serde_json::json!({
243                        "error":"unauthorized",
244                        "reason": auth_error_reason(&error),
245                    }),
246                };
247            }
248        }
249        None if decisions.console.require_app_auth => {
250            return ConsoleRestJsonResponse {
251                status: 401,
252                body: serde_json::json!({
253                    "error":"unauthorized",
254                    "reason":"missing_credentials",
255                }),
256            };
257        }
258        None => {}
259    }
260
261    // Subject for ABAC. When app auth is not required the auth resolver
262    // returns None without looking at credentials; still identify callers
263    // that *did* present a valid token so per-user grants apply on
264    // auth-optional consoles. Invalid tokens fall back to anonymous.
265    let mut access_subject = resolved_auth.as_ref().map(|auth| auth.email.clone());
266    if access_subject.is_none()
267        && access.is_some()
268        && !decisions.console.require_app_auth
269        && let Some(token) = query_params.get("auth_token")
270    {
271        access_subject =
272            resolve_authorized_console_auth_from_token(decisions, token).map(|auth| auth.email);
273    }
274    let access_view =
275        access.map(|controller| controller.view_for_subject(access_subject.as_deref()));
276
277    let modules: Vec<String> = decisions
278        .modules
279        .iter()
280        .map(|module| module.id.clone())
281        .collect();
282    let mut live_snapshot = live_snapshot
283        .cloned()
284        .unwrap_or_else(|| default_console_live_snapshot(decisions));
285    if let Some(controller) = access {
286        prime_access_attributes(controller, &live_snapshot);
287    }
288    if let Some(view) = &access_view {
289        filter_snapshot_for_access(&mut live_snapshot, view);
290    }
291    let mut body = if base_path == CONSOLE_EXPERIENCE_ROUTE {
292        build_console_experience_contract(&modules, &live_snapshot, &decisions.console)
293    } else {
294        serde_json::json!({
295            "contract_version": MOBKIT_CONTRACT_VERSION,
296            "modules": modules
297        })
298    };
299    if base_path == CONSOLE_EXPERIENCE_ROUTE
300        && let Some(view) = &access_view
301    {
302        apply_access_to_experience(&mut body, view);
303    }
304    if base_path == CONSOLE_EXPERIENCE_ROUTE {
305        apply_memory_to_experience(&mut body, access_view.as_ref(), memory_panel_available);
306        apply_workgraph_to_experience(&mut body, access_view.as_ref(), workgraph_available);
307    }
308    ConsoleRestJsonResponse { status: 200, body }
309}
310
311/// Append the `workgraph` affordance section. `can_view`/`can_manage` are
312/// intersected with the caller's grants when access control is enforced and
313/// mirror availability otherwise; per-method enforcement still applies to
314/// every `mobkit/workgraph/*` RPC.
315fn apply_workgraph_to_experience(
316    body: &mut Value,
317    view: Option<&AccessView>,
318    workgraph_available: bool,
319) {
320    let (can_view, can_manage) = match view.filter(|view| view.enforced()) {
321        Some(view) => (
322            workgraph_available && view.allows(crate::access::ACTION_WORKGRAPH_VIEW),
323            workgraph_available && view.allows(crate::access::ACTION_WORKGRAPH_MANAGE),
324        ),
325        None => (workgraph_available, workgraph_available),
326    };
327    if let Some(object) = body.as_object_mut() {
328        object.insert(
329            "workgraph".to_string(),
330            serde_json::json!({
331                "available": workgraph_available,
332                "can_view": can_view,
333                "can_manage": can_manage,
334            }),
335        );
336    }
337}
338
339/// Append the `memory` affordance section (§9.3) the console nav gate
340/// consumes. Coarse by design — `can_read` means "some memory read could
341/// succeed for this caller", mirroring the capabilities intersection;
342/// per-scope enforcement still applies to every panel RPC.
343fn apply_memory_to_experience(
344    body: &mut Value,
345    view: Option<&AccessView>,
346    memory_panel_available: bool,
347) {
348    let (can_read, can_review_quarantine) = match view.filter(|view| view.enforced()) {
349        Some(view) => (
350            memory_panel_available
351                && (view.may_perform_anywhere(crate::access::ACTION_AGENT_MEMORY_READ)
352                    || view.may_perform_anywhere(crate::access::ACTION_MOB_MEMORY_READ)
353                    || view.may_perform_anywhere(crate::access::ACTION_OPERATOR_MEMORY_READ)),
354            memory_panel_available && view.allows(crate::access::ACTION_MEMORY_QUARANTINE_REVIEW),
355        ),
356        None => (memory_panel_available, memory_panel_available),
357    };
358    if let Some(object) = body.as_object_mut() {
359        object.insert(
360            "memory".to_string(),
361            serde_json::json!({
362                "available": memory_panel_available,
363                "can_read": can_read,
364                "can_review_quarantine": can_review_quarantine,
365            }),
366        );
367    }
368}
369
370/// Feed the controller's attribute cache from a roster snapshot so
371/// label/role selectors resolve on surfaces that only carry an identity
372/// string (timeline frames, send requests, SSE streams).
373fn prime_access_attributes(controller: &AccessController, snapshot: &ConsoleLiveSnapshot) {
374    for agent in &snapshot.agents {
375        let identity = agent
376            .identity
377            .clone()
378            .unwrap_or_else(|| agent.agent_id.clone());
379        controller.record_agent_attributes(AgentResourceAttributes {
380            identity,
381            agent_id: Some(agent.agent_id.clone()),
382            role: agent.role.clone(),
383            labels: std::collections::BTreeMap::new(),
384        });
385    }
386    // Members carry the richer attribute set; record them last so they win.
387    for member in &snapshot.members {
388        controller.record_agent_attributes(AgentResourceAttributes {
389            identity: console_member_console_identity(member).to_string(),
390            agent_id: Some(member.agent_identity.clone()),
391            role: Some(member.role.clone()),
392            labels: member.labels.clone(),
393        });
394    }
395}
396
397fn member_access_resource(member: &ConsoleMember) -> AccessResource<'_> {
398    AccessResource {
399        identity: Some(console_member_console_identity(member)),
400        agent_id: Some(member.agent_identity.as_str()),
401        role: Some(member.role.as_str()),
402        labels: Some(&member.labels),
403    }
404}
405
406fn filter_snapshot_for_access(snapshot: &mut ConsoleLiveSnapshot, view: &AccessView) {
407    if !view.enforced() {
408        return;
409    }
410    snapshot.members.retain(|member| {
411        view.decide(
412            crate::access::ACTION_AGENT_VIEW,
413            &member_access_resource(member),
414        )
415        .is_allow()
416    });
417    snapshot.agents.retain(|agent| {
418        let identity = agent.identity.as_deref().unwrap_or(agent.agent_id.as_str());
419        view.allows_agent(crate::access::ACTION_AGENT_VIEW, identity)
420    });
421    // Module IDs double as module-agent sidebar rows when no roster
422    // member survives filtering; gate them like any other agent so a
423    // fully-denied caller is not shown the module fallback.
424    snapshot
425        .loaded_modules
426        .retain(|module_id| view.allows_agent(crate::access::ACTION_AGENT_VIEW, module_id));
427}
428
429/// Intersect the projected experience with the caller's grants and append
430/// the `access` section the console UI consumes.
431fn apply_access_to_experience(body: &mut Value, view: &AccessView) {
432    if view.enforced() {
433        let mut any_can_send = false;
434        let mut any_can_retire = false;
435        if let Some(agents) = body
436            .pointer_mut("/agent_sidebar/live_snapshot/agents")
437            .and_then(Value::as_array_mut)
438        {
439            for agent in agents {
440                let identity = agent
441                    .get("identity")
442                    .or_else(|| agent.get("member_id"))
443                    .or_else(|| agent.get("agent_id"))
444                    .and_then(Value::as_str)
445                    .unwrap_or_default()
446                    .to_string();
447                let can_send =
448                    view.allows_agent(crate::access::ACTION_AGENT_SEND, identity.as_str());
449                let can_retire =
450                    view.allows_agent(crate::access::ACTION_AGENT_RETIRE, identity.as_str());
451                let can_respawn =
452                    view.allows_agent(crate::access::ACTION_AGENT_RESPAWN, identity.as_str());
453                if let Some(affordances) =
454                    agent.get_mut("affordances").and_then(Value::as_object_mut)
455                {
456                    intersect_bool(affordances, "can_send_message", can_send);
457                    intersect_bool(affordances, "can_retire", can_retire);
458                    intersect_bool(affordances, "can_respawn", can_respawn);
459                    any_can_send |= affordances
460                        .get("can_send_message")
461                        .and_then(Value::as_bool)
462                        .unwrap_or(false);
463                    any_can_retire |= affordances
464                        .get("can_retire")
465                        .and_then(Value::as_bool)
466                        .unwrap_or(false);
467                } else {
468                    any_can_send |= can_send;
469                    any_can_retire |= can_retire;
470                }
471            }
472        }
473        if let Some(capabilities) = body
474            .get_mut("runtime_capabilities")
475            .and_then(Value::as_object_mut)
476        {
477            intersect_bool(capabilities, "can_send_messages", any_can_send);
478            intersect_bool(capabilities, "can_retire_members", any_can_retire);
479            intersect_bool(
480                capabilities,
481                "can_spawn_members",
482                view.allows(crate::access::ACTION_AGENT_SPAWN),
483            );
484            intersect_bool(
485                capabilities,
486                "can_wire_members",
487                view.allows(crate::access::ACTION_RUNTIME_ADMIN),
488            );
489        }
490    }
491    if let Some(object) = body.as_object_mut() {
492        object.insert(
493            "access".to_string(),
494            serde_json::json!({
495                "available": true,
496                "enabled": view.enforced(),
497                "subject": view.subject(),
498                "groups": view.groups().iter().collect::<Vec<_>>(),
499                "can_administer": view.can_administer(),
500            }),
501        );
502    }
503}
504
505fn intersect_bool(object: &mut serde_json::Map<String, Value>, key: &str, allowed: bool) {
506    let current = object.get(key).and_then(Value::as_bool).unwrap_or(false);
507    object.insert(key.to_string(), Value::Bool(current && allowed));
508}
509
510fn default_console_live_snapshot(decisions: &RuntimeDecisionState) -> ConsoleLiveSnapshot {
511    let loaded_modules = decisions
512        .modules
513        .iter()
514        .map(|module| module.id.clone())
515        .collect::<Vec<_>>();
516    let agents = loaded_modules
517        .iter()
518        .map(|module_id| ConsoleAgentLiveSnapshot {
519            agent_id: module_id.clone(),
520            member_id: module_id.clone(),
521            label: module_id.clone(),
522            kind: "module_agent".to_string(),
523            identity: None,
524            role: None,
525            state: Some("idle".to_string()),
526            session_id: None,
527            model_capabilities: ConsoleModelCapabilities::default(),
528            response_phase: None,
529            watched: None,
530            alert_level: None,
531            degraded: None,
532            degraded_reason: None,
533        })
534        .collect::<Vec<_>>();
535    ConsoleLiveSnapshot::new(
536        None,
537        !decisions.modules.is_empty(),
538        loaded_modules,
539        agents,
540        Vec::new(),
541        false,
542    )
543}
544
545fn build_console_experience_contract(
546    modules: &[String],
547    live_snapshot: &ConsoleLiveSnapshot,
548    console_policy: &ConsolePolicy,
549) -> Value {
550    let console_config = &console_policy.ui;
551    let is_read_only = console_policy.read_only;
552    let is_aggregate_console = live_snapshot.runtime_id.as_deref() == Some("console-aggregator");
553    fn has_extended_agent_contract(agent: &ConsoleAgentLiveSnapshot) -> bool {
554        agent.role.is_some()
555            || agent.session_id.is_some()
556            || agent.model_capabilities != ConsoleModelCapabilities::default()
557            || agent.response_phase.is_some()
558            || agent.watched.is_some()
559            || agent.alert_level.is_some()
560            || agent.degraded.is_some()
561            || agent.degraded_reason.is_some()
562            || agent.kind != "module_agent"
563            || agent.member_id != agent.agent_id
564            || agent.label != agent.agent_id
565    }
566
567    let module_panels = modules
568        .iter()
569        .map(|module_id| {
570            serde_json::json!({
571                "panel_id": format!("module.{module_id}"),
572                "module_id": module_id,
573                "title": format!("{module_id} module"),
574                "route": format!("/console/modules/{module_id}"),
575                "capabilities": {
576                    "can_render": true,
577                    "can_subscribe_activity": true,
578                }
579            })
580        })
581        .collect::<Vec<_>>();
582
583    // P0 fix: Build sidebar from the full mob roster (members) when a mob
584    // runtime is present, so multi-instance profiles (e.g. 5 profiles → 15
585    // agents) enumerate every individual agent, not just profile-level IDs.
586    // Fall back to loaded_modules for module-only runtimes.
587    let has_roster_members = live_snapshot.has_mob_runtime && !live_snapshot.members.is_empty();
588    let sidebar_agents: Vec<Value> = if has_roster_members {
589        let mut sorted_members: Vec<&ConsoleMember> = live_snapshot.members.iter().collect();
590        sorted_members.sort_by(|a, b| a.agent_identity.cmp(&b.agent_identity));
591        sorted_members
592                .iter()
593                .map(|member| {
594                    let console_identity = console_member_console_identity(member);
595                    let label = member
596                        .labels
597                        .get("display_name")
598                        .cloned()
599                        .unwrap_or_else(|| member.agent_identity.clone());
600                    let is_active = member.state == "active";
601                    let addressable = is_active
602                        && member
603                        .labels
604                        .get("addressable")
605                        .map(|v| v != "false")
606                        .unwrap_or(true);
607                    let can_send_message = addressable && !is_read_only;
608                    let watched = member
609                        .labels
610                        .get("console_watched")
611                        .map(|value| value == "true");
612                    let alert_level = member
613                        .labels
614                        .get("console_alert_level")
615                        .filter(|value| matches!(value.as_str(), "elevated" | "critical"))
616                        .cloned();
617                    let degraded = member
618                        .labels
619                        .get("console_degraded")
620                        .map(|value| value == "true");
621                    let degraded_reason = member.labels.get("console_degraded_reason").cloned();
622                    let singleton = member
623                        .labels
624                        .get("singleton")
625                        .map(|v| v == "true")
626                        .unwrap_or(false);
627                    let group = member
628                        .labels
629                        .get("group")
630                        .cloned()
631                        .unwrap_or_else(|| member.role.clone());
632                    serde_json::json!({
633                        "agent_id": member.agent_identity,
634                        "member_id": member.agent_identity,
635                        "identity": console_identity,
636                        "label": label,
637                        "kind": "mob_agent",
638                        "role": member.role,
639                        "state": member.state,
640                        "progress": member.progress,
641                        "model_capabilities": member.model_capabilities,
642                        "session_id": member.session_id,
643                        "wired_to": member.wired_to,
644                        "labels": member.labels,
645                        "group": group,
646                        "addressable": addressable,
647                        "watched": watched,
648                        "alertLevel": alert_level,
649                        "degraded": degraded,
650                        "degradedReason": degraded_reason,
651                        "affordances": {
652                            "addressable": addressable,
653                            "can_send_message": can_send_message,
654                            "can_retire": is_active && !is_read_only && !is_aggregate_console && !singleton,
655                            "can_respawn": !is_read_only && !is_aggregate_console,
656                            "runtime_mode": if is_aggregate_console { "console_aggregator" } else { "mob_agent" },
657                        },
658                    })
659                })
660                .collect()
661    } else {
662        live_snapshot
663            .loaded_modules
664            .iter()
665            .map(|module_id| {
666                serde_json::json!({
667                    "agent_id": module_id,
668                    "member_id": module_id,
669                    "label": module_id,
670                    "kind": "module_agent",
671                })
672            })
673            .collect()
674    };
675
676    let sidebar_agents: Vec<Value> = if has_roster_members {
677        sidebar_agents
678    } else if live_snapshot.agents.iter().any(has_extended_agent_contract) {
679        live_snapshot
680            .agents
681            .iter()
682            .map(|agent| {
683                let mut record = serde_json::Map::new();
684                record.insert(
685                    "agent_id".to_string(),
686                    Value::String(agent.agent_id.clone()),
687                );
688                record.insert(
689                    "member_id".to_string(),
690                    Value::String(agent.member_id.clone()),
691                );
692                record.insert("label".to_string(), Value::String(agent.label.clone()));
693                record.insert("kind".to_string(), Value::String(agent.kind.clone()));
694                if let Some(role) = &agent.role {
695                    record.insert("role".to_string(), Value::String(role.clone()));
696                }
697                if let Some(state) = &agent.state {
698                    record.insert("state".to_string(), Value::String(state.clone()));
699                }
700                if let Some(identity) = &agent.identity {
701                    record.insert("identity".to_string(), Value::String(identity.clone()));
702                }
703                if let Some(session_id) = &agent.session_id {
704                    record.insert("session_id".to_string(), Value::String(session_id.clone()));
705                }
706                record.insert(
707                    "model_capabilities".to_string(),
708                    serde_json::to_value(&agent.model_capabilities).unwrap_or(Value::Null),
709                );
710                if let Some(response_phase) = &agent.response_phase {
711                    record.insert(
712                        "response_phase".to_string(),
713                        Value::String(response_phase.clone()),
714                    );
715                }
716                if is_aggregate_console {
717                    record.insert("addressable".to_string(), Value::Bool(true));
718                    record.insert(
719                        "affordances".to_string(),
720                        serde_json::json!({
721                            "addressable": true,
722                            "can_send_message": !is_read_only,
723                            "can_retire": false,
724                            "can_respawn": false,
725                            "runtime_mode": "console_aggregator",
726                        }),
727                    );
728                }
729                if let Some(watched) = agent.watched {
730                    record.insert("watched".to_string(), Value::Bool(watched));
731                }
732                if let Some(alert_level) = &agent.alert_level {
733                    record.insert("alertLevel".to_string(), Value::String(alert_level.clone()));
734                }
735                if let Some(degraded) = agent.degraded {
736                    record.insert("degraded".to_string(), Value::Bool(degraded));
737                }
738                if let Some(degraded_reason) = &agent.degraded_reason {
739                    record.insert(
740                        "degradedReason".to_string(),
741                        Value::String(degraded_reason.clone()),
742                    );
743                }
744                Value::Object(record)
745            })
746            .collect()
747    } else {
748        sidebar_agents
749    };
750
751    let has_mob = live_snapshot.has_mob_runtime && !is_aggregate_console;
752    let can_send_messages = !is_read_only && (has_mob || is_aggregate_console);
753    let can_spawn_members = !is_read_only && has_mob && !is_aggregate_console;
754    let can_wire_members = !is_read_only && has_mob && !is_aggregate_console;
755    let can_retire_members = !is_read_only && has_mob && !is_aggregate_console;
756    let identity_status_rows = build_identity_status_rows(&sidebar_agents);
757
758    // P3: Build per-profile capability hints from roster data.
759    let profile_capabilities: BTreeMap<String, Value> = {
760        let mut profiles: BTreeMap<String, (usize, bool, bool)> = BTreeMap::new();
761        for member in &live_snapshot.members {
762            let entry = profiles
763                .entry(member.role.clone())
764                .or_insert((0, true, false));
765            entry.0 += 1; // instance_count
766            // addressable = all instances addressable
767            let member_addressable = member
768                .labels
769                .get("addressable")
770                .map(|v| v != "false")
771                .unwrap_or(true);
772            entry.1 = entry.1 && member_addressable;
773            // has_wiring = any instance wired
774            entry.2 = entry.2 || !member.wired_to.is_empty();
775        }
776        profiles
777            .into_iter()
778            .map(|(profile, (count, addressable, has_wiring))| {
779                (
780                    profile,
781                    serde_json::json!({
782                        "instance_count": count,
783                        "addressable": addressable,
784                        "has_wiring": has_wiring,
785                    }),
786                )
787            })
788            .collect()
789    };
790
791    let console_title = console_config.title.as_deref().unwrap_or("Mob Console");
792    let mut body = serde_json::json!({
793        "contract_version": MOBKIT_CONTRACT_VERSION,
794        "runtime_id": live_snapshot.runtime_id,
795        "console_config": console_config,
796        "runtime_capabilities": {
797            "can_spawn_members": can_spawn_members,
798            "can_send_messages": can_send_messages,
799            "can_wire_members": can_wire_members,
800            "can_retire_members": can_retire_members,
801            "available_spawn_modes": if can_spawn_members {
802                vec!["module", "role"]
803            } else {
804                vec!["module"]
805            },
806            "profile_capabilities": profile_capabilities,
807        },
808        "base_panel": {
809            "panel_id": "console.home",
810            "title": console_title,
811            "route": CONSOLE_EXPERIENCE_ROUTE,
812            "capabilities": {
813                "can_render": true,
814                "surface": "console",
815            }
816        },
817        "module_panels": module_panels,
818        "agent_sidebar": {
819            "panel_id": "console.agent_sidebar",
820            "title": "Agents",
821            "schema_version": "1",
822            "refresh": {
823                "mode": "poll",
824                "interval_ms": 5000,
825            },
826            "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else if has_mob { "mobkit/list_members" } else { "mobkit/status" },
827            "refresh_policy": {
828                "mode": "pull",
829                "poll_interval_ms": 5000,
830            },
831            "selection_contract": {
832                "selected_agent_id_field": "agent_id",
833                "selected_member_id_field": "member_id",
834                "emits_scope": "agent",
835                "supported_scopes": ["mob", "agent"],
836            },
837            "list_item_contract": {
838                "fields": ["agent_id", "member_id", "identity", "label", "kind", "role", "state", "progress", "model_capabilities", "response_phase", "wired_to", "labels", "group", "addressable", "affordances", "watched", "alertLevel", "degraded", "degradedReason"],
839                "agent_id_field": "agent_id",
840                "member_id_field": "member_id",
841                "group_by_field": "group",
842                "well_known_labels": {
843                    "display_name": "human-readable label; overrides member_id in sidebar",
844                    "addressable": "set \"false\" to hide send-message actions for internal agents",
845                    "singleton": "set \"true\" to prevent retire (e.g. review, summarizer agents)",
846                    "group": "sidebar group name; overrides profile-based grouping",
847                },
848                "refresh_projection": "source_method returns ConsoleMember rows (agent_identity, role, state, model_capabilities, wired_to, labels). Clients must project: agent_id=agent_identity, member_id=agent_identity, identity=labels.agent_identity||agent_identity, label=labels.display_name||agent_identity, group=labels.group||role, addressable=labels.addressable!='false', model_capabilities.image_input default false, affordances derived from labels.singleton and addressable.",
849            },
850            "live_snapshot": {
851                "agents": sidebar_agents,
852            }
853        },
854        "identity_status": {
855            "panel_id": "console.identity_status",
856            "title": "Identity Status",
857            "schema_version": "1",
858            "refresh": {
859                "mode": "poll",
860                "interval_ms": 5000,
861            },
862            "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else if has_mob { "mobkit/list_members" } else { "mobkit/status" },
863            "rows": identity_status_rows,
864        },
865        "activity_feed": {
866            "panel_id": "console.activity_feed",
867            "title": "Activity",
868            "schema_version": "1",
869            "refresh": {
870                "mode": "stream",
871                "topic": "all_events",
872                "update_semantics": "append",
873            },
874            "transport": "sse",
875            "source_route": "/console/timeline/stream",
876            "request_contract": {
877                "last_event_id_header": "optional Last-Event-ID checkpoint from prior event_id",
878            },
879            "event_contract": {
880                "envelope_fields": ["event_id", "interaction_id", "identity", "event_type", "timestamp_ms", "data"],
881                "event_type_path": "event_type",
882                "frame_format": "id: <event_id>\\nevent: <event_type>\\ndata: <event_json>\\n\\n",
883            },
884            "keep_alive": {
885                "interval_ms": SSE_KEEP_ALIVE_INTERVAL_MS,
886                "event": SSE_KEEP_ALIVE_EVENT_NAME,
887                "comment_frame": SSE_KEEP_ALIVE_COMMENT_FRAME,
888            },
889            "filter_presets": [
890                { "id": "all", "label": "All" },
891                { "id": "watched-only", "label": "Watched only", "watchedOnly": true },
892                { "id": "critical", "label": "Critical", "alertLevels": ["critical"] }
893            ],
894            "active_preset_id": "all"
895        },
896        "chat_inspector": {
897            "panel_id": "console.chat_inspector",
898            "title": "Chat Inspector",
899            "schema_version": "1",
900            "refresh": {
901                "mode": "stream",
902                "topic": "selected_identity",
903                "update_semantics": "append",
904            },
905            "send_method": "mobkit/console/send",
906            "observe_route": "/console/timeline/stream",
907            "transport": "rpc+sse",
908            "request_contract": {
909                "identity": "required target identity",
910                "content": "required user text to send",
911                "origin": "required caller identifier for audit and routing",
912            },
913            "response_contract": {
914                "interaction_id": "per-turn console correlation token",
915                "identity": "echoed target identity",
916            },
917            "event_contract": {
918                "envelope_fields": ["event_id", "interaction_id", "identity", "event_type", "timestamp_ms", "data"],
919                "event_type_path": "event_type",
920                "interaction_id_field": "interaction_id",
921            }
922        },
923        "topology": {
924            "panel_id": "console.topology",
925            "title": "Topology",
926            "schema_version": "1",
927            "refresh": {
928                "mode": "poll",
929                "interval_ms": 5000,
930            },
931            "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else { "mobkit/status" },
932            "route_method": if is_aggregate_console { Value::Null } else { serde_json::json!("mobkit/routing/routes/list") },
933            "refresh_policy": {
934                "mode": "pull",
935                "poll_interval_ms": 5000,
936            },
937            "graph_contract": {
938                "node_id_field": "identity",
939                "edge_fields": ["wired_to"],
940            },
941            "live_snapshot": if live_snapshot.members.is_empty() {
942                // Module-only runtime: topology nodes are loaded module IDs.
943                serde_json::json!({
944                    "nodes": &live_snapshot.loaded_modules,
945                    "node_count": live_snapshot.loaded_modules.len(),
946                })
947            } else {
948                // Mob runtime: identity-native topology from members.
949                serde_json::json!({
950                    "nodes": live_snapshot.members.iter().map(|member| {
951                        let addressable = member.state == "active" && member
952                            .labels
953                            .get("addressable")
954                            .map(|value| value != "false")
955                            .unwrap_or(true);
956                        serde_json::json!({
957                            "identity": member.agent_identity,
958                            "label": member.labels.get("display_name").cloned().unwrap_or_else(|| member.agent_identity.clone()),
959                            "role": member.role,
960                            "state": member.state,
961                            "wired_to": member.wired_to,
962                            "addressable": addressable,
963                        })
964                    }).collect::<Vec<_>>(),
965                    "node_count": live_snapshot.members.len(),
966                })
967            }
968        },
969        "health_overview": {
970            "panel_id": "console.health_overview",
971            "title": "Health",
972            "schema_version": "1",
973            "refresh": {
974                "mode": "poll",
975                "interval_ms": 5000,
976            },
977            "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else { "mobkit/status" },
978            "activity_source_method": if is_aggregate_console { Value::Null } else { serde_json::json!(EVENTS_SUBSCRIBE_METHOD) },
979            "activity_source_route": if is_aggregate_console { serde_json::json!("/console/timeline/stream") } else { Value::Null },
980            "refresh_policy": {
981                "mode": "pull_and_stream",
982                "poll_interval_ms": 5000,
983            },
984            "status_contract": {
985                "running_field": "running",
986                "loaded_modules_field": "loaded_modules",
987            },
988            "live_snapshot": {
989                "running": live_snapshot.running,
990                "loaded_modules": &live_snapshot.loaded_modules,
991                "loaded_module_count": &live_snapshot.loaded_modules.len(),
992                "identities": identity_status_rows,
993            }
994        },
995        "flows": if is_aggregate_console {
996            serde_json::json!({
997                "panel_id": "console.flows",
998                "title": "Flows",
999                "schema_version": "1",
1000                "available": false,
1001                "reason": "flow scheduling is not exposed by the console aggregator surface",
1002            })
1003        } else {
1004            serde_json::json!({
1005                "panel_id": "console.flows",
1006                "title": "Flows",
1007                "schema_version": "1",
1008                "refresh": {
1009                    "mode": "poll",
1010                    "interval_ms": 10000,
1011                },
1012                "evaluate_method": "mobkit/scheduling/evaluate",
1013                "dispatch_method": "mobkit/scheduling/dispatch",
1014                "refresh_policy": {
1015                    "mode": "pull",
1016                    "poll_interval_ms": 10000,
1017                },
1018                "request_contract": {
1019                    "schedules": "caller-supplied array of ScheduleDefinition (schedule_id, interval|cron, timezone, enabled)",
1020                    "tick_ms": "evaluation timestamp in epoch milliseconds",
1021                },
1022                "evaluate_response_contract": {
1023                    "tick_ms": "u64 - echoed evaluation tick",
1024                    "due_triggers": "array of ScheduleTrigger {schedule_id, interval, timezone, due_tick_ms}",
1025                },
1026                "dispatch_response_contract": {
1027                    "tick_ms": "u64 - echoed dispatch tick",
1028                    "due_count": "usize - number of triggers that were due",
1029                    "dispatched": "array of ScheduleDispatch {claim_key, schedule_id, interval, timezone, due_tick_ms, tick_ms, event_id, supervisor_signal?, runtime_injection?, runtime_injection_error?}",
1030                    "skipped_claims": "array of schedule_id strings skipped due to idempotent claim",
1031                },
1032                "note": "Flows require caller-supplied schedule definitions; the runtime does not persist a flow registry. Clients must maintain their own schedule configs."
1033            })
1034        },
1035        "session_history": if has_mob {
1036            serde_json::json!({
1037                "panel_id": "console.session_history",
1038                "title": "Session History",
1039                "schema_version": "1",
1040                "refresh": {
1041                    "mode": "poll",
1042                    "interval_ms": 5000,
1043                },
1044                "source_method": "mobkit/console/query_timeline",
1045                "transport": "rpc",
1046                "available": true,
1047                "request_contract": {
1048                    "session_id": "required current session id for the identity/member",
1049                    "offset": "optional message offset from the start of the transcript",
1050                    "limit": "optional max messages to return",
1051                },
1052                "response_contract": {
1053                    "success": "result is SessionHistoryPage {session_id, message_count, offset, limit, has_more, messages}",
1054                }
1055            })
1056        } else {
1057            serde_json::json!({
1058                "panel_id": "console.session_history",
1059                "title": "Session History",
1060                "schema_version": "1",
1061                "refresh": {
1062                    "mode": "poll",
1063                    "interval_ms": 5000,
1064                },
1065                "available": false,
1066                "reason": "session history is projected through the console timeline",
1067            })
1068        }
1069    });
1070    if let Some(fetch_timeout_ms) = console_policy.fetch_timeout_ms {
1071        body["console_policy"] = serde_json::json!({
1072            "fetch_timeout_ms": fetch_timeout_ms,
1073        });
1074    }
1075    if is_read_only && let Some(object) = body.as_object_mut() {
1076        let policy = object
1077            .entry("console_policy")
1078            .or_insert_with(|| serde_json::json!({}));
1079        policy["read_only"] = Value::Bool(true);
1080    }
1081    body
1082}
1083
1084fn build_identity_status_rows(sidebar_agents: &[Value]) -> Vec<Value> {
1085    sidebar_agents
1086        .iter()
1087        .map(|agent| {
1088            let identity = agent
1089                .get("identity")
1090                .and_then(Value::as_str)
1091                .or_else(|| agent.get("member_id").and_then(Value::as_str))
1092                .unwrap_or_default();
1093            let display_name = agent
1094                .get("label")
1095                .and_then(Value::as_str)
1096                .filter(|label| *label != identity);
1097            let role = agent.get("role").and_then(Value::as_str);
1098            let state = agent
1099                .get("state")
1100                .and_then(Value::as_str)
1101                .unwrap_or("unknown");
1102            let labels = agent
1103                .get("labels")
1104                .cloned()
1105                .unwrap_or_else(|| serde_json::json!({}));
1106            let addressability = if agent
1107                .get("addressable")
1108                .and_then(Value::as_bool)
1109                .unwrap_or(true)
1110            {
1111                "addressable"
1112            } else {
1113                "internal_only"
1114            };
1115            let mut row = serde_json::json!({
1116                "identity": identity,
1117                "state": state,
1118                "addressability": addressability,
1119                "labels": labels,
1120            });
1121            if let Some(display_name) = display_name {
1122                row["display_name"] = Value::String(display_name.to_string());
1123            }
1124            if let Some(role) = role {
1125                row["role"] = Value::String(role.to_string());
1126            }
1127            if let Some(generation) = agent.get("generation").and_then(Value::as_u64) {
1128                row["generation"] = Value::from(generation);
1129            }
1130            if let Some(checkpoint_version) =
1131                agent.get("checkpoint_version").and_then(Value::as_u64)
1132            {
1133                row["checkpoint_version"] = Value::from(checkpoint_version);
1134            }
1135            if let Some(lease_healthy) = agent.get("lease_healthy").and_then(Value::as_bool) {
1136                row["lease_healthy"] = Value::from(lease_healthy);
1137            }
1138            // Machine-owned liveness projection (meerkat 0.7.29+, ask 14).
1139            if let Some(progress) = agent.get("progress").filter(|value| !value.is_null()) {
1140                row["progress"] = progress.clone();
1141            }
1142            if let Some(response_phase) = agent.get("response_phase").and_then(Value::as_str) {
1143                row["response_phase"] = Value::String(response_phase.to_string());
1144            }
1145            if let Some(model_capabilities) = agent.get("model_capabilities") {
1146                row["model_capabilities"] = model_capabilities.clone();
1147            }
1148            if let Some(session_id) = agent.get("session_id").and_then(Value::as_str) {
1149                row["session_id"] = Value::String(session_id.to_string());
1150            }
1151            row
1152        })
1153        .collect()
1154}
1155
1156fn split_path_and_query(path: &str) -> (&str, BTreeMap<String, String>) {
1157    let (base, query) = path.split_once('?').unwrap_or((path, ""));
1158    let mut params = BTreeMap::new();
1159    for part in query.split('&') {
1160        if part.is_empty() {
1161            continue;
1162        }
1163        let (k, v) = part.split_once('=').unwrap_or((part, ""));
1164        if !k.is_empty() {
1165            params.insert(k.to_string(), v.to_string());
1166        }
1167    }
1168    (base, params)
1169}
1170
1171fn resolve_console_auth(
1172    decisions: &RuntimeDecisionState,
1173    explicit_auth: Option<&ConsoleAccessRequest>,
1174    query_params: &BTreeMap<String, String>,
1175) -> Result<Option<ConsoleAccessRequest>, ConsoleAuthResolutionError> {
1176    if let Some(auth) = explicit_auth {
1177        return Ok(Some(auth.clone()));
1178    }
1179
1180    if !decisions.console.require_app_auth {
1181        return Ok(None);
1182    }
1183
1184    // Check query-param auth_token (also used as the bearer-header injection
1185    // point by the HTTP handler — see console_json_handler).
1186    match query_params.get("auth_token") {
1187        Some(token) => resolve_console_auth_from_token(decisions, token).map(Some),
1188        None => Ok(None),
1189    }
1190}
1191
1192/// Extract a bearer token from an `Authorization: Bearer <token>` header value.
1193pub fn extract_bearer_token_from_header(header_value: &str) -> Option<&str> {
1194    let token = header_value.strip_prefix("Bearer ")?;
1195    if token.is_empty() { None } else { Some(token) }
1196}
1197
1198/// Validate a bearer token against the console's trusted OIDC config AND
1199/// the email allowlist / provider policy (via `enforce_console_route_access`).
1200/// Returns `true` only if the token is valid AND the caller is authorized.
1201pub fn validate_console_token(decisions: &RuntimeDecisionState, token: &str) -> bool {
1202    resolve_authorized_console_auth_from_token(decisions, token).is_some()
1203}
1204
1205pub(crate) fn resolve_authorized_console_auth_from_token(
1206    decisions: &RuntimeDecisionState,
1207    token: &str,
1208) -> Option<ConsoleAccessRequest> {
1209    let auth = resolve_console_auth_from_token(decisions, token).ok()?;
1210    crate::decisions::enforce_console_route_access(&decisions.auth, &decisions.console, &auth)
1211        .ok()?;
1212    Some(auth)
1213}
1214
1215fn resolve_console_auth_from_token(
1216    decisions: &RuntimeDecisionState,
1217    token: &str,
1218) -> Result<ConsoleAccessRequest, ConsoleAuthResolutionError> {
1219    if decisions.trusted_oidc.audience.trim().is_empty() {
1220        return Err(ConsoleAuthResolutionError::InvalidTrustedOidcConfig);
1221    }
1222
1223    let discovery = parse_oidc_discovery_json(&decisions.trusted_oidc.discovery_json)
1224        .map_err(|_| ConsoleAuthResolutionError::InvalidTrustedOidcConfig)?;
1225    let jwks = parse_jwks_json(&decisions.trusted_oidc.jwks_json)
1226        .map_err(|_| ConsoleAuthResolutionError::InvalidTrustedOidcConfig)?;
1227    let header =
1228        inspect_jwt_header(token).map_err(|_| ConsoleAuthResolutionError::InvalidTokenHeader)?;
1229
1230    if header.alg == "HS256"
1231        && !hs256_allowed_for_development_issuer(&discovery.issuer, &discovery.jwks_uri)
1232    {
1233        return Err(ConsoleAuthResolutionError::Hs256NotAllowed);
1234    }
1235
1236    let key = select_jwk_for_token(&jwks, header.kid.as_deref(), &header.alg)
1237        .map_err(|_| ConsoleAuthResolutionError::JwksKeyNotFound)?;
1238    let verification_key = build_jwt_verification_key(key, &header.alg)
1239        .map_err(|_| ConsoleAuthResolutionError::InvalidJwksKeyMaterial)?;
1240
1241    let now_epoch_seconds = SystemTime::now()
1242        .duration_since(UNIX_EPOCH)
1243        .unwrap_or_default()
1244        .as_secs();
1245    let claims = validate_jwt_with_verification_key(
1246        token,
1247        &verification_key,
1248        &JwtClaimsValidationConfig {
1249            issuer: Some(discovery.issuer),
1250            audience: Some(decisions.trusted_oidc.audience.clone()),
1251            now_epoch_seconds,
1252            leeway_seconds: 30,
1253        },
1254    )
1255    .map_err(|_| ConsoleAuthResolutionError::InvalidToken)?;
1256
1257    let principal = claims
1258        .email
1259        .or(claims.subject)
1260        .ok_or(ConsoleAuthResolutionError::MissingTokenIdentity)?;
1261    let provider =
1262        if claims.actor_type.as_deref() == Some("service") || principal.starts_with("svc:") {
1263            AuthProvider::ServiceIdentity
1264        } else {
1265            match claims.provider.as_deref() {
1266                Some("google_oauth") => AuthProvider::GoogleOAuth,
1267                Some("github_oauth") => AuthProvider::GitHubOAuth,
1268                Some("generic_oidc") => AuthProvider::GenericOidc,
1269                _ => AuthProvider::GenericOidc,
1270            }
1271        };
1272
1273    Ok(ConsoleAccessRequest {
1274        provider,
1275        email: principal,
1276    })
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq)]
1280enum ConsoleAuthResolutionError {
1281    InvalidTrustedOidcConfig,
1282    InvalidTokenHeader,
1283    JwksKeyNotFound,
1284    InvalidJwksKeyMaterial,
1285    InvalidToken,
1286    MissingTokenIdentity,
1287    Hs256NotAllowed,
1288}
1289
1290fn console_auth_error_reason(error: &ConsoleAuthResolutionError) -> &'static str {
1291    match error {
1292        ConsoleAuthResolutionError::InvalidTrustedOidcConfig => "invalid_trusted_oidc_config",
1293        ConsoleAuthResolutionError::InvalidTokenHeader => "invalid_token_header",
1294        ConsoleAuthResolutionError::JwksKeyNotFound => "jwks_key_not_found",
1295        ConsoleAuthResolutionError::InvalidJwksKeyMaterial => "invalid_jwks_key_material",
1296        ConsoleAuthResolutionError::InvalidToken => "invalid_token",
1297        ConsoleAuthResolutionError::MissingTokenIdentity => "missing_token_identity",
1298        ConsoleAuthResolutionError::Hs256NotAllowed => "hs256_not_allowed",
1299    }
1300}
1301
1302fn hs256_allowed_for_development_issuer(issuer: &str, jwks_uri: &str) -> bool {
1303    match (extract_uri_host(issuer), extract_uri_host(jwks_uri)) {
1304        (Some(issuer_host), Some(jwks_host)) => {
1305            is_development_host(issuer_host) && is_development_host(jwks_host)
1306        }
1307        _ => false,
1308    }
1309}
1310
1311fn extract_uri_host(uri: &str) -> Option<&str> {
1312    let after_scheme = uri.split_once("://").map_or(uri, |(_, rest)| rest);
1313    let authority_with_path = after_scheme.split('/').next()?;
1314    let authority = authority_with_path
1315        .rsplit('@')
1316        .next()
1317        .unwrap_or(authority_with_path);
1318    if authority.is_empty() {
1319        return None;
1320    }
1321
1322    if let Some(stripped) = authority.strip_prefix('[') {
1323        let (ipv6_host, _) = stripped.split_once(']')?;
1324        return if ipv6_host.is_empty() {
1325            None
1326        } else {
1327            Some(ipv6_host)
1328        };
1329    }
1330
1331    let host = authority
1332        .split_once(':')
1333        .map_or(authority, |(hostname, _)| hostname);
1334    if host.is_empty() { None } else { Some(host) }
1335}
1336
1337fn is_development_host(host: &str) -> bool {
1338    let lowercase = host.to_ascii_lowercase();
1339    lowercase == "localhost"
1340        || lowercase == "127.0.0.1"
1341        || lowercase == "::1"
1342        || lowercase.ends_with(".localhost")
1343}
1344
1345fn auth_error_reason(error: &DecisionPolicyError) -> &'static str {
1346    match error {
1347        DecisionPolicyError::AuthProviderMismatch => "provider_mismatch",
1348        DecisionPolicyError::AuthProviderNotSupported => "provider_not_supported",
1349        DecisionPolicyError::EmailNotAllowlisted => "email_not_allowlisted",
1350        DecisionPolicyError::InvalidServiceIdentity => "invalid_service_identity",
1351        DecisionPolicyError::ServiceIdentityNotAllowlisted => "service_identity_not_allowlisted",
1352        _ => "policy_denied",
1353    }
1354}