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