Skip to main content

eui_core/host/
contract.rs

1//! The host API contract (`catalog/host.json`) as constants, plus the client
2//! messages every platform host sends (`tests/host_fixtures.rs` gates the
3//! constants against the JSON).
4
5use serde_json::{json, Map, Value};
6
7use crate::catalog::A2UI_VERSION;
8
9pub const HOST_CONTRACT_VERSION: u32 = 3;
10/// The four A2UI v0.9 server messages.
11pub const A2UI_MESSAGE_KINDS: &[&str] = &["createSurface", "updateComponents", "updateDataModel", "deleteSurface"];
12/// The two Exponential UI extensions.
13pub const EXTENSION_MESSAGE_KINDS: &[&str] = &["applyTemplate", "bindDataModel"];
14/// Every message kind the router takes, A2UI first.
15pub const MESSAGE_KINDS: &[&str] = &["createSurface", "updateComponents", "updateDataModel", "deleteSurface", "applyTemplate", "bindDataModel"];
16/// What the router turns a message into.
17pub const OP_KINDS: &[&str] = &["create", "components", "data", "bind", "delete", "send"];
18pub const HOST_ERROR_CODES: &[&str] =
19    &["VALIDATION_FAILED", "INVALID_MESSAGE", "UNSUPPORTED_CATALOG", "SURFACE_NOT_FOUND", "TEMPLATE_NOT_FOUND", "FUNCTION_NOT_FOUND", "FUNCTION_DENIED", "SOURCE_DENIED", "RENDER_FAILED", "ACTION_FAILED"];
20pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED";
21pub const INVALID_MESSAGE: &str = "INVALID_MESSAGE";
22pub const UNSUPPORTED_CATALOG: &str = "UNSUPPORTED_CATALOG";
23pub const SURFACE_NOT_FOUND: &str = "SURFACE_NOT_FOUND";
24pub const TEMPLATE_NOT_FOUND: &str = "TEMPLATE_NOT_FOUND";
25pub const FUNCTION_NOT_FOUND: &str = "FUNCTION_NOT_FOUND";
26pub const FUNCTION_DENIED: &str = "FUNCTION_DENIED";
27/// VAPP-107: a `bindDataModel` outside a package surface's `sources`.
28pub const SOURCE_DENIED: &str = "SOURCE_DENIED";
29/// A component's painter failed (`paint.errorCode`).
30pub const RENDER_FAILED: &str = "RENDER_FAILED";
31/// VAPP-194: a package action that landed nothing (a false require, a
32/// refused write or call).
33pub const ACTION_FAILED: &str = "ACTION_FAILED";
34pub const FUNCTION_DECISIONS: &[&str] = &["allow", "ask", "deny", "not_found"];
35/// The default function policy's `default`.
36pub const DEFAULT_FUNCTION_DECISION: &str = "allow";
37pub const DEFAULT_URL_SCHEMES: &[&str] = &["https", "http", "mailto", "tel"];
38/// VAPP-107: the hosts `decide_url` denies by default (`urls.privateHosts`,
39/// the rule itself is [`super::is_private_host`]).
40pub const PRIVATE_HOSTS: &[&str] = &["localhost", "*.localhost", "*.local", "0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "::", "::1", "fe80::/10", "fc00::/7", "::ffff:<private IPv4>", "::<private IPv4>", "64:ff9b::<private IPv4>", "2002:<private IPv4>::/48"];
41pub const URL_REASONS: &[&str] = &["invalid", "scheme", "host", "private"];
42pub const MEDIA_RULE_KEYS: &[&str] = &["prefix", "headers"];
43pub const MEDIA_OPTION_KEYS: &[&str] = &["baseUrl", "rules", "schemes", "hosts", "allowPrivate"];
44/// The media loader's schemes when the host lists none (no `file`).
45pub const DEFAULT_MEDIA_SCHEMES: &[&str] = &["https", "http", "data"];
46/// `media.limits`: what every image loader enforces.
47pub const MEDIA_MAX_BYTES: u64 = 20_971_520;
48pub const MEDIA_TIMEOUT_MS: u64 = 30_000;
49pub const MEDIA_MAX_PIXELS: u64 = 33_554_432;
50/// The renderer → host hook a failed painter calls (`paint.hook`).
51pub const PAINT_ERROR_HOOK: &str = "onPaintError";
52/// A binding source URI (`<scheme>:<name>[?k=v&…]`).
53pub const SOURCE_PATTERN: &str = "^([a-zA-Z][a-zA-Z0-9+.-]*):([^?]+)(?:\\?(.*))?$";
54pub const MCP_MIME_TYPES: &[&str] = &["application/json+a2ui", "application/a2ui+json"];
55pub const MCP_ACTION_TOOL: &str = "a2ui_event";
56pub const SSE_EVENTS: &[&str] = &["message", "a2ui"];
57pub const PACKAGE_REQUIRED: &[&str] = &["id", "name", "version", "catalogId", "templates"];
58/// Round 2: what a host hands each surface besides messages — the
59/// `SurfaceSettings` keys, the host formatter's methods, the commands.
60pub const SURFACE_SETTINGS: &[&str] = &["locale", "timeZone", "strings", "mode", "density", "contrast", "theme", "readOnly"];
61pub const SURFACE_FORMATTER: &[&str] = &["number", "currency", "percent", "date", "relativeTime", "plural"];
62pub const SURFACE_COMMANDS: &[&str] = &["focus", "announce", "scrollIntoView", "scrollToIndex"];
63
64/// VAPP-122: how the `bind` action appends its params (`sources.paramsEncoding`).
65pub const SOURCE_PARAMS_ENCODING: &str = "sourceWithParams: keys sorted; key and value percent-encoded (everything but A-Z a-z 0-9 - _ . ! ~ * ' ( )); null skipped; an object or array as JSON; after ? or &";
66/// VAPP-122: a record kind's name (`package.records.kindPattern`).
67pub const RECORD_KIND_PATTERN: &str = "^[a-z][a-z0-9_]{0,31}$";
68
69/// `catalog/host.json` without its `$comment`s, rebuilt from the constants.
70pub fn host_contract() -> Value {
71    use super::{actions as act, exposes as ex, record_store as rs, signature as sig, tests as pt, tools};
72    json!({
73        "version": HOST_CONTRACT_VERSION,
74        "a2uiVersion": A2UI_VERSION,
75        "messages": {"a2ui": A2UI_MESSAGE_KINDS, "extensions": EXTENSION_MESSAGE_KINDS},
76        "ops": {"kinds": OP_KINDS},
77        "clientMessages": {"errorCodes": HOST_ERROR_CODES},
78        "functions": {"decisions": FUNCTION_DECISIONS, "defaultPolicy": {"allow": [], "ask": [], "deny": [], "default": DEFAULT_FUNCTION_DECISION}},
79        "urls": {"defaultSchemes": DEFAULT_URL_SCHEMES, "privateHosts": PRIVATE_HOSTS, "reasons": URL_REASONS},
80        "media": {
81            "ruleKeys": MEDIA_RULE_KEYS,
82            "optionKeys": MEDIA_OPTION_KEYS,
83            "defaultSchemes": DEFAULT_MEDIA_SCHEMES,
84            "limits": {"maxBytes": MEDIA_MAX_BYTES, "timeoutMs": MEDIA_TIMEOUT_MS, "maxPixels": MEDIA_MAX_PIXELS},
85        },
86        "sources": {"pattern": SOURCE_PATTERN, "paramsEncoding": SOURCE_PARAMS_ENCODING},
87        "transport": {"mcpMimeTypes": MCP_MIME_TYPES, "mcpActionTool": MCP_ACTION_TOOL, "sseEvents": SSE_EVENTS},
88        "negotiation": {},
89        "package": {
90            "required": PACKAGE_REQUIRED,
91            "formatVersion": super::package::PACKAGE_FORMAT_VERSION,
92            "records": {
93                "kindPattern": RECORD_KIND_PATTERN,
94                "reserved": super::records::RECORD_RESERVED_KEYS,
95                "types": super::records::RECORD_FIELD_TYPES,
96                "formats": super::records::RECORD_FORMATS,
97                "scheme": rs::RECORDS_SCHEME,
98                "functions": rs::RECORDS_FUNCTIONS,
99                "query": {"sortDefault": rs::RECORDS_SORT_DEFAULT, "orderDefault": rs::RECORDS_ORDER_DEFAULT, "limitDefault": rs::RECORDS_LIMIT_DEFAULT, "limitMax": rs::RECORDS_LIMIT_MAX, "queryNamePattern": super::package::QUERY_NAME_PATTERN, "reserved": rs::RECORDS_RESERVED_PARAMS},
100            },
101            "actions": {
102                "limits": {"actions": act::ACTION_LIMIT_ACTIONS, "steps": act::ACTION_LIMIT_STEPS, "maxSteps": act::ACTION_LIMIT_MAX_STEPS},
103                "namePattern": act::ACTION_NAME_PATTERN,
104                "stepKinds": act::ACTION_STEP_KINDS,
105                "callPrefix": act::ACTION_CALL_PREFIX,
106                "batchFunction": act::RECORDS_BATCH_FUNCTION,
107            },
108            "tests": {
109                "limits": {"tests": pt::PACKAGE_TEST_LIMIT_TESTS, "gotMax": pt::PACKAGE_TEST_LIMIT_GOT_MAX},
110                "defaults": {"now": pt::PACKAGE_TEST_DEFAULT_NOW, "as": pt::PACKAGE_TEST_DEFAULT_AS, "role": pt::PACKAGE_TEST_DEFAULT_ROLE},
111                "roles": pt::PACKAGE_TEST_ROLES,
112                "kinds": pt::PACKAGE_TEST_KINDS,
113            },
114            "tools": {"idPattern": tools::TOOL_ID_PATTERN, "kinds": tools::TOOL_KINDS, "transports": tools::TOOL_TRANSPORTS, "functionPrefix": tools::TOOL_FUNCTION_PREFIX},
115            "exposes": {
116                "limits": {"namePattern": ex::EXPOSE_NAME_PATTERN, "nameMax": ex::EXPOSE_NAME_MAX, "titleMax": ex::EXPOSE_TITLE_MAX, "descriptionMax": ex::EXPOSE_DESCRIPTION_MAX, "maxTools": ex::EXPOSE_MAX_TOOLS, "separator": ex::EXPOSE_SEPARATOR},
117                "runKinds": ex::EXPOSE_RUN_KINDS,
118                "recordOps": ex::EXPOSE_RECORD_OPS,
119                "readRecordOps": ex::EXPOSE_READ_RECORD_OPS,
120                "readOpWords": ex::EXPOSE_READ_OP_WORDS,
121                "destructiveWords": ex::EXPOSE_DESTRUCTIVE_WORDS,
122                "actionPattern": ex::EXPOSE_ACTION_PATTERN,
123                "agent": {"slugPattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "slugMax": ex::EXPOSE_AGENT_SLUG_MAX, "promptMax": ex::EXPOSE_AGENT_PROMPT_MAX, "devices": ex::EXPOSE_AGENT_DEVICES},
124            },
125            "extensions": {"versionPattern": super::package::EXTENSION_VERSION_PATTERN},
126            "signature": {"alg": sig::PACKAGE_FILE_SIGNATURE_ALG, "context": sig::PACKAGE_FILE_SIGNATURE_CONTEXT, "keyPrefix": sig::AUTHOR_KEY_PREFIX, "nameMax": sig::AUTHOR_NAME_MAX, "statuses": sig::PACKAGE_FILE_STATUSES},
127        },
128        "paint": {"hook": PAINT_ERROR_HOOK, "errorCode": RENDER_FAILED},
129        "surface": {"settings": SURFACE_SETTINGS, "formatter": SURFACE_FORMATTER, "commands": SURFACE_COMMANDS},
130    })
131}
132
133/// A client error message (A2UI v0.9 `error`); `path` only when given.
134pub fn error_message(code: &str, surface_id: &str, message: &str, path: Option<&str>) -> Value {
135    let mut error = Map::new();
136    error.insert("code".into(), Value::String(code.into()));
137    error.insert("surfaceId".into(), Value::String(surface_id.into()));
138    error.insert("message".into(), Value::String(message.into()));
139    if let Some(path) = path {
140        error.insert("path".into(), Value::String(path.into()));
141    }
142    json!({"version": A2UI_VERSION, "error": error})
143}
144
145/// A client action message (A2UI v0.9 `action`). `context` null = `{}`;
146/// `payload` only when it has keys (JS `Object.keys(payload).length`).
147pub fn action_message(surface_id: &str, component_id: &str, name: &str, context: Value, payload: Option<Value>, timestamp: &str) -> Value {
148    let mut action = Map::new();
149    action.insert("name".into(), Value::String(name.into()));
150    action.insert("surfaceId".into(), Value::String(surface_id.into()));
151    action.insert("sourceComponentId".into(), Value::String(component_id.into()));
152    action.insert("timestamp".into(), Value::String(timestamp.into()));
153    action.insert("context".into(), if context.is_null() { Value::Object(Map::new()) } else { context });
154    if let Some(payload) = payload {
155        let has_keys = match &payload {
156            Value::Object(o) => !o.is_empty(),
157            Value::Array(a) => !a.is_empty(),
158            Value::String(s) => !s.is_empty(),
159            _ => false,
160        };
161        if has_keys {
162            action.insert("payload".into(), payload);
163        }
164    }
165    json!({"version": A2UI_VERSION, "action": action})
166}