feat(portal): add api contracts for future ui

This commit is contained in:
igor04091968
2026-06-05 08:18:12 +03:00
parent ec9de0e039
commit 5e6bbd47a1
8 changed files with 1163 additions and 65 deletions
+109 -39
View File
@@ -3,7 +3,9 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use clap::Parser;
use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue};
use reqwest::header::{
CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, LOCATION,
};
use serde_json::json;
use tiny_http::{Header, Request, Response, Server, StatusCode};
@@ -12,7 +14,7 @@ const APP_CSS: &str = include_str!("static/app.css");
const APP_JS: &str = include_str!("static/app.js");
#[derive(Clone, Debug, Parser)]
#[command(about = "DetMir DPD/Dioxus pilot portal")]
#[command(about = "DetMir DPD parallel portal gateway")]
struct Cli {
#[arg(long, default_value = "127.0.0.1:8722", env = "DETMIR_DPD_BIND")]
bind: String,
@@ -91,8 +93,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()> {
let method = reqwest::Method::from_bytes(request.method().as_str().as_bytes())
.map_err(|err| anyhow!("unsupported method {}: {err}", request.method()))?;
let content_type = request_header(&request, "Content-Type");
let accept = request_header(&request, "Accept");
let forwarded_headers = forwarded_request_headers(&request)?;
let mut body = Vec::new();
request
.as_reader()
@@ -110,41 +111,93 @@ fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()>
.request(method, &url)
.header(CONNECTION, HeaderValue::from_static("close"))
.body(body);
if let Some(value) = content_type {
upstream = upstream.header(CONTENT_TYPE, value);
}
if let Some(value) = accept {
upstream = upstream.header(ACCEPT, value);
for (name, value) in forwarded_headers {
upstream = upstream.header(name, value);
}
let upstream_response = upstream
.send()
.with_context(|| format!("proxy upstream {url}"))?;
let status = StatusCode(upstream_response.status().as_u16());
let content_type = upstream_response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let content_disposition = upstream_response
.headers()
.get(CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let response_headers = mirrored_response_headers(upstream_response.headers())?;
let mut bytes = upstream_response
.bytes()
.context("upstream response body")?
.to_vec();
rewrite_mirrored_body(&mut bytes, content_type.as_deref());
respond_bytes(request, status, bytes, content_type, content_disposition)
let content_type = response_headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("Content-Type"))
.map(|(_, value)| value.as_str());
rewrite_mirrored_body(&mut bytes, content_type);
respond_bytes(request, status, bytes, response_headers)
}
fn request_header(request: &Request, name: &'static str) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.equiv(name))
.map(|header| header.value.as_str().to_string())
fn forwarded_request_headers(request: &Request) -> Result<Vec<(HeaderName, String)>> {
let mut headers = Vec::new();
for header in request.headers() {
let name = header.field.as_str();
let lower = name.to_string().to_ascii_lowercase();
if is_hop_by_hop_header(&lower) {
continue;
}
if should_forward_header(&lower) {
let name = HeaderName::from_bytes(name.as_bytes())
.with_context(|| format!("invalid request header name {name}"))?;
headers.push((name, header.value.as_str().to_string()));
}
}
Ok(headers)
}
fn should_forward_header(lower_name: &str) -> bool {
matches!(
lower_name,
"accept"
| "accept-language"
| "authorization"
| "content-type"
| "cookie"
| "origin"
| "referer"
| "user-agent"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-gateway-user"
| "x-real-ip"
| "x-remote-user"
)
}
fn is_hop_by_hop_header(lower_name: &str) -> bool {
matches!(
lower_name,
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
)
}
fn mirrored_response_headers(headers: &HeaderMap) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for name in [CONTENT_TYPE, CONTENT_DISPOSITION, LOCATION] {
if let Some(value) = headers.get(&name) {
let mut value = value
.to_str()
.with_context(|| format!("invalid upstream {name} header"))?
.to_string();
if name == LOCATION {
value = rewrite_mirrored_text(&value);
}
out.push((name.as_str().to_string(), value));
}
}
Ok(out)
}
fn normalize_path_with_query(url: &str) -> String {
@@ -221,8 +274,7 @@ fn respond_bytes(
request: Request,
status: StatusCode,
body: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
response_headers: Vec<(String, String)>,
) -> Result<()> {
let mut response = Response::from_data(body)
.with_status_code(status)
@@ -230,16 +282,10 @@ fn respond_bytes(
Header::from_bytes("Cache-Control", "no-store")
.map_err(|_| anyhow!("invalid Cache-Control header"))?,
);
if let Some(value) = content_type {
for (name, value) in response_headers {
response = response.with_header(
Header::from_bytes("Content-Type", value)
.map_err(|_| anyhow!("invalid upstream Content-Type header"))?,
);
}
if let Some(value) = content_disposition {
response = response.with_header(
Header::from_bytes("Content-Disposition", value)
.map_err(|_| anyhow!("invalid upstream Content-Disposition header"))?,
Header::from_bytes(name, value)
.map_err(|_| anyhow!("invalid mirrored response header"))?,
);
}
request.respond(response)?;
@@ -259,7 +305,11 @@ fn rewrite_mirrored_body(body: &mut Vec<u8>, content_type: Option<&str>) {
if !text.contains("/portal") {
return;
}
*body = text.replace("/portal", "/dpd").into_bytes();
*body = rewrite_mirrored_text(text).into_bytes();
}
fn rewrite_mirrored_text(text: &str) -> String {
text.replace("/portal", "/dpd")
}
#[cfg(test)]
@@ -293,4 +343,24 @@ mod tests {
assert!(text.contains("/dpd/api/cases/c1"));
assert!(!text.contains("/portal/api"));
}
#[test]
fn forwards_operator_identity_headers_but_not_hop_by_hop_headers() {
assert!(should_forward_header("x-remote-user"));
assert!(should_forward_header("x-gateway-user"));
assert!(should_forward_header("authorization"));
assert!(should_forward_header("cookie"));
assert!(!should_forward_header("x-debug-private"));
assert!(is_hop_by_hop_header("connection"));
assert!(is_hop_by_hop_header("transfer-encoding"));
assert!(!is_hop_by_hop_header("x-remote-user"));
}
#[test]
fn rewrites_portal_locations_for_dpd_mirror() {
assert_eq!(
rewrite_mirrored_text("/portal/reports?format=markdown"),
"/dpd/reports?format=markdown"
);
}
}
@@ -12,7 +12,7 @@
<div>
<p class="eyebrow">DetMir DPD</p>
<h1>Пилотный портал</h1>
<p class="lead">Параллельный read-only интерфейс для оценки будущего Dioxus-подхода.</p>
<p class="lead">Параллельный read-only интерфейс для оценки будущего React/Tauri-подхода.</p>
</div>
<div class="actions">
<button id="refreshButton" type="button">Обновить</button>
@@ -0,0 +1,584 @@
{
"openapi": "3.1.0",
"info": {
"title": "AWatch-rus DetMir Portal API",
"version": "2026-06-05.v1",
"description": "Stable additive API contract for the current HTML portal and future React/Tauri clients. Clients must ignore unknown fields and tolerate missing optional fields."
},
"servers": [
{
"url": "/api",
"description": "Gateway-relative API base"
}
],
"tags": [
{ "name": "contracts" },
{ "name": "portal" },
{ "name": "reports" },
{ "name": "incidents" },
{ "name": "cases" },
{ "name": "readiness" },
{ "name": "telemetry" }
],
"paths": {
"/contracts": {
"get": {
"tags": ["contracts"],
"summary": "Contract index",
"responses": {
"200": {
"description": "Contract index",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ContractIndex" }
}
}
}
}
}
},
"/contracts/openapi.json": {
"get": {
"tags": ["contracts"],
"summary": "OpenAPI document",
"responses": {
"200": {
"description": "OpenAPI 3.1 document",
"content": {
"application/json": {
"schema": { "type": "object", "additionalProperties": true }
}
}
}
}
}
},
"/contracts/typescript.d.ts": {
"get": {
"tags": ["contracts"],
"summary": "TypeScript declarations",
"responses": {
"200": {
"description": "TypeScript declaration file",
"content": {
"text/plain": {
"schema": { "type": "string" }
}
}
}
}
}
},
"/health": {
"get": {
"tags": ["portal"],
"summary": "Light service health",
"responses": {
"200": {
"description": "Health payload",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/operator": {
"get": {
"tags": ["portal"],
"summary": "Operator overview payload",
"responses": {
"200": {
"description": "Operator payload used by the HTML portal",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/manager": {
"get": {
"tags": ["portal"],
"summary": "Manager payload",
"responses": {
"200": {
"description": "Manager payload",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/owner": {
"get": {
"tags": ["portal"],
"summary": "Owner/security payload",
"responses": {
"200": {
"description": "Owner payload",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/reports": {
"get": {
"tags": ["reports"],
"summary": "Management report payload",
"parameters": [
{
"name": "anonymize",
"in": "query",
"required": false,
"schema": { "type": "boolean" },
"description": "Return anonymized values when supported"
}
],
"responses": {
"200": {
"description": "Report payload",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ReportPayload" }
}
}
}
}
}
},
"/workforce/policy/explain": {
"get": {
"tags": ["reports"],
"summary": "Workforce scoring policy explanation",
"parameters": [
{
"name": "anonymize",
"in": "query",
"required": false,
"schema": { "type": "boolean" }
}
],
"responses": {
"200": {
"description": "Policy explanation",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/incidents": {
"get": {
"tags": ["incidents"],
"summary": "Incident list and current state",
"responses": {
"200": {
"description": "Incident payload",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/incident-review": {
"post": {
"tags": ["incidents"],
"summary": "Set manual review status for an incident candidate",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/IncidentReviewRequest" }
}
}
},
"responses": {
"200": {
"description": "Updated review state",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/investigation-pack/{candidate_id}": {
"get": {
"tags": ["incidents"],
"summary": "Export investigation pack for a candidate",
"parameters": [
{
"name": "candidate_id",
"in": "path",
"required": true,
"schema": { "type": "string" }
},
{
"name": "format",
"in": "query",
"required": false,
"schema": { "type": "string", "enum": ["json", "markdown"] }
}
],
"responses": {
"200": {
"description": "Investigation pack",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
},
"text/markdown": {
"schema": { "type": "string" }
}
}
}
}
}
},
"/cases": {
"get": {
"tags": ["cases"],
"summary": "Case list",
"responses": {
"200": {
"description": "Case list",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CaseListResponse" }
}
}
}
}
},
"post": {
"tags": ["cases"],
"summary": "Create a manual case from a confirmed candidate",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateCaseRequest" }
}
}
},
"responses": {
"200": {
"description": "Created case",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/cases/{case_id}": {
"get": {
"tags": ["cases"],
"summary": "Case details",
"parameters": [
{
"name": "case_id",
"in": "path",
"required": true,
"schema": { "type": "string" }
},
{
"name": "format",
"in": "query",
"required": false,
"schema": { "type": "string", "enum": ["json", "markdown"] }
}
],
"responses": {
"200": {
"description": "Case details",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
},
"text/markdown": {
"schema": { "type": "string" }
}
}
}
}
}
},
"/cases/{case_id}/status": {
"post": {
"tags": ["cases"],
"summary": "Set manual case status",
"parameters": [
{
"name": "case_id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CaseStatusRequest" }
}
}
},
"responses": {
"200": {
"description": "Updated case",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/dlp/evidence": {
"get": {
"tags": ["incidents"],
"summary": "DLP evidence list",
"responses": {
"200": {
"description": "Evidence list",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/readiness/latest": {
"get": {
"tags": ["readiness"],
"summary": "Latest readiness bundle status",
"responses": {
"200": {
"description": "Readiness status",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/readiness/bundle": {
"get": {
"tags": ["readiness"],
"summary": "Readiness bundle artifact list",
"responses": {
"200": {
"description": "Readiness bundle",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/readiness/verify": {
"get": {
"tags": ["readiness"],
"summary": "Verify readiness checksums and signature",
"responses": {
"200": {
"description": "Verification result",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/links": {
"get": {
"tags": ["portal"],
"summary": "Gateway-relative portal links",
"responses": {
"200": {
"description": "Link map",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
}
}
}
},
"/telemetry": {
"post": {
"tags": ["telemetry"],
"summary": "Agent telemetry ingest",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
},
"responses": {
"200": {
"description": "Accepted telemetry",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/JsonObject" }
}
}
},
"401": {
"description": "Missing or invalid telemetry API key"
}
}
}
}
},
"components": {
"schemas": {
"JsonObject": {
"type": "object",
"additionalProperties": true
},
"ContractIndex": {
"type": "object",
"required": ["ok", "contract_version", "api_base", "artifacts", "stable_endpoints"],
"properties": {
"ok": { "type": "boolean" },
"contract_version": { "type": "string" },
"generated_by": { "type": "string" },
"api_base": { "type": "string" },
"compatibility": { "$ref": "#/components/schemas/JsonObject" },
"targets": { "type": "array", "items": { "type": "string" } },
"artifacts": { "$ref": "#/components/schemas/JsonObject" },
"stable_endpoints": {
"type": "array",
"items": { "$ref": "#/components/schemas/EndpointDescriptor" }
}
},
"additionalProperties": true
},
"EndpointDescriptor": {
"type": "object",
"required": ["method", "path"],
"properties": {
"method": { "type": "string" },
"path": { "type": "string" },
"purpose": { "type": "string" }
},
"additionalProperties": true
},
"ReportPayload": {
"type": "object",
"required": ["ok"],
"properties": {
"ok": { "type": "boolean" },
"generated_at_utc": { "type": "string" },
"executive_points": {
"type": "array",
"items": { "type": "string" }
},
"executive_dashboard": { "$ref": "#/components/schemas/JsonObject" },
"risk_narrative": { "$ref": "#/components/schemas/JsonObject" },
"agent_quality": { "$ref": "#/components/schemas/JsonObject" },
"agent_coverage_sla": { "$ref": "#/components/schemas/JsonObject" },
"business_risk": {
"type": "array",
"items": { "$ref": "#/components/schemas/JsonObject" }
},
"risk_incident_candidates": {
"type": "array",
"items": { "$ref": "#/components/schemas/JsonObject" }
},
"cases": {
"type": "array",
"items": { "$ref": "#/components/schemas/JsonObject" }
}
},
"additionalProperties": true
},
"IncidentReviewRequest": {
"type": "object",
"required": ["candidate_id", "status"],
"properties": {
"candidate_id": { "type": "string" },
"status": {
"type": "string",
"enum": ["NEW", "IN_REVIEW", "CONFIRMED", "FALSE_POSITIVE", "POSTPONED"]
},
"reviewer": { "type": "string" },
"comment": { "type": "string" }
},
"additionalProperties": true
},
"CreateCaseRequest": {
"type": "object",
"required": ["candidate_id"],
"properties": {
"candidate_id": { "type": "string" },
"title": { "type": "string" },
"owner": { "type": "string" },
"summary": { "type": "string" },
"decision": { "type": "string" }
},
"additionalProperties": true
},
"CaseStatusRequest": {
"type": "object",
"required": ["status"],
"properties": {
"status": {
"type": "string",
"enum": ["OPEN", "IN_PROGRESS", "RESOLVED", "REJECTED", "ARCHIVED"]
},
"decision": { "type": "string" }
},
"additionalProperties": true
},
"CaseListResponse": {
"type": "object",
"required": ["ok", "cases"],
"properties": {
"ok": { "type": "boolean" },
"cases": {
"type": "array",
"items": { "$ref": "#/components/schemas/JsonObject" }
}
},
"additionalProperties": true
}
}
}
}
@@ -0,0 +1,188 @@
export type ISODateTime = string;
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
export type ReviewStatus = "NEW" | "IN_REVIEW" | "CONFIRMED" | "FALSE_POSITIVE" | "POSTPONED";
export type CaseStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "REJECTED" | "ARCHIVED";
export interface JsonObject {
[key: string]: unknown;
}
export interface EndpointDescriptor {
method: string;
path: string;
purpose?: string;
[key: string]: unknown;
}
export interface ContractIndex {
ok: boolean;
contract_version: string;
generated_by?: string;
api_base: "/api" | string;
compatibility?: JsonObject;
targets?: string[];
artifacts: {
openapi: string;
typescript: string;
[key: string]: unknown;
};
stable_endpoints: EndpointDescriptor[];
[key: string]: unknown;
}
export interface ExecutiveDashboard {
trust_kpi_score?: number;
agent_coverage_pct?: number;
high_risk_departments?: number;
critical_candidates?: number;
open_cases?: number;
resolved_cases_30d?: number;
forensics_readiness?: string;
[key: string]: unknown;
}
export interface RiskNarrative {
status?: "NORMAL" | "ATTENTION" | "HIGH_RISK" | "CRITICAL" | string;
title?: string;
summary?: string;
main_reason?: string;
recommendation?: string;
supporting_layers?: JsonObject[];
[key: string]: unknown;
}
export interface AgentQuality {
collector_source?: string;
collector_error?: string | null;
sessions_collected_total?: number;
active_sessions_total?: number;
rdp_sessions_total?: number;
quality_status?: string;
[key: string]: unknown;
}
export interface AgentCoverageSla {
expected_nodes?: number;
reporting_nodes_24h?: number;
stale_nodes?: number;
missing_nodes?: number;
coverage_pct?: number;
freshness_pct?: number;
sla_status?: "OK" | "WARNING" | "CRITICAL" | "UNKNOWN" | string;
[key: string]: unknown;
}
export interface BusinessRiskItem {
department?: string;
trust_score?: number;
activity_score?: number;
trend?: string;
risk_level?: RiskLevel;
reasons?: string[];
recommendation?: string;
problem_nodes_count?: number;
missing_nodes_count?: number;
stale_nodes_count?: number;
[key: string]: unknown;
}
export interface IncidentCandidate {
id?: string;
department?: string;
owner?: string;
hostname?: string;
risk_level?: RiskLevel;
reason?: string;
evidence?: unknown;
first_seen_utc?: ISODateTime;
last_seen_utc?: ISODateTime;
recommendation?: string;
review?: IncidentReview;
[key: string]: unknown;
}
export interface IncidentReview {
candidate_id: string;
status: ReviewStatus;
reviewer?: string;
comment?: string;
updated_at?: ISODateTime;
[key: string]: unknown;
}
export interface IncidentReviewRequest {
candidate_id: string;
status: ReviewStatus;
reviewer?: string;
comment?: string;
[key: string]: unknown;
}
export interface CaseItem {
case_id?: string;
candidate_id?: string;
title?: string;
status?: CaseStatus;
owner?: string;
created_at_utc?: ISODateTime;
updated_at_utc?: ISODateTime;
summary?: string;
decision?: string;
[key: string]: unknown;
}
export interface CreateCaseRequest {
candidate_id: string;
title?: string;
owner?: string;
summary?: string;
decision?: string;
[key: string]: unknown;
}
export interface CaseStatusRequest {
status: CaseStatus;
decision?: string;
[key: string]: unknown;
}
export interface ReportsResponse {
ok: boolean;
generated_at_utc?: ISODateTime;
executive_points?: string[];
executive_dashboard?: ExecutiveDashboard;
risk_narrative?: RiskNarrative;
agent_quality?: AgentQuality;
agent_coverage_sla?: AgentCoverageSla;
business_risk?: BusinessRiskItem[];
risk_incident_candidates?: IncidentCandidate[];
cases?: CaseItem[];
[key: string]: unknown;
}
export interface CaseListResponse {
ok: boolean;
cases: CaseItem[];
[key: string]: unknown;
}
export interface DetMirPortalApi {
getContracts(): Promise<ContractIndex>;
getHealth(): Promise<JsonObject>;
getOperator(): Promise<JsonObject>;
getManager(): Promise<JsonObject>;
getOwner(): Promise<JsonObject>;
getReports(options?: { anonymize?: boolean }): Promise<ReportsResponse>;
getIncidents(): Promise<JsonObject>;
getCases(): Promise<CaseListResponse>;
createCase(request: CreateCaseRequest): Promise<JsonObject>;
setCaseStatus(caseId: string, request: CaseStatusRequest): Promise<JsonObject>;
setIncidentReview(request: IncidentReviewRequest): Promise<JsonObject>;
getInvestigationPack(candidateId: string, options?: { format?: "json" | "markdown" }): Promise<JsonObject | string>;
getDlpEvidence(): Promise<JsonObject>;
getReadinessLatest(): Promise<JsonObject>;
getReadinessBundle(): Promise<JsonObject>;
verifyReadiness(): Promise<JsonObject>;
getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise<JsonObject>;
}
+76
View File
@@ -26,6 +26,8 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
const INDEX_HTML: &str = include_str!("static/index.html");
const APP_CSS: &str = include_str!("static/app.css");
const APP_JS: &str = include_str!("static/app.js");
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5);
const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения";
@@ -1293,6 +1295,19 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
"application/javascript; charset=utf-8",
),
"/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"),
"/api/contracts" => respond_json(request, &api_contract_summary()),
"/api/contracts/openapi.json" => respond_text(
request,
StatusCode(200),
API_CONTRACT_OPENAPI,
"application/json; charset=utf-8",
),
"/api/contracts/typescript.d.ts" => respond_text(
request,
StatusCode(200),
API_CONTRACT_TYPESCRIPT,
"text/plain; charset=utf-8",
),
"/api/health" => respond_json(
request,
&build_health(&cached_snapshot(args, snapshot_cache)),
@@ -1421,6 +1436,42 @@ fn normalize_path(url: &str) -> String {
}
}
fn api_contract_summary() -> Value {
json!({
"ok": true,
"contract_version": "2026-06-05.v1",
"generated_by": "detmir-portal",
"api_base": "/api",
"compatibility": {
"policy": "additive",
"existing_html_portal": "unchanged",
"unknown_fields": "clients must ignore unknown fields",
"nullable_fields": "clients must tolerate null and missing optional fields"
},
"targets": ["current-html", "future-react", "future-tauri"],
"artifacts": {
"openapi": "/api/contracts/openapi.json",
"typescript": "/api/contracts/typescript.d.ts"
},
"stable_endpoints": [
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"}
]
})
}
fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
@@ -9157,6 +9208,31 @@ mod tests {
assert_eq!(safe_download_stem("risk:candidate/1"), "risk_candidate_1");
}
#[test]
fn api_contract_artifacts_are_valid_and_future_ui_ready() {
let openapi: Value =
serde_json::from_str(API_CONTRACT_OPENAPI).expect("OpenAPI contract must be JSON");
assert_eq!(openapi["openapi"], "3.1.0");
assert!(openapi["paths"]["/reports"].is_object());
assert!(openapi["paths"]["/incident-review"].is_object());
assert!(openapi["paths"]["/cases"].is_object());
assert!(API_CONTRACT_TYPESCRIPT.contains("export interface DetMirPortalApi"));
assert!(API_CONTRACT_TYPESCRIPT.contains("ReportsResponse"));
assert!(API_CONTRACT_TYPESCRIPT.contains("IncidentReviewRequest"));
let summary = api_contract_summary();
assert_eq!(summary["ok"], true);
assert_eq!(summary["api_base"], "/api");
assert_eq!(
summary["artifacts"]["openapi"],
"/api/contracts/openapi.json"
);
assert_eq!(
summary["artifacts"]["typescript"],
"/api/contracts/typescript.d.ts"
);
}
#[test]
fn links_are_gateway_relative() {
let links = links();