Skip to main content

host_extensions/
executor_contract.rs

1//! Executor contract — canonical response types and error constants for
2//! `window.host.*` bridge calls.
3//!
4//! Host implementations (host-rs, dotli) use these definitions to ensure SPAs
5//! receive identical promise resolution values regardless of which host they
6//! run on.
7//!
8//! ## Response value conventions
9//!
10//! Most bridge calls resolve with **bare values** — a string, an integer, a
11//! boolean, or `null`.  Only `mediaGetUserMedia` resolves with a structured
12//! JSON object.  The types below document the exact JS-level value the
13//! promise resolves with.
14//!
15//! See also `docs/executor-contract.md` for behavioral rules that types cannot
16//! express (listener lifecycle, error semantics, identity rules).
17
18use serde::{Deserialize, Serialize};
19
20// ── Structured response types ───────────────────────────────────────────
21//
22// These are the only bridge calls that resolve with a JSON object.
23
24/// `mediaGetUserMedia({ audio, video })` — resolves with a JSON object
25/// containing the allocated local track IDs *before* the corresponding
26/// `mediaTrackReady` events fire.
27///
28/// A field is **absent** (not `null`) when the caller did not request that
29/// track kind.  Hosts MUST use `skip_serializing_if` (or equivalent) to
30/// omit unrequested fields rather than setting them to `null`.
31#[derive(Debug, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct GetUserMediaResult {
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub audio_track_id: Option<u64>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub video_track_id: Option<u64>,
38}
39
40/// `mediaGetDisplayMedia()` — resolves with the allocated screen track ID.
41#[derive(Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct GetDisplayMediaResult {
44    pub screen_track_id: u64,
45}
46
47/// `meshStatus()` — resolves with the current mesh backend health and queue state.
48#[derive(Debug, Serialize, serde::Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct MeshStatusResult {
51    pub health: String,
52    pub transport: String,
53    pub pending_publishes: u64,
54    pub pending_queries: u64,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub last_error: Option<String>,
57}
58
59/// `mesh.objects.put(path, dataBase64, policy?)` retention and preview policy.
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "camelCase")]
62pub struct MeshObjectPolicy {
63    /// Absolute expiry in host time. Hosts may internally derive this from TTL
64    /// before persistence, but app-facing behavior is always expressed as an
65    /// absolute host-clock deadline.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub expires_at_ms: Option<u64>,
68    /// Hint for host-managed previews, notifications, and restored plaintext
69    /// derivatives on compliant hosts.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub suppress_previews: Option<bool>,
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum MeshObjectReadReason {
77    Expired,
78    NotFound,
79    PolicyDenied,
80}
81
82/// `mesh.objects.getResult(path)` and structured negative `meshReply` model.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "camelCase")]
85pub struct MeshObjectResult {
86    pub data_base64: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub reason: Option<MeshObjectReadReason>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub expires_at_ms: Option<u64>,
91}
92
93impl MeshObjectResult {
94    pub fn found(data_base64: String, expires_at_ms: Option<u64>) -> Self {
95        Self {
96            data_base64: Some(data_base64),
97            reason: None,
98            expires_at_ms,
99        }
100    }
101
102    pub fn unavailable(reason: MeshObjectReadReason, expires_at_ms: Option<u64>) -> Self {
103        Self {
104            data_base64: None,
105            reason: Some(reason),
106            expires_at_ms,
107        }
108    }
109
110    pub fn compat_data(self) -> Option<String> {
111        self.data_base64
112    }
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "lowercase")]
117pub enum MeshControlMode {
118    Visible,
119    Encrypted,
120}
121
122/// Capability-scoped control envelope for mesh 1.2 private channels.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct MeshControlEnvelope {
126    pub mode: MeshControlMode,
127    pub data_base64: String,
128}
129
130/// `mesh.private.objects.put(dataBase64, policy?)` opaque object reference.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "camelCase")]
133pub struct MeshPrivateObjectRef {
134    pub handle: String,
135    pub capability: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub expires_at_ms: Option<u64>,
138}
139
140// ── Bare-value response documentation ───────────────────────────────────
141//
142// The following bridge calls resolve with bare (non-object) values.
143// No Rust struct is needed — the host serializes the value directly.
144//
145// | Method                  | Resolves with                | JS type   |
146// |-------------------------|------------------------------|-----------|
147// | `getAddress`            | SS58 address                 | `string`  |
148// | `sign`                  | `0x`-prefixed hex signature  | `string`  |
149// | `chainQuery`            | raw JSON-RPC result          | `any`     |
150// | `chainSubmit`           | transaction hash             | `string`  |
151// | `dataConnect`           | connection ID                | `number`  |
152// | `dataGetPeerId`         | wallet SS58 address          | `string`  |
153// | `mediaConnect`          | session ID                   | `number`  |
154// | `mediaAccept`           | session ID                   | `number`  |
155// |   (with `{ peer }`)    | session ID (group path)      | `number`  |
156// | `mediaGetPeerId`        | wallet SS58 address          | `string`  |
157// | `storageGet`            | stored value or `null`       | `string?` |
158// | `dataSend`              | `true`                       | `boolean` |
159// | `dataSendBytes`         | `true`                       | `boolean` |
160// | `dataClose`             | `true`                       | `boolean` |
161// | `dataStartListening`    | `true`                       | `boolean` |
162// | `storageSet`            | `true`                       | `boolean` |
163// | `storageRemove`         | `true`                       | `boolean` |
164// | `statementsWrite`       | `true`                       | `boolean` |
165// | `statementsSubscribe`   | `true`                       | `boolean` |
166// | `filesSave`             | `true`                       | `boolean` |
167// | `meshPublish`           | `true`                       | `boolean` |
168// | `meshSubscribe`         | `true`                       | `boolean` |
169// | `meshPutObject`         | `true`                       | `boolean` |
170// | `meshGetObject`         | stored object or `null`      | `string?` |
171// | `meshGetObjectResult`   | retention-aware read result  | `object`  |
172// | `meshRequest`           | request ID                   | `string`  |
173// | `meshRespond`           | `true`                       | `boolean` |
174// | `meshStatus`            | queue + health object        | `object`  |
175// | `meshPrivatePutObject`  | opaque ref + capability      | `object`  |
176// | `meshPrivateGetObject`  | capability-scoped read       | `object`  |
177// | `meshPrivateRequest`    | request ID                   | `string`  |
178// | `meshPrivateControlPublish` | `true`                  | `boolean` |
179// | `meshPrivateControlSubscribe` | `true`                | `boolean` |
180// | `meshPrivateReceiptPublish` | `true`                  | `boolean` |
181// | `meshPrivateReceiptSubscribe` | `true`                | `boolean` |
182// | `mediaClose`            | `true`                       | `boolean` |
183// | `mediaStartListening`   | `true`                       | `boolean` |
184// | `mediaAttachTrack`      | `true`                       | `boolean` |
185// | `mediaSetTrackEnabled`  | `true`                       | `boolean` |
186// | `mediaSignal`           | `true`                       | `boolean` |
187// | `requestWssPermission`  | `true`                       | `boolean` |
188// | `requestHttpPermission` | `true`                       | `boolean` |
189// | `mediaAddTrack`       | `true`                    | `boolean` |
190// | `mediaRemoveTrack`    | `true`                    | `boolean` |
191// | `mediaGroupConnect`   | group session ID (string) | `string`  |
192// | `mediaGroupAccept`    | session ID                | `number`  |
193// | `mediaGroupReject`    | `true`                    | `boolean` |
194// | `crdtJoin`            | room handle object        | `object`  |
195// | `crdtApplyUpdate`     | `true`                    | `boolean` |
196// | `crdtGetStateVector`  | base64-encoded vector     | `string`  |
197// | `crdtGetFullState`    | base64-encoded state      | `string`  |
198// | `crdtSetAwareness`    | `true`                    | `boolean` |
199// | `crdtDestroy`         | `true`                    | `boolean` |
200
201/// `crdtJoin(roomId, opts?)` — resolves with room metadata.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct CrdtJoinResult {
205    pub room_id: String,
206    pub transport: String,
207}
208
209// ── Error message constants ──────────────────────────────────────────────
210// Hosts MUST use these exact strings so SPAs can match on error text.
211
212pub const ERR_WALLET_NOT_CONNECTED: &str = "wallet not connected";
213pub const ERR_WALLET_NOT_ENABLED: &str = "wallet not enabled";
214pub const ERR_DATA_PERMISSION: &str = "data permission not granted";
215pub const ERR_MEDIA_PERMISSION: &str = "media permission not granted";
216pub const ERR_CAMERA_PERMISSION: &str = "camera permission not granted";
217pub const ERR_AUDIO_PERMISSION: &str = "audio permission not granted";
218pub const ERR_CHAIN_PERMISSION: &str = "chain permission not granted";
219pub const ERR_STATEMENTS_PERMISSION: &str = "statements permission not granted";
220pub const ERR_PENDING_SIGN: &str = "another signing request is pending";
221pub const ERR_PENDING_SUBMIT: &str = "another submit request is pending";
222pub const ERR_PENDING_CONNECT: &str = "another connect request is pending";
223pub const ERR_ADDRESS_REQUIRED: &str = "address is required";
224pub const ERR_PEER_ADDRESS_EMPTY: &str = "peer address cannot be empty";
225pub const ERR_FILENAME_REQUIRED: &str = "filename is required";
226pub const ERR_INVALID_ELEMENT_ID: &str = "invalid elementId";
227pub const ERR_SCREEN_PERMISSION: &str = "screen capture permission not granted";
228pub const ERR_SCREEN_DENIED_BY_OS: &str = "screen capture denied by operating system";
229pub const ERR_GROUP_TOO_LARGE: &str = "group exceeds maximum peer limit";
230pub const ERR_NO_PENDING_CALL_FOR_PEER: &str = "no pending incoming call from that peer";
231pub const ERR_GROUP_ID_REQUIRED: &str = "groupId is required for group calls";
232pub const ERR_CRDT_ROOM_NOT_FOUND: &str = "crdt room not found";
233pub const ERR_CRDT_ROOM_ID_REQUIRED: &str = "roomId is required";
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn json(v: &impl serde::Serialize) -> String {
240        serde_json::to_string(v).unwrap()
241    }
242
243    #[test]
244    fn get_user_media_result_both_tracks_json_shape() {
245        let r = GetUserMediaResult {
246            audio_track_id: Some(1),
247            video_track_id: Some(2),
248        };
249        assert_eq!(json(&r), r#"{"audioTrackId":1,"videoTrackId":2}"#);
250    }
251
252    #[test]
253    fn get_user_media_result_audio_only_omits_video() {
254        let r = GetUserMediaResult {
255            audio_track_id: Some(1),
256            video_track_id: None,
257        };
258        assert_eq!(json(&r), r#"{"audioTrackId":1}"#);
259    }
260
261    #[test]
262    fn get_user_media_result_video_only_omits_audio() {
263        let r = GetUserMediaResult {
264            audio_track_id: None,
265            video_track_id: Some(3),
266        };
267        assert_eq!(json(&r), r#"{"videoTrackId":3}"#);
268    }
269
270    #[test]
271    fn get_display_media_result_json_shape() {
272        let r = GetDisplayMediaResult {
273            screen_track_id: 42,
274        };
275        assert_eq!(json(&r), r#"{"screenTrackId":42}"#);
276    }
277
278    #[test]
279    fn mesh_status_result_json_shape() {
280        let result = MeshStatusResult {
281            health: "healthy".into(),
282            transport: "statement-store".into(),
283            pending_publishes: 1,
284            pending_queries: 2,
285            last_error: None,
286        };
287        assert_eq!(
288            json(&result),
289            r#"{"health":"healthy","transport":"statement-store","pendingPublishes":1,"pendingQueries":2}"#
290        );
291    }
292
293    #[test]
294    fn mesh_object_policy_json_shape() {
295        let policy = MeshObjectPolicy {
296            expires_at_ms: Some(1710000000000),
297            suppress_previews: Some(true),
298        };
299        assert_eq!(
300            json(&policy),
301            r#"{"expiresAtMs":1710000000000,"suppressPreviews":true}"#
302        );
303    }
304
305    #[test]
306    fn mesh_object_result_positive_json_shape() {
307        let result = MeshObjectResult::found("AQID".into(), Some(1710000000000));
308        assert_eq!(
309            json(&result),
310            r#"{"dataBase64":"AQID","expiresAtMs":1710000000000}"#
311        );
312    }
313
314    #[test]
315    fn mesh_object_result_negative_json_shape() {
316        let result =
317            MeshObjectResult::unavailable(MeshObjectReadReason::Expired, Some(1710000000000));
318        assert_eq!(
319            json(&result),
320            r#"{"dataBase64":null,"reason":"expired","expiresAtMs":1710000000000}"#
321        );
322    }
323
324    #[test]
325    fn mesh_control_envelope_json_shape() {
326        let envelope = MeshControlEnvelope {
327            mode: MeshControlMode::Encrypted,
328            data_base64: "AQID".into(),
329        };
330        assert_eq!(
331            json(&envelope),
332            r#"{"mode":"encrypted","dataBase64":"AQID"}"#
333        );
334    }
335
336    #[test]
337    fn mesh_private_object_ref_json_shape() {
338        let reference = MeshPrivateObjectRef {
339            handle: "mesh-private-handle-1".into(),
340            capability: "mesh-private-capability-1".into(),
341            expires_at_ms: Some(1710000000000),
342        };
343        assert_eq!(
344            json(&reference),
345            r#"{"handle":"mesh-private-handle-1","capability":"mesh-private-capability-1","expiresAtMs":1710000000000}"#
346        );
347    }
348
349    #[test]
350    fn crdt_join_result_json_shape() {
351        let r = CrdtJoinResult {
352            room_id: "doc-abc".into(),
353            transport: "relay".into(),
354        };
355        assert_eq!(json(&r), r#"{"roomId":"doc-abc","transport":"relay"}"#);
356    }
357
358    #[test]
359    fn error_constants_are_non_empty() {
360        let constants = [
361            ERR_WALLET_NOT_CONNECTED,
362            ERR_WALLET_NOT_ENABLED,
363            ERR_DATA_PERMISSION,
364            ERR_MEDIA_PERMISSION,
365            ERR_CAMERA_PERMISSION,
366            ERR_AUDIO_PERMISSION,
367            ERR_CHAIN_PERMISSION,
368            ERR_STATEMENTS_PERMISSION,
369            ERR_PENDING_SIGN,
370            ERR_PENDING_SUBMIT,
371            ERR_PENDING_CONNECT,
372            ERR_ADDRESS_REQUIRED,
373            ERR_PEER_ADDRESS_EMPTY,
374            ERR_FILENAME_REQUIRED,
375            ERR_INVALID_ELEMENT_ID,
376            ERR_SCREEN_PERMISSION,
377            ERR_SCREEN_DENIED_BY_OS,
378            ERR_GROUP_TOO_LARGE,
379            ERR_NO_PENDING_CALL_FOR_PEER,
380            ERR_GROUP_ID_REQUIRED,
381            ERR_CRDT_ROOM_NOT_FOUND,
382            ERR_CRDT_ROOM_ID_REQUIRED,
383        ];
384        for c in &constants {
385            assert!(!c.is_empty());
386        }
387    }
388}