Skip to repository content5065 lines · 197.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:17:12.802Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
issue31_nostr.rs
1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7pub const ISSUE31_HOST_DISCOVERY_SCHEMA: &str = "openagents.omega.issue31.host_discovery.v1";
8pub const ISSUE31_HOST_DISCOVERY_SCHEMA_V2: &str = "openagents.omega.issue31.host_discovery.v2";
9pub const ISSUE31_PAIRING_SCHEMA: &str = "openagents.omega.issue31.pairing.v1";
10pub const ISSUE31_COMMAND_SCHEMA: &str = "openagents.omega.issue31.command.v1";
11pub const ISSUE31_COMMAND_SCHEMA_V2: &str = "openagents.omega.issue31.command.v2";
12pub const ISSUE31_OWNER_PROJECTION_SCHEMA: &str = "openagents.omega.issue31.owner_projection.v1";
13pub const ISSUE31_WITHHELD_SOURCES_SCHEMA: &str = "openagents.omega.issue31.withheld_sources.v1";
14/// The omega#47 host snapshot, as an owner-private record (omega#49).
15pub const ISSUE31_HOST_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.host.v1";
16/// The omega#47 Full Auto detail projection, as an owner-private record.
17pub const ISSUE31_FULL_AUTO_ADJUNCT_SCHEMA: &str = "openagents.omega.issue31.fullauto.v1";
18pub const ISSUE31_HOST_ADJUNCT_RECORD_TYPE: &str = "host_snapshot";
19pub const ISSUE31_FULL_AUTO_ADJUNCT_RECORD_TYPE: &str = "full_auto_detail";
20/// The fields the host pump adds when it addresses an adjunct to a device.
21///
22/// An omega#47 adjunct describes a host; on its own it names neither the key
23/// that signed it nor the device it is for, so the device's envelope check has
24/// nothing to compare the seal author and the gift wrap recipient against. The
25/// pump states all five together or the record is not delivered at all.
26pub const ISSUE31_ADJUNCT_DELIVERY_KEYS: [&str; 5] = [
27 "recordType",
28 "hostPublicKeyHex",
29 "devicePublicKeyHex",
30 "grantRef",
31 "expectedGeneration",
32];
33pub const ISSUE31_HOST_DISCOVERY_KIND: u16 = 31_990;
34pub const ISSUE31_PRIVATE_RUMOR_KIND: u16 = 14;
35pub const ISSUE31_PRIVATE_SEAL_KIND: u16 = 13;
36pub const ISSUE31_PRIVATE_GIFT_WRAP_KIND: u16 = 1_059;
37pub const ISSUE31_ACTION_SEND_MESSAGE: &str = "action.issue31.sarah.send";
38pub const ISSUE31_ACTION_INTERRUPT_TURN: &str = "action.issue31.sarah.interrupt";
39pub const ISSUE31_ACTION_ADVANCE_READ_STATE: &str = "action.issue31.read_state.advance";
40pub const ISSUE31_ACTION_CREATE_REMINDER: &str = "action.issue31.reminder.create";
41pub const ISSUE31_ACTION_CHANGE_REMINDER: &str = "action.issue31.reminder.change";
42pub const ISSUE31_ACTION_COMPLETE_REMINDER: &str = "action.issue31.reminder.complete";
43pub const ISSUE31_ACTION_CANCEL_REMINDER: &str = "action.issue31.reminder.cancel";
44pub const SARAH_TURN_RECORD_KIND: u16 = 44_300;
45pub const SARAH_AUTHORITY_RECEIPT_KIND: u16 = 44_301;
46pub const SARAH_ENGRAM_KIND: u16 = 30_174;
47pub const SARAH_READ_STATE_KIND: u16 = 30_078;
48pub const SARAH_REMINDER_KIND: u16 = 30_300;
49
50#[derive(Debug, Error, PartialEq, Eq)]
51pub enum Issue31NostrError {
52 #[error("invalid Issue 31 record: {0}")]
53 Invalid(String),
54 #[error("Issue 31 record decoding failed: {0}")]
55 Decode(String),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct Issue31HostDiscovery {
61 pub schema: String,
62 pub host_ref: String,
63 pub host_public_key_hex: String,
64 pub sarah_public_key_hex: String,
65 pub display_name: String,
66 pub protocols: Vec<String>,
67 pub relay_urls: Vec<String>,
68 pub generation: u64,
69 pub issued_at: u64,
70 pub expires_at: u64,
71}
72
73impl Issue31HostDiscovery {
74 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
75 if bytes.len() > 64 * 1024 {
76 return Err(Issue31NostrError::Invalid(
77 "host discovery exceeds the record budget".into(),
78 ));
79 }
80 let record: Self = serde_json::from_slice(bytes)
81 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
82 record.validate()?;
83 Ok(record)
84 }
85
86 pub fn validate(&self) -> Result<(), Issue31NostrError> {
87 if self.schema != ISSUE31_HOST_DISCOVERY_SCHEMA
88 || !valid_ref(&self.host_ref)
89 || !valid_hex64(&self.host_public_key_hex)
90 || !valid_hex64(&self.sarah_public_key_hex)
91 || self.sarah_public_key_hex == self.host_public_key_hex
92 || self.display_name.is_empty()
93 || self.display_name.len() > 80
94 || self.generation == 0
95 || self.expires_at <= self.issued_at
96 || self.protocols.len() != 2
97 || !all_unique(&self.protocols)
98 || !self
99 .protocols
100 .iter()
101 .any(|protocol| protocol == ISSUE31_PAIRING_SCHEMA)
102 || !self
103 .protocols
104 .iter()
105 .any(|protocol| protocol == ISSUE31_COMMAND_SCHEMA)
106 || self.relay_urls.is_empty()
107 || self.relay_urls.len() > 8
108 || !all_unique(&self.relay_urls)
109 || self
110 .relay_urls
111 .iter()
112 .any(|relay_url| !valid_relay_url(relay_url))
113 {
114 return Err(Issue31NostrError::Invalid(
115 "host discovery failed its schema, identity, protocol, relay, or lifetime law"
116 .into(),
117 ));
118 }
119 Ok(())
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct Issue31HostDiscoveryV2 {
126 pub schema: String,
127 pub host_ref: String,
128 pub host_public_key_hex: String,
129 pub sarah_public_key_hex: String,
130 pub conversation: String,
131 pub display_name: String,
132 pub protocols: Vec<String>,
133 pub relay_urls: Vec<String>,
134 pub generation: u64,
135 pub issued_at: u64,
136 pub expires_at: u64,
137}
138
139impl Issue31HostDiscoveryV2 {
140 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
141 if bytes.len() > 64 * 1024 {
142 return Err(Issue31NostrError::Invalid(
143 "host discovery exceeds the record budget".into(),
144 ));
145 }
146 let record: Self = serde_json::from_slice(bytes)
147 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
148 record.validate()?;
149 Ok(record)
150 }
151
152 pub fn validate(&self) -> Result<(), Issue31NostrError> {
153 if self.schema != ISSUE31_HOST_DISCOVERY_SCHEMA_V2
154 || !valid_ref(&self.host_ref)
155 || !valid_hex64(&self.host_public_key_hex)
156 || !valid_hex64(&self.sarah_public_key_hex)
157 || self.sarah_public_key_hex == self.host_public_key_hex
158 || !valid_conversation_tag(&self.conversation)
159 || self.display_name.is_empty()
160 || self.display_name.len() > 80
161 || self.generation == 0
162 || self.expires_at <= self.issued_at
163 || self.protocols.len() != 3
164 || !all_unique(&self.protocols)
165 || !self
166 .protocols
167 .iter()
168 .any(|protocol| protocol == ISSUE31_PAIRING_SCHEMA)
169 || !self
170 .protocols
171 .iter()
172 .any(|protocol| protocol == ISSUE31_COMMAND_SCHEMA)
173 || !self
174 .protocols
175 .iter()
176 .any(|protocol| protocol == ISSUE31_COMMAND_SCHEMA_V2)
177 || self.relay_urls.is_empty()
178 || self.relay_urls.len() > 8
179 || !all_unique(&self.relay_urls)
180 || self
181 .relay_urls
182 .iter()
183 .any(|relay_url| !valid_relay_url(relay_url))
184 {
185 return Err(Issue31NostrError::Invalid(
186 "v2 host discovery failed its schema, identity, protocol, relay, or lifetime law"
187 .into(),
188 ));
189 }
190 Ok(())
191 }
192}
193
194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
195#[serde(rename_all = "snake_case")]
196pub enum Issue31PairingScope {
197 ObserveIssue31,
198 SendMessage,
199 InterruptTurn,
200 ControlFullAuto,
201 RequestProviderHandoff,
202 ActInCommunity,
203}
204
205impl Issue31PairingScope {
206 pub fn parse(value: &str) -> Result<Self, Issue31NostrError> {
207 match value {
208 "observe_issue31" => Ok(Self::ObserveIssue31),
209 "send_message" => Ok(Self::SendMessage),
210 "interrupt_turn" => Ok(Self::InterruptTurn),
211 "control_full_auto" => Ok(Self::ControlFullAuto),
212 "request_provider_handoff" => Ok(Self::RequestProviderHandoff),
213 "act_in_community" => Ok(Self::ActInCommunity),
214 _ => Err(Issue31NostrError::Invalid(
215 "unknown Issue 31 pairing scope".into(),
216 )),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(tag = "recordType", rename_all = "snake_case", deny_unknown_fields)]
223pub enum Issue31PairingRecord {
224 PairingRequest {
225 schema: String,
226 #[serde(rename = "hostRef")]
227 host_ref: String,
228 #[serde(rename = "hostPublicKeyHex")]
229 host_public_key_hex: String,
230 #[serde(rename = "devicePublicKeyHex")]
231 device_public_key_hex: String,
232 #[serde(rename = "issuedAt")]
233 issued_at: u64,
234 #[serde(rename = "pairingRequestRef")]
235 pairing_request_ref: String,
236 #[serde(rename = "requestedScopes")]
237 requested_scopes: Vec<Issue31PairingScope>,
238 #[serde(rename = "expiresAt")]
239 expires_at: u64,
240 },
241 PairingChallenge {
242 schema: String,
243 #[serde(rename = "hostRef")]
244 host_ref: String,
245 #[serde(rename = "hostPublicKeyHex")]
246 host_public_key_hex: String,
247 #[serde(rename = "devicePublicKeyHex")]
248 device_public_key_hex: String,
249 #[serde(rename = "issuedAt")]
250 issued_at: u64,
251 #[serde(rename = "pairingChallengeRef")]
252 pairing_challenge_ref: String,
253 #[serde(rename = "pairingRequestEventId")]
254 pairing_request_event_id: String,
255 challenge: String,
256 #[serde(rename = "expiresAt")]
257 expires_at: u64,
258 },
259 PairingResponse {
260 schema: String,
261 #[serde(rename = "hostRef")]
262 host_ref: String,
263 #[serde(rename = "hostPublicKeyHex")]
264 host_public_key_hex: String,
265 #[serde(rename = "devicePublicKeyHex")]
266 device_public_key_hex: String,
267 #[serde(rename = "issuedAt")]
268 issued_at: u64,
269 #[serde(rename = "pairingResponseRef")]
270 pairing_response_ref: String,
271 #[serde(rename = "pairingChallengeEventId")]
272 pairing_challenge_event_id: String,
273 challenge: String,
274 #[serde(rename = "expiresAt")]
275 expires_at: u64,
276 },
277 ScopedGrant {
278 schema: String,
279 #[serde(rename = "hostRef")]
280 host_ref: String,
281 #[serde(rename = "hostPublicKeyHex")]
282 host_public_key_hex: String,
283 #[serde(rename = "sarahPublicKeyHex")]
284 sarah_public_key_hex: String,
285 #[serde(rename = "devicePublicKeyHex")]
286 device_public_key_hex: String,
287 #[serde(rename = "issuedAt")]
288 issued_at: u64,
289 #[serde(rename = "pairingResponseEventId")]
290 pairing_response_event_id: String,
291 #[serde(rename = "grantRef")]
292 grant_ref: String,
293 generation: u64,
294 scopes: Vec<Issue31PairingScope>,
295 #[serde(rename = "expiresAt")]
296 expires_at: u64,
297 },
298 GrantRenewal {
299 schema: String,
300 #[serde(rename = "hostRef")]
301 host_ref: String,
302 #[serde(rename = "hostPublicKeyHex")]
303 host_public_key_hex: String,
304 #[serde(rename = "sarahPublicKeyHex")]
305 sarah_public_key_hex: String,
306 #[serde(rename = "devicePublicKeyHex")]
307 device_public_key_hex: String,
308 #[serde(rename = "issuedAt")]
309 issued_at: u64,
310 #[serde(rename = "grantRef")]
311 grant_ref: String,
312 #[serde(rename = "previousGrantEventId")]
313 previous_grant_event_id: String,
314 #[serde(rename = "priorGeneration")]
315 prior_generation: u64,
316 generation: u64,
317 scopes: Vec<Issue31PairingScope>,
318 #[serde(rename = "expiresAt")]
319 expires_at: u64,
320 },
321 GrantRevocation {
322 schema: String,
323 #[serde(rename = "hostRef")]
324 host_ref: String,
325 #[serde(rename = "hostPublicKeyHex")]
326 host_public_key_hex: String,
327 #[serde(rename = "sarahPublicKeyHex")]
328 sarah_public_key_hex: String,
329 #[serde(rename = "devicePublicKeyHex")]
330 device_public_key_hex: String,
331 #[serde(rename = "issuedAt")]
332 issued_at: u64,
333 #[serde(rename = "grantRef")]
334 grant_ref: String,
335 generation: u64,
336 #[serde(rename = "reasonRef", default, skip_serializing_if = "Option::is_none")]
337 reason_ref: Option<String>,
338 },
339}
340
341impl Issue31PairingRecord {
342 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
343 if bytes.len() > 64 * 1024 {
344 return Err(Issue31NostrError::Invalid(
345 "pairing record exceeds the record budget".into(),
346 ));
347 }
348 let record: Self = serde_json::from_slice(bytes)
349 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
350 record.validate()?;
351 Ok(record)
352 }
353
354 pub fn validate(&self) -> Result<(), Issue31NostrError> {
355 let (schema, host_ref, host_key, device_key, issued_at) = self.base();
356 if schema != ISSUE31_PAIRING_SCHEMA
357 || !valid_ref(host_ref)
358 || !valid_hex64(host_key)
359 || !valid_hex64(device_key)
360 {
361 return Err(Issue31NostrError::Invalid(
362 "invalid pairing identity".into(),
363 ));
364 }
365 match self {
366 Self::PairingRequest {
367 pairing_request_ref,
368 requested_scopes,
369 expires_at,
370 ..
371 } => validate_scopes(requested_scopes, "requested scopes")
372 .and_then(|()| validate_ref_value(pairing_request_ref, "pairing request"))
373 .and_then(|()| validate_lifetime(issued_at, *expires_at)),
374 Self::PairingChallenge {
375 pairing_challenge_ref,
376 pairing_request_event_id,
377 challenge,
378 expires_at,
379 ..
380 } => validate_ref_value(pairing_challenge_ref, "pairing challenge")
381 .and_then(|()| {
382 validate_hex_value(pairing_request_event_id, "pairing request event")
383 })
384 .and_then(|()| validate_hex_value(challenge, "pairing challenge nonce"))
385 .and_then(|()| validate_lifetime(issued_at, *expires_at)),
386 Self::PairingResponse {
387 pairing_response_ref,
388 pairing_challenge_event_id,
389 challenge,
390 expires_at,
391 ..
392 } => validate_ref_value(pairing_response_ref, "pairing response")
393 .and_then(|()| validate_hex_value(pairing_challenge_event_id, "challenge event"))
394 .and_then(|()| validate_hex_value(challenge, "pairing challenge nonce"))
395 .and_then(|()| validate_lifetime(issued_at, *expires_at)),
396 Self::ScopedGrant {
397 sarah_public_key_hex,
398 pairing_response_event_id,
399 grant_ref,
400 generation,
401 scopes,
402 expires_at,
403 ..
404 } => validate_sarah_binding(host_key, sarah_public_key_hex)
405 .and_then(|()| {
406 validate_hex_value(pairing_response_event_id, "pairing response event")
407 })
408 .and_then(|()| validate_ref_value(grant_ref, "grant"))
409 .and_then(|()| validate_generation(*generation))
410 .and_then(|()| validate_scopes(scopes, "grant scopes"))
411 .and_then(|()| validate_lifetime(issued_at, *expires_at)),
412 Self::GrantRenewal {
413 sarah_public_key_hex,
414 grant_ref,
415 previous_grant_event_id,
416 prior_generation,
417 generation,
418 scopes,
419 expires_at,
420 ..
421 } => validate_sarah_binding(host_key, sarah_public_key_hex)
422 .and_then(|()| validate_ref_value(grant_ref, "grant"))
423 .and_then(|()| validate_hex_value(previous_grant_event_id, "previous grant event"))
424 .and_then(|()| validate_generation(*prior_generation))
425 .and_then(|()| {
426 if *generation == prior_generation.saturating_add(1) {
427 Ok(())
428 } else {
429 Err(Issue31NostrError::Invalid(
430 "grant renewal must advance exactly one generation".into(),
431 ))
432 }
433 })
434 .and_then(|()| validate_scopes(scopes, "grant scopes"))
435 .and_then(|()| validate_lifetime(issued_at, *expires_at)),
436 Self::GrantRevocation {
437 sarah_public_key_hex,
438 grant_ref,
439 generation,
440 reason_ref,
441 ..
442 } => validate_sarah_binding(host_key, sarah_public_key_hex)
443 .and_then(|()| validate_ref_value(grant_ref, "grant"))
444 .and_then(|()| validate_generation(*generation))
445 .and_then(|()| match reason_ref {
446 Some(reason_ref) => validate_ref_value(reason_ref, "revocation reason"),
447 None => Ok(()),
448 }),
449 }
450 }
451
452 fn base(&self) -> (&str, &str, &str, &str, u64) {
453 match self {
454 Self::PairingRequest {
455 schema,
456 host_ref,
457 host_public_key_hex,
458 device_public_key_hex,
459 issued_at,
460 ..
461 }
462 | Self::PairingChallenge {
463 schema,
464 host_ref,
465 host_public_key_hex,
466 device_public_key_hex,
467 issued_at,
468 ..
469 }
470 | Self::PairingResponse {
471 schema,
472 host_ref,
473 host_public_key_hex,
474 device_public_key_hex,
475 issued_at,
476 ..
477 }
478 | Self::ScopedGrant {
479 schema,
480 host_ref,
481 host_public_key_hex,
482 device_public_key_hex,
483 issued_at,
484 ..
485 }
486 | Self::GrantRenewal {
487 schema,
488 host_ref,
489 host_public_key_hex,
490 device_public_key_hex,
491 issued_at,
492 ..
493 }
494 | Self::GrantRevocation {
495 schema,
496 host_ref,
497 host_public_key_hex,
498 device_public_key_hex,
499 issued_at,
500 ..
501 } => (
502 schema,
503 host_ref,
504 host_public_key_hex,
505 device_public_key_hex,
506 *issued_at,
507 ),
508 }
509 }
510
511 fn lifecycle_binding(&self) -> Option<(&str, &str, &str, &str, &str, u64, u64)> {
512 match self {
513 Self::ScopedGrant {
514 host_ref,
515 host_public_key_hex,
516 sarah_public_key_hex,
517 device_public_key_hex,
518 issued_at,
519 grant_ref,
520 generation,
521 ..
522 }
523 | Self::GrantRenewal {
524 host_ref,
525 host_public_key_hex,
526 sarah_public_key_hex,
527 device_public_key_hex,
528 issued_at,
529 grant_ref,
530 generation,
531 ..
532 }
533 | Self::GrantRevocation {
534 host_ref,
535 host_public_key_hex,
536 sarah_public_key_hex,
537 device_public_key_hex,
538 issued_at,
539 grant_ref,
540 generation,
541 ..
542 } => Some((
543 host_ref,
544 host_public_key_hex,
545 sarah_public_key_hex,
546 device_public_key_hex,
547 grant_ref,
548 *generation,
549 *issued_at,
550 )),
551 Self::PairingRequest { .. }
552 | Self::PairingChallenge { .. }
553 | Self::PairingResponse { .. } => None,
554 }
555 }
556
557 pub fn validate_private_binding(
558 &self,
559 sender_public_key_hex: &str,
560 recipient_public_key_hex: &str,
561 ) -> Result<(), Issue31NostrError> {
562 self.validate()?;
563 let (_, _, host_key, device_key, _) = self.base();
564 let device_authored = matches!(
565 self,
566 Self::PairingRequest { .. } | Self::PairingResponse { .. }
567 );
568 let expected_sender = if device_authored {
569 device_key
570 } else {
571 host_key
572 };
573 let expected_recipient = if device_authored {
574 host_key
575 } else {
576 device_key
577 };
578 if sender_public_key_hex != expected_sender
579 || recipient_public_key_hex != expected_recipient
580 {
581 return Err(Issue31NostrError::Invalid(
582 "pairing record signer or recipient does not match its binding".into(),
583 ));
584 }
585 Ok(())
586 }
587
588 pub fn device_public_key_hex(&self) -> &str {
589 let (_, _, _, device_public_key_hex, _) = self.base();
590 device_public_key_hex
591 }
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595pub struct Issue31PairingEvent {
596 pub event_id: String,
597 pub record: Issue31PairingRecord,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum Issue31GrantStatus {
602 Active,
603 Revoked,
604}
605
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub struct Issue31GrantState {
608 pub grant_ref: String,
609 pub host_ref: String,
610 pub host_public_key_hex: String,
611 pub sarah_public_key_hex: String,
612 pub device_public_key_hex: String,
613 pub generation: u64,
614 pub status: Issue31GrantStatus,
615 pub scopes: Vec<Issue31PairingScope>,
616 pub expires_at: Option<u64>,
617 pub issued_at: u64,
618 pub source_event_id: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
622#[serde(rename_all = "camelCase", deny_unknown_fields)]
623pub struct Issue31GrantProjection {
624 pub grant_ref: String,
625 pub device_fingerprint: String,
626 pub generation: u64,
627 pub status: String,
628 pub scopes: Vec<Issue31PairingScope>,
629 pub expires_at: Option<u64>,
630 pub source_event_id: String,
631}
632
633pub fn fold_issue31_grant(
634 events: &[Issue31PairingEvent],
635 grant_ref: &str,
636) -> Result<Option<Issue31GrantState>, Issue31NostrError> {
637 validate_ref_value(grant_ref, "grant")?;
638 let mut unique_events: BTreeMap<&str, &Issue31PairingRecord> = BTreeMap::new();
639 for event in events {
640 if !valid_hex64(&event.event_id) {
641 return Err(Issue31NostrError::Invalid(
642 "invalid pairing event id".into(),
643 ));
644 }
645 event.record.validate()?;
646 if let Some(prior) = unique_events.get(event.event_id.as_str()) {
647 if **prior != event.record {
648 return Err(Issue31NostrError::Invalid(format!(
649 "Issue 31 event {} has conflicting records",
650 event.event_id
651 )));
652 }
653 } else {
654 unique_events.insert(&event.event_id, &event.record);
655 }
656 }
657
658 let mut candidates: Vec<(&str, &Issue31PairingRecord)> = unique_events
659 .iter()
660 .map(|(event_id, record)| (*event_id, *record))
661 .filter(|(_, record)| {
662 record
663 .lifecycle_binding()
664 .is_some_and(|(_, _, _, _, candidate_grant_ref, _, _)| {
665 candidate_grant_ref == grant_ref
666 })
667 })
668 .collect();
669 candidates.sort_by(|(left_id, left), (right_id, right)| {
670 let left_generation = left
671 .lifecycle_binding()
672 .map(|(_, _, _, _, _, generation, _)| generation)
673 .unwrap_or_default();
674 let right_generation = right
675 .lifecycle_binding()
676 .map(|(_, _, _, _, _, generation, _)| generation)
677 .unwrap_or_default();
678 (left_generation, *left_id).cmp(&(right_generation, *right_id))
679 });
680
681 let Some((_, identity_record)) = candidates.first() else {
682 return Ok(None);
683 };
684 let (identity_host_ref, identity_host_key, identity_sarah_key, identity_device_key, _, _, _) =
685 identity_record
686 .lifecycle_binding()
687 .ok_or_else(|| Issue31NostrError::Invalid("invalid grant lifecycle record".into()))?;
688 if candidates.iter().any(|(_, record)| {
689 record.lifecycle_binding().is_some_and(
690 |(host_ref, host_key, sarah_key, device_key, _, _, _)| {
691 host_ref != identity_host_ref
692 || host_key != identity_host_key
693 || sarah_key != identity_sarah_key
694 || device_key != identity_device_key
695 },
696 )
697 }) {
698 return Err(Issue31NostrError::Invalid(format!(
699 "Issue 31 grant {grant_ref} has an identity fork"
700 )));
701 }
702
703 if let Some((
704 event_id,
705 Issue31PairingRecord::GrantRevocation {
706 host_ref,
707 host_public_key_hex,
708 sarah_public_key_hex,
709 device_public_key_hex,
710 issued_at,
711 generation,
712 ..
713 },
714 )) = candidates
715 .iter()
716 .rev()
717 .find(|(_, record)| matches!(record, Issue31PairingRecord::GrantRevocation { .. }))
718 .copied()
719 {
720 return Ok(Some(Issue31GrantState {
721 grant_ref: grant_ref.to_string(),
722 host_ref: host_ref.clone(),
723 host_public_key_hex: host_public_key_hex.clone(),
724 sarah_public_key_hex: sarah_public_key_hex.clone(),
725 device_public_key_hex: device_public_key_hex.clone(),
726 generation: *generation,
727 status: Issue31GrantStatus::Revoked,
728 scopes: Vec::new(),
729 expires_at: None,
730 issued_at: *issued_at,
731 source_event_id: event_id.to_string(),
732 }));
733 }
734
735 let mut records_by_generation: BTreeMap<u64, Vec<&Issue31PairingRecord>> = BTreeMap::new();
736 for (_, record) in &candidates {
737 let generation = record
738 .lifecycle_binding()
739 .map(|(_, _, _, _, _, generation, _)| generation)
740 .ok_or_else(|| Issue31NostrError::Invalid("invalid grant lifecycle record".into()))?;
741 records_by_generation
742 .entry(generation)
743 .or_default()
744 .push(record);
745 }
746 for (generation, records) in records_by_generation {
747 if records
748 .first()
749 .is_some_and(|first| records.iter().skip(1).any(|record| *record != *first))
750 {
751 return Err(Issue31NostrError::Invalid(format!(
752 "Issue 31 grant {grant_ref} forks at generation {generation}"
753 )));
754 }
755 }
756
757 let mut state: Option<Issue31GrantState> = None;
758 for (event_id, record) in candidates {
759 match (state.as_ref(), record) {
760 (
761 None,
762 Issue31PairingRecord::ScopedGrant {
763 host_ref,
764 host_public_key_hex,
765 sarah_public_key_hex,
766 device_public_key_hex,
767 issued_at,
768 generation,
769 scopes,
770 expires_at,
771 ..
772 },
773 ) => {
774 validate_scoped_grant_pairing_chain(record, &unique_events)?;
775 state = Some(Issue31GrantState {
776 grant_ref: grant_ref.to_string(),
777 host_ref: host_ref.clone(),
778 host_public_key_hex: host_public_key_hex.clone(),
779 sarah_public_key_hex: sarah_public_key_hex.clone(),
780 device_public_key_hex: device_public_key_hex.clone(),
781 generation: *generation,
782 status: Issue31GrantStatus::Active,
783 scopes: scopes.clone(),
784 expires_at: Some(*expires_at),
785 issued_at: *issued_at,
786 source_event_id: event_id.to_string(),
787 });
788 }
789 (
790 Some(prior),
791 Issue31PairingRecord::GrantRenewal {
792 host_ref,
793 host_public_key_hex,
794 sarah_public_key_hex,
795 device_public_key_hex,
796 issued_at,
797 previous_grant_event_id,
798 prior_generation,
799 generation,
800 scopes,
801 expires_at,
802 ..
803 },
804 ) if previous_grant_event_id == &prior.source_event_id
805 && *prior_generation == prior.generation
806 && *generation == prior.generation.saturating_add(1)
807 && host_ref == &prior.host_ref
808 && host_public_key_hex == &prior.host_public_key_hex
809 && sarah_public_key_hex == &prior.sarah_public_key_hex
810 && device_public_key_hex == &prior.device_public_key_hex =>
811 {
812 state = Some(Issue31GrantState {
813 grant_ref: grant_ref.to_string(),
814 host_ref: host_ref.clone(),
815 host_public_key_hex: host_public_key_hex.clone(),
816 sarah_public_key_hex: sarah_public_key_hex.clone(),
817 device_public_key_hex: device_public_key_hex.clone(),
818 generation: *generation,
819 status: Issue31GrantStatus::Active,
820 scopes: scopes.clone(),
821 expires_at: Some(*expires_at),
822 issued_at: *issued_at,
823 source_event_id: event_id.to_string(),
824 });
825 }
826 (None, Issue31PairingRecord::GrantRenewal { .. }) => {
827 return Err(Issue31NostrError::Invalid(format!(
828 "Issue 31 grant {grant_ref} renewal has no initial grant"
829 )));
830 }
831 (Some(_), Issue31PairingRecord::GrantRenewal { .. }) => {
832 return Err(Issue31NostrError::Invalid(format!(
833 "Issue 31 grant {grant_ref} renewal lineage is invalid"
834 )));
835 }
836 (Some(_), Issue31PairingRecord::ScopedGrant { .. }) => {
837 return Err(Issue31NostrError::Invalid(format!(
838 "Issue 31 grant {grant_ref} has more than one initial grant"
839 )));
840 }
841 (_, Issue31PairingRecord::GrantRevocation { .. }) => {}
842 (
843 _,
844 Issue31PairingRecord::PairingRequest { .. }
845 | Issue31PairingRecord::PairingChallenge { .. }
846 | Issue31PairingRecord::PairingResponse { .. },
847 ) => {
848 return Err(Issue31NostrError::Invalid(
849 "non-lifecycle pairing record entered grant projection".into(),
850 ));
851 }
852 }
853 }
854 Ok(state)
855}
856
857fn validate_scoped_grant_pairing_chain(
858 grant: &Issue31PairingRecord,
859 records_by_event_id: &BTreeMap<&str, &Issue31PairingRecord>,
860) -> Result<(), Issue31NostrError> {
861 let Issue31PairingRecord::ScopedGrant {
862 host_ref,
863 host_public_key_hex,
864 device_public_key_hex,
865 issued_at: grant_issued_at,
866 pairing_response_event_id,
867 scopes,
868 ..
869 } = grant
870 else {
871 return Err(Issue31NostrError::Invalid(
872 "pairing-chain validation requires a scoped grant".into(),
873 ));
874 };
875 let Some(Issue31PairingRecord::PairingResponse {
876 host_ref: response_host_ref,
877 host_public_key_hex: response_host_key,
878 device_public_key_hex: response_device_key,
879 issued_at: response_issued_at,
880 pairing_challenge_event_id,
881 challenge: response_challenge,
882 ..
883 }) = records_by_event_id
884 .get(pairing_response_event_id.as_str())
885 .copied()
886 else {
887 return Err(Issue31NostrError::Invalid(
888 "Issue 31 scoped grant has no pairing response".into(),
889 ));
890 };
891 let Some(Issue31PairingRecord::PairingChallenge {
892 host_ref: challenge_host_ref,
893 host_public_key_hex: challenge_host_key,
894 device_public_key_hex: challenge_device_key,
895 issued_at: challenge_issued_at,
896 pairing_request_event_id,
897 challenge,
898 ..
899 }) = records_by_event_id
900 .get(pairing_challenge_event_id.as_str())
901 .copied()
902 else {
903 return Err(Issue31NostrError::Invalid(
904 "Issue 31 pairing response has no challenge".into(),
905 ));
906 };
907 let Some(Issue31PairingRecord::PairingRequest {
908 host_ref: request_host_ref,
909 host_public_key_hex: request_host_key,
910 device_public_key_hex: request_device_key,
911 issued_at: request_issued_at,
912 requested_scopes,
913 ..
914 }) = records_by_event_id
915 .get(pairing_request_event_id.as_str())
916 .copied()
917 else {
918 return Err(Issue31NostrError::Invalid(
919 "Issue 31 pairing challenge has no request".into(),
920 ));
921 };
922 for (candidate_host_ref, candidate_host_key, candidate_device_key) in [
923 (response_host_ref, response_host_key, response_device_key),
924 (challenge_host_ref, challenge_host_key, challenge_device_key),
925 (request_host_ref, request_host_key, request_device_key),
926 ] {
927 if candidate_host_ref != host_ref
928 || candidate_host_key != host_public_key_hex
929 || candidate_device_key != device_public_key_hex
930 {
931 return Err(Issue31NostrError::Invalid(
932 "Issue 31 pairing chain changes host or device identity".into(),
933 ));
934 }
935 }
936 if response_challenge != challenge {
937 return Err(Issue31NostrError::Invalid(
938 "Issue 31 pairing response does not answer its challenge".into(),
939 ));
940 }
941 if scopes.iter().any(|scope| !requested_scopes.contains(scope)) {
942 return Err(Issue31NostrError::Invalid(
943 "Issue 31 scoped grant exceeds requested scopes".into(),
944 ));
945 }
946 if request_issued_at > challenge_issued_at
947 || challenge_issued_at > response_issued_at
948 || response_issued_at > grant_issued_at
949 {
950 return Err(Issue31NostrError::Invalid(
951 "Issue 31 pairing chain time order is invalid".into(),
952 ));
953 }
954 Ok(())
955}
956
957#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
958#[serde(rename_all = "snake_case")]
959pub enum Issue31CommandStatus {
960 Completed,
961 Failed,
962 Refused,
963 Stopped,
964 Unavailable,
965}
966
967#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
968#[serde(tag = "recordType", rename_all = "snake_case", deny_unknown_fields)]
969pub enum Issue31CommandRecord {
970 CommandIntent {
971 schema: String,
972 #[serde(rename = "hostRef")]
973 host_ref: String,
974 #[serde(rename = "hostPublicKeyHex")]
975 host_public_key_hex: String,
976 #[serde(rename = "devicePublicKeyHex")]
977 device_public_key_hex: String,
978 #[serde(rename = "grantRef")]
979 grant_ref: String,
980 #[serde(rename = "actionRef")]
981 action_ref: String,
982 #[serde(rename = "idempotencyRef")]
983 idempotency_ref: String,
984 #[serde(rename = "expectedGeneration")]
985 expected_generation: u64,
986 #[serde(rename = "argumentsRef")]
987 arguments_ref: String,
988 #[serde(rename = "issuedAt")]
989 issued_at: u64,
990 #[serde(rename = "expiresAt")]
991 expires_at: u64,
992 },
993 CommandResult {
994 schema: String,
995 #[serde(rename = "hostRef")]
996 host_ref: String,
997 #[serde(rename = "hostPublicKeyHex")]
998 host_public_key_hex: String,
999 #[serde(rename = "devicePublicKeyHex")]
1000 device_public_key_hex: String,
1001 #[serde(rename = "grantRef")]
1002 grant_ref: String,
1003 #[serde(rename = "intentEventId")]
1004 intent_event_id: String,
1005 #[serde(rename = "actionRef")]
1006 action_ref: String,
1007 #[serde(rename = "idempotencyRef")]
1008 idempotency_ref: String,
1009 #[serde(rename = "expectedGeneration")]
1010 expected_generation: u64,
1011 status: Issue31CommandStatus,
1012 #[serde(rename = "outcomeRef")]
1013 outcome_ref: String,
1014 #[serde(rename = "reasonRef", default, skip_serializing_if = "Option::is_none")]
1015 reason_ref: Option<String>,
1016 #[serde(rename = "completedAt")]
1017 completed_at: u64,
1018 },
1019}
1020
1021impl Issue31CommandRecord {
1022 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
1023 if bytes.len() > 64 * 1024 {
1024 return Err(Issue31NostrError::Invalid(
1025 "command record exceeds the record budget".into(),
1026 ));
1027 }
1028 let record: Self = serde_json::from_slice(bytes)
1029 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
1030 record.validate()?;
1031 Ok(record)
1032 }
1033
1034 pub fn validate(&self) -> Result<(), Issue31NostrError> {
1035 let (
1036 schema,
1037 host_ref,
1038 host_key,
1039 device_key,
1040 grant_ref,
1041 action_ref,
1042 idempotency_ref,
1043 generation,
1044 ) = self.binding();
1045 if schema != ISSUE31_COMMAND_SCHEMA
1046 || !valid_ref(host_ref)
1047 || !valid_hex64(host_key)
1048 || !valid_hex64(device_key)
1049 || !valid_ref(grant_ref)
1050 || !valid_ref(action_ref)
1051 || !valid_ref(idempotency_ref)
1052 || generation == 0
1053 {
1054 return Err(Issue31NostrError::Invalid("invalid command binding".into()));
1055 }
1056 match self {
1057 Self::CommandIntent {
1058 arguments_ref,
1059 issued_at,
1060 expires_at,
1061 ..
1062 } => {
1063 validate_ref_value(arguments_ref, "arguments")?;
1064 validate_lifetime(*issued_at, *expires_at)
1065 }
1066 Self::CommandResult {
1067 intent_event_id,
1068 outcome_ref,
1069 reason_ref,
1070 ..
1071 } => {
1072 validate_hex_value(intent_event_id, "intent event")?;
1073 validate_ref_value(outcome_ref, "outcome")?;
1074 if let Some(reason_ref) = reason_ref {
1075 validate_ref_value(reason_ref, "reason")?;
1076 }
1077 Ok(())
1078 }
1079 }
1080 }
1081
1082 fn binding(&self) -> (&str, &str, &str, &str, &str, &str, &str, u64) {
1083 match self {
1084 Self::CommandIntent {
1085 schema,
1086 host_ref,
1087 host_public_key_hex,
1088 device_public_key_hex,
1089 grant_ref,
1090 action_ref,
1091 idempotency_ref,
1092 expected_generation,
1093 ..
1094 }
1095 | Self::CommandResult {
1096 schema,
1097 host_ref,
1098 host_public_key_hex,
1099 device_public_key_hex,
1100 grant_ref,
1101 action_ref,
1102 idempotency_ref,
1103 expected_generation,
1104 ..
1105 } => (
1106 schema,
1107 host_ref,
1108 host_public_key_hex,
1109 device_public_key_hex,
1110 grant_ref,
1111 action_ref,
1112 idempotency_ref,
1113 *expected_generation,
1114 ),
1115 }
1116 }
1117
1118 pub fn validate_private_binding(
1119 &self,
1120 sender_public_key_hex: &str,
1121 recipient_public_key_hex: &str,
1122 ) -> Result<(), Issue31NostrError> {
1123 self.validate()?;
1124 let (_, _, host_key, device_key, _, _, _, _) = self.binding();
1125 let device_authored = matches!(self, Self::CommandIntent { .. });
1126 let expected_sender = if device_authored {
1127 device_key
1128 } else {
1129 host_key
1130 };
1131 let expected_recipient = if device_authored {
1132 host_key
1133 } else {
1134 device_key
1135 };
1136 if sender_public_key_hex != expected_sender
1137 || recipient_public_key_hex != expected_recipient
1138 {
1139 return Err(Issue31NostrError::Invalid(
1140 "command record signer or recipient does not match its binding".into(),
1141 ));
1142 }
1143 Ok(())
1144 }
1145
1146 pub fn device_public_key_hex(&self) -> &str {
1147 let (_, _, _, device_public_key_hex, _, _, _, _) = self.binding();
1148 device_public_key_hex
1149 }
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1153#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1154pub enum Issue31CommandArguments {
1155 SendMessage {
1156 #[serde(rename = "actionRef")]
1157 action_ref: String,
1158 conversation: String,
1159 text: String,
1160 },
1161 InterruptTurn {
1162 #[serde(rename = "actionRef")]
1163 action_ref: String,
1164 conversation: String,
1165 #[serde(rename = "turnRef")]
1166 turn_ref: String,
1167 },
1168 ReadStatePatch {
1169 #[serde(rename = "actionRef")]
1170 action_ref: String,
1171 #[serde(rename = "slotId")]
1172 slot_id: String,
1173 #[serde(rename = "clientId")]
1174 client_id: String,
1175 #[serde(rename = "contextRef")]
1176 context_ref: String,
1177 #[serde(rename = "readAt")]
1178 read_at: u64,
1179 },
1180 ReminderCreate {
1181 #[serde(rename = "actionRef")]
1182 action_ref: String,
1183 #[serde(rename = "reminderId")]
1184 reminder_id: String,
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1186 note: Option<String>,
1187 #[serde(
1188 rename = "targetEventId",
1189 default,
1190 skip_serializing_if = "Option::is_none"
1191 )]
1192 target_event_id: Option<String>,
1193 #[serde(rename = "notBefore")]
1194 not_before: u64,
1195 #[serde(default, skip_serializing_if = "Option::is_none")]
1196 expiration: Option<u64>,
1197 },
1198 ReminderChange {
1199 #[serde(rename = "actionRef")]
1200 action_ref: String,
1201 #[serde(rename = "reminderId")]
1202 reminder_id: String,
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 note: Option<String>,
1205 #[serde(
1206 rename = "targetEventId",
1207 default,
1208 skip_serializing_if = "Option::is_none"
1209 )]
1210 target_event_id: Option<String>,
1211 #[serde(rename = "notBefore")]
1212 not_before: u64,
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 expiration: Option<u64>,
1215 },
1216 ReminderComplete {
1217 #[serde(rename = "actionRef")]
1218 action_ref: String,
1219 #[serde(rename = "reminderId")]
1220 reminder_id: String,
1221 },
1222 ReminderCancel {
1223 #[serde(rename = "actionRef")]
1224 action_ref: String,
1225 #[serde(rename = "reminderId")]
1226 reminder_id: String,
1227 },
1228}
1229
1230impl Issue31CommandArguments {
1231 pub fn action_ref(&self) -> &str {
1232 match self {
1233 Self::SendMessage { action_ref, .. }
1234 | Self::InterruptTurn { action_ref, .. }
1235 | Self::ReadStatePatch { action_ref, .. }
1236 | Self::ReminderCreate { action_ref, .. }
1237 | Self::ReminderChange { action_ref, .. }
1238 | Self::ReminderComplete { action_ref, .. }
1239 | Self::ReminderCancel { action_ref, .. } => action_ref,
1240 }
1241 }
1242
1243 pub fn validate(&self) -> Result<(), Issue31NostrError> {
1244 let valid_short_identifier = |value: &str| {
1245 !value.is_empty()
1246 && value.len() <= 64
1247 && value
1248 .bytes()
1249 .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
1250 };
1251 match self {
1252 Self::SendMessage {
1253 action_ref,
1254 conversation,
1255 text,
1256 } if action_ref == ISSUE31_ACTION_SEND_MESSAGE
1257 && valid_conversation_tag(conversation)
1258 && !text.is_empty()
1259 && text.len() <= 12_000 =>
1260 {
1261 Ok(())
1262 }
1263 Self::InterruptTurn {
1264 action_ref,
1265 conversation,
1266 turn_ref,
1267 } if action_ref == ISSUE31_ACTION_INTERRUPT_TURN
1268 && valid_conversation_tag(conversation)
1269 && valid_ref(turn_ref) =>
1270 {
1271 Ok(())
1272 }
1273 Self::ReadStatePatch {
1274 action_ref,
1275 slot_id,
1276 client_id,
1277 context_ref,
1278 ..
1279 } if action_ref == ISSUE31_ACTION_ADVANCE_READ_STATE
1280 && valid_short_identifier(slot_id)
1281 && valid_short_identifier(client_id)
1282 && !context_ref.is_empty()
1283 && context_ref.len() <= 256
1284 && !context_ref.chars().any(char::is_control) =>
1285 {
1286 Ok(())
1287 }
1288 Self::ReminderCreate {
1289 action_ref,
1290 reminder_id,
1291 note,
1292 target_event_id,
1293 not_before,
1294 expiration,
1295 } if action_ref == ISSUE31_ACTION_CREATE_REMINDER => validate_reminder_arguments(
1296 reminder_id,
1297 note.as_deref(),
1298 target_event_id.as_deref(),
1299 *not_before,
1300 *expiration,
1301 ),
1302 Self::ReminderChange {
1303 action_ref,
1304 reminder_id,
1305 note,
1306 target_event_id,
1307 not_before,
1308 expiration,
1309 } if action_ref == ISSUE31_ACTION_CHANGE_REMINDER => validate_reminder_arguments(
1310 reminder_id,
1311 note.as_deref(),
1312 target_event_id.as_deref(),
1313 *not_before,
1314 *expiration,
1315 ),
1316 Self::ReminderComplete {
1317 action_ref,
1318 reminder_id,
1319 } if action_ref == ISSUE31_ACTION_COMPLETE_REMINDER && valid_hex32(reminder_id) => {
1320 Ok(())
1321 }
1322 Self::ReminderCancel {
1323 action_ref,
1324 reminder_id,
1325 } if action_ref == ISSUE31_ACTION_CANCEL_REMINDER && valid_hex32(reminder_id) => Ok(()),
1326 _ => Err(Issue31NostrError::Invalid(
1327 "Issue 31 command arguments violate their action contract".into(),
1328 )),
1329 }
1330 }
1331}
1332
1333#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1334#[serde(rename_all = "snake_case")]
1335pub enum Issue31CommandHandlingStatus {
1336 Accepted,
1337 Failed,
1338 Refused,
1339 Unavailable,
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1343#[serde(tag = "recordType", rename_all = "snake_case", deny_unknown_fields)]
1344pub enum Issue31CommandRecordV2 {
1345 CommandIntent {
1346 schema: String,
1347 #[serde(rename = "hostRef")]
1348 host_ref: String,
1349 #[serde(rename = "hostPublicKeyHex")]
1350 host_public_key_hex: String,
1351 #[serde(rename = "devicePublicKeyHex")]
1352 device_public_key_hex: String,
1353 #[serde(rename = "grantRef")]
1354 grant_ref: String,
1355 #[serde(rename = "idempotencyRef")]
1356 idempotency_ref: String,
1357 #[serde(rename = "expectedGeneration")]
1358 expected_generation: u64,
1359 arguments: Issue31CommandArguments,
1360 #[serde(rename = "issuedAt")]
1361 issued_at: u64,
1362 #[serde(rename = "expiresAt")]
1363 expires_at: u64,
1364 },
1365 CommandResult {
1366 schema: String,
1367 #[serde(rename = "hostRef")]
1368 host_ref: String,
1369 #[serde(rename = "hostPublicKeyHex")]
1370 host_public_key_hex: String,
1371 #[serde(rename = "devicePublicKeyHex")]
1372 device_public_key_hex: String,
1373 #[serde(rename = "grantRef")]
1374 grant_ref: String,
1375 #[serde(rename = "intentEventId")]
1376 intent_event_id: String,
1377 #[serde(rename = "actionRef")]
1378 action_ref: String,
1379 #[serde(rename = "idempotencyRef")]
1380 idempotency_ref: String,
1381 #[serde(rename = "expectedGeneration")]
1382 expected_generation: u64,
1383 status: Issue31CommandHandlingStatus,
1384 #[serde(rename = "handlingRef")]
1385 handling_ref: String,
1386 #[serde(rename = "reasonRef", default, skip_serializing_if = "Option::is_none")]
1387 reason_ref: Option<String>,
1388 #[serde(
1389 rename = "sourceEventId",
1390 default,
1391 skip_serializing_if = "Option::is_none"
1392 )]
1393 source_event_id: Option<String>,
1394 #[serde(rename = "handledAt")]
1395 handled_at: u64,
1396 },
1397}
1398
1399impl Issue31CommandRecordV2 {
1400 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
1401 if bytes.len() > 64 * 1024 {
1402 return Err(Issue31NostrError::Invalid(
1403 "command v2 record exceeds the record budget".into(),
1404 ));
1405 }
1406 let record: Self = serde_json::from_slice(bytes)
1407 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
1408 record.validate()?;
1409 Ok(record)
1410 }
1411
1412 pub fn validate(&self) -> Result<(), Issue31NostrError> {
1413 let (schema, host_ref, host_key, device_key, grant_ref, idempotency_ref, generation) =
1414 self.binding();
1415 if schema != ISSUE31_COMMAND_SCHEMA_V2
1416 || !valid_ref(host_ref)
1417 || !valid_hex64(host_key)
1418 || !valid_hex64(device_key)
1419 || !valid_ref(grant_ref)
1420 || !valid_ref(idempotency_ref)
1421 || generation == 0
1422 {
1423 return Err(Issue31NostrError::Invalid(
1424 "invalid command v2 binding".into(),
1425 ));
1426 }
1427 match self {
1428 Self::CommandIntent {
1429 arguments,
1430 issued_at,
1431 expires_at,
1432 ..
1433 } => {
1434 arguments.validate()?;
1435 validate_lifetime(*issued_at, *expires_at)
1436 }
1437 Self::CommandResult {
1438 intent_event_id,
1439 action_ref,
1440 status,
1441 handling_ref,
1442 reason_ref,
1443 source_event_id,
1444 ..
1445 } => {
1446 validate_hex_value(intent_event_id, "intent event")?;
1447 validate_ref_value(action_ref, "action")?;
1448 validate_ref_value(handling_ref, "handling")?;
1449 if let Some(reason_ref) = reason_ref {
1450 validate_ref_value(reason_ref, "reason")?;
1451 }
1452 if let Some(source_event_id) = source_event_id {
1453 validate_hex_value(source_event_id, "source event")?;
1454 }
1455 if *status == Issue31CommandHandlingStatus::Accepted && reason_ref.is_some() {
1456 return Err(Issue31NostrError::Invalid(
1457 "accepted command handling cannot carry a failure reason".into(),
1458 ));
1459 }
1460 Ok(())
1461 }
1462 }
1463 }
1464
1465 fn binding(&self) -> (&str, &str, &str, &str, &str, &str, u64) {
1466 match self {
1467 Self::CommandIntent {
1468 schema,
1469 host_ref,
1470 host_public_key_hex,
1471 device_public_key_hex,
1472 grant_ref,
1473 idempotency_ref,
1474 expected_generation,
1475 ..
1476 }
1477 | Self::CommandResult {
1478 schema,
1479 host_ref,
1480 host_public_key_hex,
1481 device_public_key_hex,
1482 grant_ref,
1483 idempotency_ref,
1484 expected_generation,
1485 ..
1486 } => (
1487 schema,
1488 host_ref,
1489 host_public_key_hex,
1490 device_public_key_hex,
1491 grant_ref,
1492 idempotency_ref,
1493 *expected_generation,
1494 ),
1495 }
1496 }
1497
1498 pub fn validate_private_binding(
1499 &self,
1500 sender_public_key_hex: &str,
1501 recipient_public_key_hex: &str,
1502 ) -> Result<(), Issue31NostrError> {
1503 self.validate()?;
1504 let (_, _, host_key, device_key, _, _, _) = self.binding();
1505 let device_authored = matches!(self, Self::CommandIntent { .. });
1506 let (expected_sender, expected_recipient) = if device_authored {
1507 (device_key, host_key)
1508 } else {
1509 (host_key, device_key)
1510 };
1511 if sender_public_key_hex != expected_sender
1512 || recipient_public_key_hex != expected_recipient
1513 {
1514 return Err(Issue31NostrError::Invalid(
1515 "command v2 signer or recipient does not match its binding".into(),
1516 ));
1517 }
1518 Ok(())
1519 }
1520
1521 pub fn device_public_key_hex(&self) -> &str {
1522 self.binding().3
1523 }
1524}
1525
1526#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1527#[serde(rename_all = "snake_case")]
1528pub enum Issue31SourceRole {
1529 Owner,
1530 Sarah,
1531}
1532
1533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1534#[serde(rename_all = "camelCase", deny_unknown_fields)]
1535pub struct Issue31AuthorityDecisionProjection {
1536 pub state: String,
1537 pub decision_ref: String,
1538 #[serde(default, skip_serializing_if = "Option::is_none")]
1539 pub reason_ref: Option<String>,
1540}
1541
1542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1543#[serde(rename_all = "camelCase", deny_unknown_fields)]
1544pub struct Issue31TargetOutcomeProjection {
1545 pub state: String,
1546 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub outcome_ref: Option<String>,
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1549 pub reason_ref: Option<String>,
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1553#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1554pub enum Issue31OwnerProjectionBody {
1555 Message {
1556 role: Issue31SourceRole,
1557 conversation: String,
1558 text: String,
1559 #[serde(
1560 rename = "replyToEventId",
1561 default,
1562 skip_serializing_if = "Option::is_none"
1563 )]
1564 reply_to_event_id: Option<String>,
1565 },
1566 Turn {
1567 payload: serde_json::Value,
1568 },
1569 AuthorityReceipt {
1570 #[serde(rename = "receiptRef")]
1571 receipt_ref: String,
1572 #[serde(rename = "turnRef")]
1573 turn_ref: String,
1574 #[serde(rename = "authorityDecision")]
1575 authority_decision: Issue31AuthorityDecisionProjection,
1576 #[serde(rename = "targetOutcome")]
1577 target_outcome: Issue31TargetOutcomeProjection,
1578 },
1579 Engram {
1580 #[serde(rename = "dTag")]
1581 d_tag: String,
1582 plaintext: String,
1583 },
1584 ReadState {
1585 #[serde(rename = "dTag")]
1586 d_tag: String,
1587 plaintext: String,
1588 },
1589 Reminder {
1590 #[serde(rename = "reminderId")]
1591 reminder_id: String,
1592 plaintext: String,
1593 #[serde(rename = "notBefore", default, skip_serializing_if = "Option::is_none")]
1594 not_before: Option<u64>,
1595 #[serde(default, skip_serializing_if = "Option::is_none")]
1596 expiration: Option<u64>,
1597 },
1598}
1599
1600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1601#[serde(rename_all = "camelCase", deny_unknown_fields)]
1602pub struct Issue31OwnerProjectionRecord {
1603 pub schema: String,
1604 pub record_type: String,
1605 pub host_ref: String,
1606 pub host_public_key_hex: String,
1607 pub device_public_key_hex: String,
1608 pub grant_ref: String,
1609 pub expected_generation: u64,
1610 pub source_event_id: String,
1611 pub source_author_public_key_hex: String,
1612 pub source_role: Issue31SourceRole,
1613 pub source_kind: u16,
1614 pub source_created_at: u64,
1615 pub projected_at: u64,
1616 pub projection: Issue31OwnerProjectionBody,
1617}
1618
1619impl Issue31OwnerProjectionRecord {
1620 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
1621 if bytes.len() > 640 * 1024 {
1622 return Err(Issue31NostrError::Invalid(
1623 "owner projection exceeds the record budget".into(),
1624 ));
1625 }
1626 let record: Self = serde_json::from_slice(bytes)
1627 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
1628 record.validate()?;
1629 Ok(record)
1630 }
1631
1632 pub fn validate(&self) -> Result<(), Issue31NostrError> {
1633 if self.schema != ISSUE31_OWNER_PROJECTION_SCHEMA
1634 || self.record_type != "owner_projection"
1635 || !valid_ref(&self.host_ref)
1636 || !valid_hex64(&self.host_public_key_hex)
1637 || !valid_hex64(&self.device_public_key_hex)
1638 || !valid_ref(&self.grant_ref)
1639 || self.expected_generation == 0
1640 || !valid_hex64(&self.source_event_id)
1641 || !valid_hex64(&self.source_author_public_key_hex)
1642 || self.projected_at < self.source_created_at
1643 {
1644 return Err(Issue31NostrError::Invalid(
1645 "invalid owner projection binding".into(),
1646 ));
1647 }
1648 let (expected_kind, expected_role) = self.projection.validate()?;
1649 if self.source_kind != expected_kind || self.source_role != expected_role {
1650 return Err(Issue31NostrError::Invalid(
1651 "owner projection source kind or role does not match its body".into(),
1652 ));
1653 }
1654 Ok(())
1655 }
1656
1657 pub fn validate_private_binding(
1658 &self,
1659 sender_public_key_hex: &str,
1660 recipient_public_key_hex: &str,
1661 sarah_public_key_hex: &str,
1662 ) -> Result<(), Issue31NostrError> {
1663 self.validate()?;
1664 let expected_source_author = match self.source_role {
1665 Issue31SourceRole::Owner => &self.host_public_key_hex,
1666 Issue31SourceRole::Sarah => sarah_public_key_hex,
1667 };
1668 if sender_public_key_hex != self.host_public_key_hex
1669 || recipient_public_key_hex != self.device_public_key_hex
1670 || self.source_author_public_key_hex != expected_source_author
1671 {
1672 return Err(Issue31NostrError::Invalid(
1673 "owner projection signer, recipient, or source author is invalid".into(),
1674 ));
1675 }
1676 Ok(())
1677 }
1678}
1679
1680impl Issue31OwnerProjectionBody {
1681 fn validate(&self) -> Result<(u16, Issue31SourceRole), Issue31NostrError> {
1682 match self {
1683 Self::Message {
1684 role,
1685 conversation,
1686 text,
1687 reply_to_event_id,
1688 } if valid_conversation_tag(conversation)
1689 && !text.is_empty()
1690 && text.len() <= 12_000
1691 && reply_to_event_id
1692 .as_ref()
1693 .is_none_or(|value| valid_hex64(value)) =>
1694 {
1695 Ok((ISSUE31_PRIVATE_RUMOR_KIND, *role))
1696 }
1697 Self::Turn { payload } if valid_turn_payload(payload) => {
1698 Ok((SARAH_TURN_RECORD_KIND, Issue31SourceRole::Sarah))
1699 }
1700 Self::AuthorityReceipt {
1701 receipt_ref,
1702 turn_ref,
1703 authority_decision,
1704 target_outcome,
1705 } => {
1706 validate_ref_value(receipt_ref, "receipt")?;
1707 validate_ref_value(turn_ref, "turn")?;
1708 validate_authority_projection(authority_decision, target_outcome)?;
1709 Ok((SARAH_AUTHORITY_RECEIPT_KIND, Issue31SourceRole::Sarah))
1710 }
1711 Self::Engram { d_tag, plaintext }
1712 if valid_hex64(d_tag)
1713 && !plaintext.is_empty()
1714 && plaintext.len() <= 65_535
1715 && valid_engram_plaintext(plaintext) =>
1716 {
1717 Ok((SARAH_ENGRAM_KIND, Issue31SourceRole::Sarah))
1718 }
1719 Self::ReadState { d_tag, plaintext }
1720 if !d_tag.is_empty()
1721 && d_tag.len() <= 256
1722 && !plaintext.is_empty()
1723 && plaintext.len() <= 524_288
1724 && valid_read_state_plaintext(plaintext) =>
1725 {
1726 Ok((SARAH_READ_STATE_KIND, Issue31SourceRole::Owner))
1727 }
1728 Self::Reminder {
1729 reminder_id,
1730 plaintext,
1731 not_before,
1732 expiration,
1733 } if valid_hex32(reminder_id)
1734 && !plaintext.is_empty()
1735 && plaintext.len() <= 524_288
1736 && valid_reminder_plaintext(plaintext, *not_before)
1737 && expiration
1738 .zip(*not_before)
1739 .is_none_or(|(expiration, not_before)| expiration > not_before) =>
1740 {
1741 Ok((SARAH_REMINDER_KIND, Issue31SourceRole::Owner))
1742 }
1743 _ => Err(Issue31NostrError::Invalid(
1744 "owner projection body violates its source contract".into(),
1745 )),
1746 }
1747 }
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq)]
1751pub struct Issue31OwnerProjectionInput<'a> {
1752 pub host_ref: &'a str,
1753 pub host_public_key_hex: &'a str,
1754 pub device_public_key_hex: &'a str,
1755 pub sarah_public_key_hex: &'a str,
1756 pub grant_ref: &'a str,
1757 pub expected_generation: u64,
1758 pub source_event_id: &'a str,
1759 pub source_author_public_key_hex: &'a str,
1760 pub source_kind: u16,
1761 pub source_created_at: u64,
1762 pub projected_at: u64,
1763 pub projection: Issue31OwnerProjectionBody,
1764}
1765
1766#[derive(Debug, Clone, PartialEq, Eq)]
1767pub struct Issue31OwnerProjectionEmission {
1768 pub record: Issue31OwnerProjectionRecord,
1769 pub content: String,
1770}
1771
1772/// Emit an owner projection for one admitted device.
1773///
1774/// The emitted bytes are routed back through `Issue31OwnerProjectionRecord::decode`
1775/// and the private-binding check before they are returned, so the host cannot
1776/// publish a projection its own reader would refuse. `decode` also applies the
1777/// record budget, which a direct `validate` call does not: a body inside its
1778/// per-field bounds can still serialize past the budget once JSON escaping is
1779/// applied, and that record would be readable on the host and unreadable on the
1780/// device. `source_role` is derived from the body rather than supplied, because
1781/// a caller-chosen role is a second source of truth for something the body
1782/// already decides.
1783pub fn emit_issue31_owner_projection(
1784 input: Issue31OwnerProjectionInput<'_>,
1785) -> Result<Issue31OwnerProjectionEmission, Issue31NostrError> {
1786 let (_, source_role) = input.projection.validate()?;
1787 let record = Issue31OwnerProjectionRecord {
1788 schema: ISSUE31_OWNER_PROJECTION_SCHEMA.into(),
1789 record_type: "owner_projection".into(),
1790 host_ref: input.host_ref.into(),
1791 host_public_key_hex: input.host_public_key_hex.into(),
1792 device_public_key_hex: input.device_public_key_hex.into(),
1793 grant_ref: input.grant_ref.into(),
1794 expected_generation: input.expected_generation,
1795 source_event_id: input.source_event_id.into(),
1796 source_author_public_key_hex: input.source_author_public_key_hex.into(),
1797 source_role,
1798 source_kind: input.source_kind,
1799 source_created_at: input.source_created_at,
1800 projected_at: input.projected_at,
1801 projection: input.projection,
1802 };
1803 let content = serde_json::to_string(&record)
1804 .map_err(|error| Issue31NostrError::Invalid(error.to_string()))?;
1805 let decoded = Issue31OwnerProjectionRecord::decode(content.as_bytes())?;
1806 decoded.validate_private_binding(
1807 input.host_public_key_hex,
1808 input.device_public_key_hex,
1809 input.sarah_public_key_hex,
1810 )?;
1811 if decoded != record {
1812 return Err(Issue31NostrError::Invalid(
1813 "owner projection did not survive its own decoder".into(),
1814 ));
1815 }
1816 Ok(Issue31OwnerProjectionEmission {
1817 record: decoded,
1818 content,
1819 })
1820}
1821
1822/// Why a source the owner is entitled to see never became a projection.
1823///
1824/// Only causes the host can actually observe are on the wire. A device-side
1825/// read failure is real and is counted, but it is counted on the device, so a
1826/// host cannot assert something only the device can know.
1827#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1828#[serde(rename_all = "snake_case")]
1829pub enum Issue31WithheldCause {
1830 /// The source was quarantined: its plaintext, its `d` tag, or its
1831 /// projection body was refused, so it was removed from the pass.
1832 Quarantined,
1833 /// The bounded projection scan stopped before the end of the conversation,
1834 /// so an unknown number of later sources were never examined at all.
1835 ScanBound,
1836}
1837
1838impl Issue31WithheldCause {
1839 /// Whether the host can state an exact number for this cause.
1840 ///
1841 /// A quarantine is counted one event at a time, so it is exact. The scan
1842 /// bound is the opposite: the host stopped reading, so it knows only that
1843 /// at least one source is unexamined. Reporting "1 withheld" as exact when
1844 /// nine hundred are unread would be a worse lie than silence.
1845 fn is_exact(self) -> bool {
1846 match self {
1847 Self::Quarantined => true,
1848 Self::ScanBound => false,
1849 }
1850 }
1851}
1852
1853#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1854#[serde(rename_all = "camelCase", deny_unknown_fields)]
1855pub struct Issue31WithheldSourceCount {
1856 pub cause: Issue31WithheldCause,
1857 pub count: u32,
1858 /// `true` when `count` is the number withheld, `false` when it is a lower
1859 /// bound and the true number is unknown.
1860 pub exact: bool,
1861 pub reason_ref: String,
1862}
1863
1864/// A host statement, per admitted device, about how complete that device's
1865/// owner projection is.
1866///
1867/// Exit 4 of omega#46 requires that every engram reaches the device or that the
1868/// gap is exact. Before this record there was no mechanism the device could
1869/// see: a phone rendered a confident, complete-looking list and nothing said it
1870/// was short. Silence is not completeness, so a device with no such record must
1871/// read "unknown", never "complete" — that is why a complete pass emits a
1872/// record too, rather than emitting nothing.
1873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1874#[serde(rename_all = "camelCase", deny_unknown_fields)]
1875pub struct Issue31WithheldSourcesRecord {
1876 pub schema: String,
1877 pub record_type: String,
1878 pub host_ref: String,
1879 pub host_public_key_hex: String,
1880 pub device_public_key_hex: String,
1881 pub grant_ref: String,
1882 pub expected_generation: u64,
1883 pub observed_at: u64,
1884 /// `complete` when nothing was withheld, `partial` when something was.
1885 /// The two states are structurally different so a reader cannot render
1886 /// them the same way by accident.
1887 pub coverage: String,
1888 pub withheld: Vec<Issue31WithheldSourceCount>,
1889}
1890
1891pub const ISSUE31_WITHHELD_COVERAGE_COMPLETE: &str = "complete";
1892pub const ISSUE31_WITHHELD_COVERAGE_PARTIAL: &str = "partial";
1893const MAX_ISSUE31_WITHHELD_ENTRIES: usize = 8;
1894
1895impl Issue31WithheldSourcesRecord {
1896 pub fn decode(bytes: &[u8]) -> Result<Self, Issue31NostrError> {
1897 if bytes.len() > 8 * 1024 {
1898 return Err(Issue31NostrError::Invalid(
1899 "withheld sources record exceeds the record budget".into(),
1900 ));
1901 }
1902 let record: Self = serde_json::from_slice(bytes)
1903 .map_err(|error| Issue31NostrError::Decode(error.to_string()))?;
1904 record.validate()?;
1905 Ok(record)
1906 }
1907
1908 pub fn validate(&self) -> Result<(), Issue31NostrError> {
1909 if self.schema != ISSUE31_WITHHELD_SOURCES_SCHEMA
1910 || self.record_type != "withheld_sources"
1911 || !valid_ref(&self.host_ref)
1912 || !valid_hex64(&self.host_public_key_hex)
1913 || !valid_hex64(&self.device_public_key_hex)
1914 || !valid_ref(&self.grant_ref)
1915 || self.expected_generation == 0
1916 {
1917 return Err(Issue31NostrError::Invalid(
1918 "invalid withheld sources binding".into(),
1919 ));
1920 }
1921 if self.withheld.len() > MAX_ISSUE31_WITHHELD_ENTRIES {
1922 return Err(Issue31NostrError::Invalid(
1923 "withheld sources record exceeds its entry bound".into(),
1924 ));
1925 }
1926 let mut seen: BTreeSet<(Issue31WithheldCause, &str)> = BTreeSet::new();
1927 for entry in &self.withheld {
1928 if entry.count == 0 {
1929 return Err(Issue31NostrError::Invalid(
1930 "a withheld source count of zero is not a withheld source".into(),
1931 ));
1932 }
1933 if entry.exact != entry.cause.is_exact() {
1934 return Err(Issue31NostrError::Invalid(
1935 "withheld source exactness does not match what its cause can know".into(),
1936 ));
1937 }
1938 if !valid_ref(&entry.reason_ref) {
1939 return Err(Issue31NostrError::Invalid(
1940 "a withheld source count needs an exact reason".into(),
1941 ));
1942 }
1943 if !seen.insert((entry.cause, entry.reason_ref.as_str())) {
1944 return Err(Issue31NostrError::Invalid(
1945 "withheld source counts repeat a cause and reason".into(),
1946 ));
1947 }
1948 }
1949 let expected_coverage = if self.withheld.is_empty() {
1950 ISSUE31_WITHHELD_COVERAGE_COMPLETE
1951 } else {
1952 ISSUE31_WITHHELD_COVERAGE_PARTIAL
1953 };
1954 if self.coverage != expected_coverage {
1955 return Err(Issue31NostrError::Invalid(
1956 "withheld sources coverage does not match its counts".into(),
1957 ));
1958 }
1959 Ok(())
1960 }
1961
1962 pub fn validate_private_binding(
1963 &self,
1964 sender_public_key_hex: &str,
1965 recipient_public_key_hex: &str,
1966 ) -> Result<(), Issue31NostrError> {
1967 self.validate()?;
1968 if sender_public_key_hex != self.host_public_key_hex
1969 || recipient_public_key_hex != self.device_public_key_hex
1970 {
1971 return Err(Issue31NostrError::Invalid(
1972 "withheld sources signer or recipient is invalid".into(),
1973 ));
1974 }
1975 Ok(())
1976 }
1977
1978 /// The part of the record that says something about the world, with the
1979 /// observation time removed.
1980 ///
1981 /// The host re-runs its projection pass continuously. Re-publishing an
1982 /// identical statement with only a new timestamp would fill the relay with
1983 /// noise, so the host compares this instead.
1984 pub fn substance(&self) -> (String, Vec<Issue31WithheldSourceCount>) {
1985 (self.coverage.clone(), self.withheld.clone())
1986 }
1987}
1988
1989#[derive(Debug, Clone, PartialEq, Eq)]
1990pub struct Issue31WithheldSourcesInput<'a> {
1991 pub host_ref: &'a str,
1992 pub host_public_key_hex: &'a str,
1993 pub device_public_key_hex: &'a str,
1994 pub grant_ref: &'a str,
1995 pub expected_generation: u64,
1996 pub observed_at: u64,
1997 pub withheld: Vec<Issue31WithheldSourceCount>,
1998}
1999
2000#[derive(Debug, Clone, PartialEq, Eq)]
2001pub struct Issue31WithheldSourcesEmission {
2002 pub record: Issue31WithheldSourcesRecord,
2003 pub content: String,
2004}
2005
2006/// Emit one device's withheld-source statement.
2007///
2008/// This follows `emit_issue31_owner_projection`: the bytes are routed back
2009/// through this record's own decoder and private-binding check before they are
2010/// returned, so the host cannot publish a coverage statement its own reader
2011/// would refuse. `coverage` is derived from the counts rather than supplied,
2012/// because a caller-chosen coverage value is a second source of truth for
2013/// something the counts already decide — and it is exactly the field a bug
2014/// would set to "complete" over a non-empty list.
2015pub fn emit_issue31_withheld_sources(
2016 input: Issue31WithheldSourcesInput<'_>,
2017) -> Result<Issue31WithheldSourcesEmission, Issue31NostrError> {
2018 let mut withheld = input.withheld;
2019 withheld.sort_by(|left, right| {
2020 left.cause
2021 .cmp(&right.cause)
2022 .then_with(|| left.reason_ref.cmp(&right.reason_ref))
2023 });
2024 let coverage = if withheld.is_empty() {
2025 ISSUE31_WITHHELD_COVERAGE_COMPLETE
2026 } else {
2027 ISSUE31_WITHHELD_COVERAGE_PARTIAL
2028 };
2029 let record = Issue31WithheldSourcesRecord {
2030 schema: ISSUE31_WITHHELD_SOURCES_SCHEMA.into(),
2031 record_type: "withheld_sources".into(),
2032 host_ref: input.host_ref.into(),
2033 host_public_key_hex: input.host_public_key_hex.into(),
2034 device_public_key_hex: input.device_public_key_hex.into(),
2035 grant_ref: input.grant_ref.into(),
2036 expected_generation: input.expected_generation,
2037 observed_at: input.observed_at,
2038 coverage: coverage.into(),
2039 withheld,
2040 };
2041 let content = serde_json::to_string(&record)
2042 .map_err(|error| Issue31NostrError::Invalid(error.to_string()))?;
2043 let decoded = Issue31WithheldSourcesRecord::decode(content.as_bytes())?;
2044 decoded.validate_private_binding(input.host_public_key_hex, input.device_public_key_hex)?;
2045 if decoded != record {
2046 return Err(Issue31NostrError::Invalid(
2047 "withheld sources record did not survive its own decoder".into(),
2048 ));
2049 }
2050 Ok(Issue31WithheldSourcesEmission {
2051 record: decoded,
2052 content,
2053 })
2054}
2055
2056#[derive(Debug, Clone, PartialEq, Eq)]
2057pub struct Issue31CommandEvent {
2058 pub event_id: String,
2059 pub record: Issue31CommandRecord,
2060}
2061
2062#[derive(Debug, Clone, PartialEq, Eq)]
2063pub struct Issue31CommandState {
2064 pub intent_event_id: String,
2065 pub intent: Issue31CommandRecord,
2066 pub result_event_id: Option<String>,
2067 pub result: Option<Issue31CommandRecord>,
2068}
2069
2070pub fn reconcile_issue31_commands(
2071 events: &[Issue31CommandEvent],
2072) -> Result<Vec<Issue31CommandState>, Issue31NostrError> {
2073 let mut intents: BTreeMap<String, (&String, &Issue31CommandRecord)> = BTreeMap::new();
2074 let mut results: BTreeMap<String, (&String, &Issue31CommandRecord)> = BTreeMap::new();
2075 for event in events {
2076 event.record.validate()?;
2077 if !valid_hex64(&event.event_id) {
2078 return Err(Issue31NostrError::Invalid(
2079 "invalid command event id".into(),
2080 ));
2081 }
2082 let (_, _, _, _, _, _, idempotency_ref, _) = event.record.binding();
2083 let target = match &event.record {
2084 Issue31CommandRecord::CommandIntent { .. } => &mut intents,
2085 Issue31CommandRecord::CommandResult { .. } => &mut results,
2086 };
2087 if let Some((prior_event_id, prior)) = target.get_mut(idempotency_ref) {
2088 if *prior != &event.record {
2089 return Err(Issue31NostrError::Invalid(format!(
2090 "conflicting records for {idempotency_ref}"
2091 )));
2092 }
2093 if event.event_id.as_str() < prior_event_id.as_str() {
2094 *prior_event_id = &event.event_id;
2095 }
2096 } else {
2097 target.insert(
2098 idempotency_ref.to_string(),
2099 (&event.event_id, &event.record),
2100 );
2101 }
2102 }
2103
2104 let mut states = Vec::new();
2105 for (idempotency_ref, (intent_event_id, intent)) in intents {
2106 let result = results.get(&idempotency_ref).copied();
2107 if let Some((_, result_record)) = result {
2108 let Issue31CommandRecord::CommandResult {
2109 intent_event_id: result_intent_id,
2110 ..
2111 } = result_record
2112 else {
2113 return Err(Issue31NostrError::Invalid(
2114 "result map contained intent".into(),
2115 ));
2116 };
2117 if result_intent_id != intent_event_id || result_record.binding() != intent.binding() {
2118 return Err(Issue31NostrError::Invalid(format!(
2119 "command result does not match {idempotency_ref}"
2120 )));
2121 }
2122 }
2123 states.push(Issue31CommandState {
2124 intent_event_id: intent_event_id.clone(),
2125 intent: intent.clone(),
2126 result_event_id: result.map(|(event_id, _)| event_id.clone()),
2127 result: result.map(|(_, record)| record.clone()),
2128 });
2129 }
2130 Ok(states)
2131}
2132
2133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2134pub struct Issue31HostConfiguration {
2135 pub host_ref: String,
2136 pub host_public_key_hex: String,
2137 pub sarah_public_key_hex: String,
2138 #[serde(default)]
2139 pub conversation: String,
2140 pub display_name: String,
2141 pub relay_urls: Vec<String>,
2142 pub generation: u64,
2143}
2144
2145#[derive(Debug, Clone, PartialEq, Eq)]
2146pub struct Issue31CommandExecution {
2147 pub status: Issue31CommandStatus,
2148 pub outcome_ref: String,
2149 pub reason_ref: Option<String>,
2150}
2151
2152#[derive(Debug, Clone, PartialEq, Eq)]
2153pub struct Issue31CommandExecutionV2 {
2154 pub status: Issue31CommandHandlingStatus,
2155 pub handling_ref: String,
2156 pub reason_ref: Option<String>,
2157 pub source_event_id: Option<String>,
2158}
2159
2160#[derive(Debug, Clone, Serialize, Deserialize)]
2161#[serde(rename_all = "camelCase", deny_unknown_fields)]
2162pub struct Issue31HostController {
2163 configuration: Issue31HostConfiguration,
2164 #[serde(skip)]
2165 admitted_device_scopes: BTreeMap<String, BTreeSet<Issue31PairingScope>>,
2166 pairing_events: Vec<Issue31PairingEvent>,
2167 processed_pairing_event_ids: BTreeSet<String>,
2168 processed_command_event_ids: BTreeSet<String>,
2169 command_results: BTreeMap<String, (Issue31CommandRecord, Issue31CommandRecord)>,
2170 #[serde(default)]
2171 command_results_v2: BTreeMap<String, (Issue31CommandRecordV2, Issue31CommandRecordV2)>,
2172 #[serde(default)]
2173 projected_source_event_ids: BTreeMap<String, BTreeSet<String>>,
2174 /// Revocation event ids the owner has explicitly cleared by re-admitting the
2175 /// device they name. A revocation that is not listed here permanently blocks
2176 /// its device from being challenged or granted again, whatever the runtime
2177 /// admission allowlist says.
2178 #[serde(default)]
2179 cleared_device_revocation_event_ids: BTreeSet<String>,
2180}
2181
2182const MAX_ISSUE31_PAIRING_EVENTS: usize = 4_096;
2183const MAX_ISSUE31_PROCESSED_EVENTS: usize = 4_096;
2184const MAX_ISSUE31_COMMAND_RESULTS: usize = 1_024;
2185const MAX_ISSUE31_PROJECTED_SOURCE_EVENTS: usize = 16_384;
2186
2187impl Issue31HostController {
2188 pub fn new(configuration: Issue31HostConfiguration) -> Result<Self, Issue31NostrError> {
2189 Issue31HostDiscovery {
2190 schema: ISSUE31_HOST_DISCOVERY_SCHEMA.into(),
2191 host_ref: configuration.host_ref.clone(),
2192 host_public_key_hex: configuration.host_public_key_hex.clone(),
2193 sarah_public_key_hex: configuration.sarah_public_key_hex.clone(),
2194 display_name: configuration.display_name.clone(),
2195 protocols: vec![ISSUE31_PAIRING_SCHEMA.into(), ISSUE31_COMMAND_SCHEMA.into()],
2196 relay_urls: configuration.relay_urls.clone(),
2197 generation: configuration.generation,
2198 issued_at: 1,
2199 expires_at: 2,
2200 }
2201 .validate()?;
2202 Issue31HostDiscoveryV2 {
2203 schema: ISSUE31_HOST_DISCOVERY_SCHEMA_V2.into(),
2204 host_ref: configuration.host_ref.clone(),
2205 host_public_key_hex: configuration.host_public_key_hex.clone(),
2206 sarah_public_key_hex: configuration.sarah_public_key_hex.clone(),
2207 conversation: configuration.conversation.clone(),
2208 display_name: configuration.display_name.clone(),
2209 protocols: vec![
2210 ISSUE31_PAIRING_SCHEMA.into(),
2211 ISSUE31_COMMAND_SCHEMA.into(),
2212 ISSUE31_COMMAND_SCHEMA_V2.into(),
2213 ],
2214 relay_urls: configuration.relay_urls.clone(),
2215 generation: configuration.generation,
2216 issued_at: 1,
2217 expires_at: 2,
2218 }
2219 .validate()?;
2220 Ok(Self {
2221 configuration,
2222 admitted_device_scopes: BTreeMap::new(),
2223 pairing_events: Vec::new(),
2224 processed_pairing_event_ids: BTreeSet::new(),
2225 processed_command_event_ids: BTreeSet::new(),
2226 command_results: BTreeMap::new(),
2227 command_results_v2: BTreeMap::new(),
2228 projected_source_event_ids: BTreeMap::new(),
2229 cleared_device_revocation_event_ids: BTreeSet::new(),
2230 })
2231 }
2232
2233 pub fn discovery(
2234 &self,
2235 issued_at: u64,
2236 expires_at: u64,
2237 ) -> Result<Issue31HostDiscovery, Issue31NostrError> {
2238 let discovery = Issue31HostDiscovery {
2239 schema: ISSUE31_HOST_DISCOVERY_SCHEMA.into(),
2240 host_ref: self.configuration.host_ref.clone(),
2241 host_public_key_hex: self.configuration.host_public_key_hex.clone(),
2242 sarah_public_key_hex: self.configuration.sarah_public_key_hex.clone(),
2243 display_name: self.configuration.display_name.clone(),
2244 protocols: vec![ISSUE31_PAIRING_SCHEMA.into(), ISSUE31_COMMAND_SCHEMA.into()],
2245 relay_urls: self.configuration.relay_urls.clone(),
2246 generation: self.configuration.generation,
2247 issued_at,
2248 expires_at,
2249 };
2250 discovery.validate()?;
2251 Ok(discovery)
2252 }
2253
2254 pub fn discovery_v2(
2255 &self,
2256 issued_at: u64,
2257 expires_at: u64,
2258 ) -> Result<Issue31HostDiscoveryV2, Issue31NostrError> {
2259 let discovery = Issue31HostDiscoveryV2 {
2260 schema: ISSUE31_HOST_DISCOVERY_SCHEMA_V2.into(),
2261 host_ref: self.configuration.host_ref.clone(),
2262 host_public_key_hex: self.configuration.host_public_key_hex.clone(),
2263 sarah_public_key_hex: self.configuration.sarah_public_key_hex.clone(),
2264 conversation: self.configuration.conversation.clone(),
2265 display_name: self.configuration.display_name.clone(),
2266 protocols: vec![
2267 ISSUE31_PAIRING_SCHEMA.into(),
2268 ISSUE31_COMMAND_SCHEMA.into(),
2269 ISSUE31_COMMAND_SCHEMA_V2.into(),
2270 ],
2271 relay_urls: self.configuration.relay_urls.clone(),
2272 generation: self.configuration.generation,
2273 issued_at,
2274 expires_at,
2275 };
2276 discovery.validate()?;
2277 Ok(discovery)
2278 }
2279
2280 pub fn matches_configuration(&self, configuration: &Issue31HostConfiguration) -> bool {
2281 &self.configuration == configuration
2282 }
2283
2284 pub fn adopt_conversation_if_missing(
2285 &mut self,
2286 conversation: &str,
2287 ) -> Result<(), Issue31NostrError> {
2288 if self.configuration.conversation.is_empty() {
2289 if !valid_conversation_tag(conversation) {
2290 return Err(Issue31NostrError::Invalid(
2291 "invalid Issue 31 migration conversation".into(),
2292 ));
2293 }
2294 self.configuration.conversation = conversation.into();
2295 }
2296 Ok(())
2297 }
2298
2299 pub fn set_admitted_device_policy(
2300 &mut self,
2301 public_keys: Vec<String>,
2302 approved_scopes: Vec<Issue31PairingScope>,
2303 ) -> Result<(), Issue31NostrError> {
2304 if public_keys.is_empty()
2305 || public_keys.len() > 32
2306 || public_keys
2307 .iter()
2308 .any(|public_key| !valid_hex64(public_key))
2309 {
2310 return Err(Issue31NostrError::Invalid(
2311 "Issue 31 device allowlist must contain one to 32 lowercase public keys".into(),
2312 ));
2313 }
2314 let public_key_count = public_keys.len();
2315 let admitted = public_keys.into_iter().collect::<BTreeSet<_>>();
2316 if admitted.len() != public_key_count {
2317 return Err(Issue31NostrError::Invalid(
2318 "Issue 31 device allowlist contains duplicates".into(),
2319 ));
2320 }
2321 validate_scopes(&approved_scopes, "owner-approved scopes")?;
2322 let approved_scopes = approved_scopes.into_iter().collect::<BTreeSet<_>>();
2323 self.admitted_device_scopes = admitted
2324 .into_iter()
2325 .map(|public_key| (public_key, approved_scopes.clone()))
2326 .collect();
2327 Ok(())
2328 }
2329
2330 pub fn admitted_device_fingerprints(&self) -> Vec<String> {
2331 self.admitted_device_scopes
2332 .keys()
2333 .map(|public_key| {
2334 let digest = format!("{:x}", Sha256::digest(public_key.as_bytes()));
2335 digest[..16].to_ascii_uppercase()
2336 })
2337 .collect()
2338 }
2339
2340 pub fn grant_projections(
2341 &self,
2342 now: u64,
2343 ) -> Result<Vec<Issue31GrantProjection>, Issue31NostrError> {
2344 let grant_refs = self
2345 .pairing_events
2346 .iter()
2347 .filter_map(|event| match &event.record {
2348 Issue31PairingRecord::ScopedGrant { grant_ref, .. }
2349 | Issue31PairingRecord::GrantRenewal { grant_ref, .. }
2350 | Issue31PairingRecord::GrantRevocation { grant_ref, .. } => {
2351 Some(grant_ref.clone())
2352 }
2353 _ => None,
2354 })
2355 .collect::<BTreeSet<_>>();
2356 grant_refs
2357 .into_iter()
2358 .map(|grant_ref| {
2359 let state =
2360 fold_issue31_grant(&self.pairing_events, &grant_ref)?.ok_or_else(|| {
2361 Issue31NostrError::Invalid(
2362 "durable grant projection lost its lifecycle state".into(),
2363 )
2364 })?;
2365 let device_digest = format!(
2366 "{:x}",
2367 Sha256::digest(state.device_public_key_hex.as_bytes())
2368 );
2369 let status = match state.status {
2370 Issue31GrantStatus::Revoked => "revoked",
2371 Issue31GrantStatus::Active
2372 if state.expires_at.is_some_and(|expires_at| now >= expires_at) =>
2373 {
2374 "expired"
2375 }
2376 Issue31GrantStatus::Active => "active",
2377 };
2378 Ok(Issue31GrantProjection {
2379 grant_ref: state.grant_ref,
2380 device_fingerprint: device_digest[..16].to_ascii_uppercase(),
2381 generation: state.generation,
2382 status: status.into(),
2383 scopes: state.scopes,
2384 expires_at: state.expires_at,
2385 source_event_id: state.source_event_id,
2386 })
2387 })
2388 .collect()
2389 }
2390
2391 pub fn pairing_event_was_processed(&self, event_id: &str) -> bool {
2392 self.processed_pairing_event_ids.contains(event_id)
2393 }
2394
2395 /// Every revocation in the durable pairing log that names this device and
2396 /// that the owner has not cleared by re-admitting it.
2397 ///
2398 /// The grant fold is keyed by `grant_ref`, so it can only ever say that one
2399 /// grant died. Revocation is a statement about the *device*: without this
2400 /// scan a revoked device simply pairs again under a fresh `grant_ref` and
2401 /// restores the exact authority the owner just took away. The scan looks at
2402 /// every pairing event, not just the grant being folded.
2403 pub fn outstanding_device_revocations(&self, device_public_key_hex: &str) -> Vec<String> {
2404 self.pairing_events
2405 .iter()
2406 .filter(|event| {
2407 matches!(&event.record, Issue31PairingRecord::GrantRevocation { .. })
2408 && record_device_public_key(&event.record) == device_public_key_hex
2409 && !self
2410 .cleared_device_revocation_event_ids
2411 .contains(&event.event_id)
2412 })
2413 .map(|event| event.event_id.clone())
2414 .collect()
2415 }
2416
2417 pub fn device_admission_is_revoked(&self, device_public_key_hex: &str) -> bool {
2418 !self
2419 .outstanding_device_revocations(device_public_key_hex)
2420 .is_empty()
2421 }
2422
2423 /// Owner-side re-admission: clear the revocations that currently block this
2424 /// device so it may pair again.
2425 ///
2426 /// Only the revocations that exist *now* are cleared, by event id. A later
2427 /// revocation is a new event id and is therefore not cleared, so it fails
2428 /// closed; and replaying an already-cleared revocation cannot re-block the
2429 /// device, because clearance is keyed to the event rather than to a counter.
2430 pub fn readmit_device(
2431 &mut self,
2432 device_public_key_hex: &str,
2433 ) -> Result<Vec<String>, Issue31NostrError> {
2434 if !valid_hex64(device_public_key_hex) {
2435 return Err(Issue31NostrError::Invalid(
2436 "Issue 31 device public key is invalid".into(),
2437 ));
2438 }
2439 let outstanding = self.outstanding_device_revocations(device_public_key_hex);
2440 if self.cleared_device_revocation_event_ids.len() + outstanding.len()
2441 > MAX_ISSUE31_PROCESSED_EVENTS
2442 {
2443 return Err(Issue31NostrError::Invalid(
2444 "Issue 31 cleared revocation bound is exhausted".into(),
2445 ));
2446 }
2447 for event_id in &outstanding {
2448 self.cleared_device_revocation_event_ids
2449 .insert(event_id.clone());
2450 }
2451 Ok(outstanding)
2452 }
2453
2454 /// Re-admit the device behind a grant without ever naming its public key.
2455 ///
2456 /// The owner-facing grant projection deliberately shows only a fingerprint,
2457 /// so the owner acts on a `grant_ref` and the host resolves the device.
2458 pub fn readmit_device_for_grant(
2459 &mut self,
2460 grant_ref: &str,
2461 ) -> Result<Vec<String>, Issue31NostrError> {
2462 let grant = fold_issue31_grant(&self.pairing_events, grant_ref)?.ok_or_else(|| {
2463 Issue31NostrError::Invalid("cannot re-admit the device of an unknown grant".into())
2464 })?;
2465 if grant.status != Issue31GrantStatus::Revoked {
2466 return Err(Issue31NostrError::Invalid(
2467 "re-admission applies only to a revoked grant".into(),
2468 ));
2469 }
2470 let device_public_key_hex = grant.device_public_key_hex;
2471 self.readmit_device(&device_public_key_hex)
2472 }
2473
2474 pub fn validate_persisted_state(&self) -> Result<(), Issue31NostrError> {
2475 Self::new(self.configuration.clone())?;
2476 if self.pairing_events.len() > MAX_ISSUE31_PAIRING_EVENTS
2477 || self.processed_pairing_event_ids.len() > MAX_ISSUE31_PROCESSED_EVENTS
2478 || self.processed_command_event_ids.len() > MAX_ISSUE31_PROCESSED_EVENTS
2479 || self.command_results.len() > MAX_ISSUE31_COMMAND_RESULTS
2480 || self.command_results_v2.len() > MAX_ISSUE31_COMMAND_RESULTS
2481 || self.cleared_device_revocation_event_ids.len() > MAX_ISSUE31_PROCESSED_EVENTS
2482 || self
2483 .projected_source_event_ids
2484 .values()
2485 .map(BTreeSet::len)
2486 .sum::<usize>()
2487 > MAX_ISSUE31_PROJECTED_SOURCE_EVENTS
2488 {
2489 return Err(Issue31NostrError::Invalid(
2490 "persisted Issue 31 host state exceeds its bounds".into(),
2491 ));
2492 }
2493 for event in &self.pairing_events {
2494 if !valid_hex64(&event.event_id) {
2495 return Err(Issue31NostrError::Invalid(
2496 "persisted pairing event id is invalid".into(),
2497 ));
2498 }
2499 event.record.validate()?;
2500 ensure_pairing_targets_host(&event.record, &self.configuration)?;
2501 }
2502 if self
2503 .processed_pairing_event_ids
2504 .iter()
2505 .chain(&self.processed_command_event_ids)
2506 .chain(&self.cleared_device_revocation_event_ids)
2507 .any(|event_id| !valid_hex64(event_id))
2508 {
2509 return Err(Issue31NostrError::Invalid(
2510 "persisted processed event id is invalid".into(),
2511 ));
2512 }
2513 // A cleared revocation must name a revocation that actually exists in the
2514 // durable log. Otherwise persisted state could pre-clear a revocation the
2515 // owner has not yet issued and silently unblock a device on arrival.
2516 for event_id in &self.cleared_device_revocation_event_ids {
2517 if !self.pairing_events.iter().any(|event| {
2518 event.event_id == *event_id
2519 && matches!(&event.record, Issue31PairingRecord::GrantRevocation { .. })
2520 }) {
2521 return Err(Issue31NostrError::Invalid(
2522 "persisted device re-admission clears an unknown revocation".into(),
2523 ));
2524 }
2525 }
2526 for (idempotency_ref, (intent, result)) in &self.command_results {
2527 validate_ref_value(idempotency_ref, "persisted idempotency")?;
2528 intent.validate()?;
2529 result.validate()?;
2530 let Issue31CommandRecord::CommandIntent {
2531 idempotency_ref: intent_idempotency_ref,
2532 issued_at,
2533 ..
2534 } = intent
2535 else {
2536 return Err(Issue31NostrError::Invalid(
2537 "persisted command result omitted its intent".into(),
2538 ));
2539 };
2540 let Issue31CommandRecord::CommandResult {
2541 intent_event_id,
2542 idempotency_ref: result_idempotency_ref,
2543 completed_at,
2544 ..
2545 } = result
2546 else {
2547 return Err(Issue31NostrError::Invalid(
2548 "persisted command result stored a second intent".into(),
2549 ));
2550 };
2551 if intent.binding() != result.binding()
2552 || intent_idempotency_ref != idempotency_ref
2553 || result_idempotency_ref != idempotency_ref
2554 || completed_at < issued_at
2555 || !self.processed_command_event_ids.contains(intent_event_id)
2556 {
2557 return Err(Issue31NostrError::Invalid(
2558 "persisted command result changes its intent binding".into(),
2559 ));
2560 }
2561 }
2562 for (idempotency_ref, (intent, result)) in &self.command_results_v2 {
2563 validate_ref_value(idempotency_ref, "persisted idempotency")?;
2564 intent.validate()?;
2565 result.validate()?;
2566 let Issue31CommandRecordV2::CommandIntent {
2567 idempotency_ref: intent_idempotency_ref,
2568 issued_at,
2569 arguments,
2570 ..
2571 } = intent
2572 else {
2573 return Err(Issue31NostrError::Invalid(
2574 "persisted command v2 result omitted its intent".into(),
2575 ));
2576 };
2577 let Issue31CommandRecordV2::CommandResult {
2578 intent_event_id,
2579 action_ref,
2580 idempotency_ref: result_idempotency_ref,
2581 handled_at,
2582 ..
2583 } = result
2584 else {
2585 return Err(Issue31NostrError::Invalid(
2586 "persisted command v2 result stored a second intent".into(),
2587 ));
2588 };
2589 if intent.binding() != result.binding()
2590 || action_ref != arguments.action_ref()
2591 || intent_idempotency_ref != idempotency_ref
2592 || result_idempotency_ref != idempotency_ref
2593 || handled_at < issued_at
2594 || !self.processed_command_event_ids.contains(intent_event_id)
2595 {
2596 return Err(Issue31NostrError::Invalid(
2597 "persisted command v2 result changes its intent binding".into(),
2598 ));
2599 }
2600 }
2601 for (grant_ref, source_event_ids) in &self.projected_source_event_ids {
2602 validate_ref_value(grant_ref, "projected grant")?;
2603 if source_event_ids
2604 .iter()
2605 .any(|event_id| !valid_hex64(event_id))
2606 {
2607 return Err(Issue31NostrError::Invalid(
2608 "persisted projected source event id is invalid".into(),
2609 ));
2610 }
2611 }
2612 Ok(())
2613 }
2614
2615 pub fn record_emitted_pairing(
2616 &mut self,
2617 event_id: String,
2618 record: Issue31PairingRecord,
2619 ) -> Result<(), Issue31NostrError> {
2620 ensure_pairing_targets_host(&record, &self.configuration)?;
2621 record.validate_private_binding(
2622 &self.configuration.host_public_key_hex,
2623 record_device_public_key(&record),
2624 )?;
2625 self.insert_pairing_event(Issue31PairingEvent { event_id, record })
2626 }
2627
2628 pub fn handle_pairing_event(
2629 &mut self,
2630 event: Issue31PairingEvent,
2631 now: u64,
2632 ) -> Result<Option<Issue31PairingRecord>, Issue31NostrError> {
2633 if self.processed_pairing_event_ids.contains(&event.event_id) {
2634 return Ok(None);
2635 }
2636 event.record.validate()?;
2637 ensure_pairing_targets_host(&event.record, &self.configuration)?;
2638 if matches!(
2639 &event.record,
2640 Issue31PairingRecord::PairingRequest {
2641 device_public_key_hex,
2642 requested_scopes,
2643 ..
2644 } if self
2645 .admitted_device_scopes
2646 .get(device_public_key_hex)
2647 .is_none_or(|approved| requested_scopes.iter().all(|scope| !approved.contains(scope)))
2648 ) {
2649 return Ok(None);
2650 }
2651 if matches!(
2652 &event.record,
2653 Issue31PairingRecord::PairingRequest { device_public_key_hex, .. }
2654 | Issue31PairingRecord::PairingResponse { device_public_key_hex, .. }
2655 if self.device_admission_is_revoked(device_public_key_hex)
2656 ) {
2657 return Err(Issue31NostrError::Invalid(
2658 "device admission was revoked; owner re-admission is required".into(),
2659 ));
2660 }
2661 let outbound = match &event.record {
2662 Issue31PairingRecord::PairingRequest {
2663 device_public_key_hex,
2664 issued_at,
2665 expires_at,
2666 ..
2667 } => {
2668 require_live_record(*issued_at, *expires_at, now, "pairing request")?;
2669 let challenge_seed = rand::random::<[u8; 32]>();
2670 let challenge = format!("{:x}", Sha256::digest(challenge_seed));
2671 let event_digest = digest_ref_suffix(event.event_id.as_bytes());
2672 Some(Issue31PairingRecord::PairingChallenge {
2673 schema: ISSUE31_PAIRING_SCHEMA.into(),
2674 host_ref: self.configuration.host_ref.clone(),
2675 host_public_key_hex: self.configuration.host_public_key_hex.clone(),
2676 device_public_key_hex: device_public_key_hex.clone(),
2677 issued_at: now,
2678 pairing_challenge_ref: format!("pairing_challenge.omega.{event_digest}"),
2679 pairing_request_event_id: event.event_id.clone(),
2680 challenge,
2681 expires_at: (*expires_at).min(now.saturating_add(600)),
2682 })
2683 }
2684 Issue31PairingRecord::PairingResponse {
2685 device_public_key_hex,
2686 issued_at,
2687 pairing_challenge_event_id,
2688 challenge: response_challenge,
2689 expires_at,
2690 ..
2691 } => {
2692 require_live_record(*issued_at, *expires_at, now, "pairing response")?;
2693 let challenge = self
2694 .pairing_events
2695 .iter()
2696 .find(|candidate| candidate.event_id == *pairing_challenge_event_id);
2697 let Some(Issue31PairingEvent {
2698 record:
2699 Issue31PairingRecord::PairingChallenge {
2700 pairing_request_event_id,
2701 challenge,
2702 expires_at: challenge_expires_at,
2703 ..
2704 },
2705 ..
2706 }) = challenge
2707 else {
2708 return Err(Issue31NostrError::Invalid(
2709 "pairing response references an unknown challenge".into(),
2710 ));
2711 };
2712 if response_challenge != challenge || now >= *challenge_expires_at {
2713 return Err(Issue31NostrError::Invalid(
2714 "pairing response challenge is wrong or expired".into(),
2715 ));
2716 }
2717 let request = self
2718 .pairing_events
2719 .iter()
2720 .find(|candidate| candidate.event_id == *pairing_request_event_id);
2721 let Some(Issue31PairingEvent {
2722 record:
2723 Issue31PairingRecord::PairingRequest {
2724 requested_scopes,
2725 device_public_key_hex: request_device_key,
2726 ..
2727 },
2728 ..
2729 }) = request
2730 else {
2731 return Err(Issue31NostrError::Invalid(
2732 "pairing challenge references an unknown request".into(),
2733 ));
2734 };
2735 if request_device_key != device_public_key_hex {
2736 return Err(Issue31NostrError::Invalid(
2737 "pairing response changes device identity".into(),
2738 ));
2739 }
2740 let approved_scopes = self
2741 .admitted_device_scopes
2742 .get(device_public_key_hex)
2743 .ok_or_else(|| {
2744 Issue31NostrError::Invalid("device no longer has owner admission".into())
2745 })?;
2746 let scopes = requested_scopes
2747 .iter()
2748 .copied()
2749 .filter(|scope| approved_scopes.contains(scope))
2750 .collect::<Vec<_>>();
2751 validate_scopes(&scopes, "owner-approved grant scopes")?;
2752 let event_digest = digest_ref_suffix(event.event_id.as_bytes());
2753 Some(Issue31PairingRecord::ScopedGrant {
2754 schema: ISSUE31_PAIRING_SCHEMA.into(),
2755 host_ref: self.configuration.host_ref.clone(),
2756 host_public_key_hex: self.configuration.host_public_key_hex.clone(),
2757 sarah_public_key_hex: self.configuration.sarah_public_key_hex.clone(),
2758 device_public_key_hex: device_public_key_hex.clone(),
2759 issued_at: now,
2760 pairing_response_event_id: event.event_id.clone(),
2761 grant_ref: format!("grant.omega.{event_digest}"),
2762 generation: 1,
2763 scopes,
2764 expires_at: now.saturating_add(24 * 60 * 60),
2765 })
2766 }
2767 Issue31PairingRecord::PairingChallenge { .. }
2768 | Issue31PairingRecord::ScopedGrant { .. }
2769 | Issue31PairingRecord::GrantRenewal { .. }
2770 | Issue31PairingRecord::GrantRevocation { .. } => None,
2771 };
2772 self.insert_pairing_event(event)?;
2773 Ok(outbound)
2774 }
2775
2776 pub fn renew_grant(
2777 &self,
2778 grant_ref: &str,
2779 scopes: Vec<Issue31PairingScope>,
2780 now: u64,
2781 expires_at: u64,
2782 ) -> Result<Issue31PairingRecord, Issue31NostrError> {
2783 validate_scopes(&scopes, "renewal scopes")?;
2784 validate_lifetime(now, expires_at)?;
2785 let prior = fold_issue31_grant(&self.pairing_events, grant_ref)?
2786 .ok_or_else(|| Issue31NostrError::Invalid("cannot renew an unknown grant".into()))?;
2787 if prior.status != Issue31GrantStatus::Active
2788 || scopes.iter().any(|scope| !prior.scopes.contains(scope))
2789 {
2790 return Err(Issue31NostrError::Invalid(
2791 "renewal requires an active grant and cannot widen scope".into(),
2792 ));
2793 }
2794 Ok(Issue31PairingRecord::GrantRenewal {
2795 schema: ISSUE31_PAIRING_SCHEMA.into(),
2796 host_ref: prior.host_ref,
2797 host_public_key_hex: prior.host_public_key_hex,
2798 sarah_public_key_hex: prior.sarah_public_key_hex,
2799 device_public_key_hex: prior.device_public_key_hex,
2800 issued_at: now,
2801 grant_ref: grant_ref.to_string(),
2802 previous_grant_event_id: prior.source_event_id,
2803 prior_generation: prior.generation,
2804 generation: prior.generation.saturating_add(1),
2805 scopes,
2806 expires_at,
2807 })
2808 }
2809
2810 pub fn revoke_grant(
2811 &self,
2812 grant_ref: &str,
2813 now: u64,
2814 reason_ref: Option<String>,
2815 ) -> Result<Issue31PairingRecord, Issue31NostrError> {
2816 let prior = fold_issue31_grant(&self.pairing_events, grant_ref)?
2817 .ok_or_else(|| Issue31NostrError::Invalid("cannot revoke an unknown grant".into()))?;
2818 if prior.status == Issue31GrantStatus::Revoked {
2819 return Err(Issue31NostrError::Invalid(
2820 "grant is already revoked".into(),
2821 ));
2822 }
2823 let record = Issue31PairingRecord::GrantRevocation {
2824 schema: ISSUE31_PAIRING_SCHEMA.into(),
2825 host_ref: prior.host_ref,
2826 host_public_key_hex: prior.host_public_key_hex,
2827 sarah_public_key_hex: prior.sarah_public_key_hex,
2828 device_public_key_hex: prior.device_public_key_hex,
2829 issued_at: now,
2830 grant_ref: grant_ref.to_string(),
2831 generation: prior.generation,
2832 reason_ref,
2833 };
2834 record.validate()?;
2835 Ok(record)
2836 }
2837
2838 pub fn handle_command_event<F>(
2839 &mut self,
2840 event: Issue31CommandEvent,
2841 now: u64,
2842 execute: F,
2843 ) -> Result<Option<Issue31CommandRecord>, Issue31NostrError>
2844 where
2845 F: FnOnce(&str, &str, &str) -> Issue31CommandExecution,
2846 {
2847 if !valid_hex64(&event.event_id) {
2848 return Err(Issue31NostrError::Invalid(
2849 "Issue 31 command event id is invalid".into(),
2850 ));
2851 }
2852 if self.processed_command_event_ids.contains(&event.event_id) {
2853 return Ok(None);
2854 }
2855 event.record.validate()?;
2856 if self.processed_command_event_ids.len() >= MAX_ISSUE31_PROCESSED_EVENTS {
2857 return Err(Issue31NostrError::Invalid(
2858 "Issue 31 processed command event bound is exhausted".into(),
2859 ));
2860 }
2861 let Issue31CommandRecord::CommandIntent {
2862 host_ref,
2863 host_public_key_hex,
2864 device_public_key_hex,
2865 grant_ref,
2866 action_ref,
2867 idempotency_ref,
2868 expected_generation,
2869 arguments_ref,
2870 issued_at,
2871 expires_at,
2872 ..
2873 } = &event.record
2874 else {
2875 self.processed_command_event_ids.insert(event.event_id);
2876 return Ok(None);
2877 };
2878 if let Some((prior_intent, prior_result)) = self.command_results.get(idempotency_ref) {
2879 if prior_intent == &event.record {
2880 self.processed_command_event_ids.insert(event.event_id);
2881 return Ok(Some(prior_result.clone()));
2882 }
2883 return Err(Issue31NostrError::Invalid(format!(
2884 "idempotency ref {idempotency_ref} conflicts with an earlier command"
2885 )));
2886 }
2887 if self.command_results.len() >= MAX_ISSUE31_COMMAND_RESULTS {
2888 return Err(Issue31NostrError::Invalid(
2889 "Issue 31 command result bound is exhausted".into(),
2890 ));
2891 }
2892
2893 let refusal = |reason: &str| Issue31CommandExecution {
2894 status: Issue31CommandStatus::Refused,
2895 outcome_ref: "outcome.omega.refused".into(),
2896 reason_ref: Some(reason.into()),
2897 };
2898 let execution = if host_ref != &self.configuration.host_ref
2899 || host_public_key_hex != &self.configuration.host_public_key_hex
2900 {
2901 refusal("reason.omega.host_binding_mismatch")
2902 } else if require_live_record(*issued_at, *expires_at, now, "command intent").is_err() {
2903 refusal("reason.omega.command_expired")
2904 } else {
2905 let grant = fold_issue31_grant(&self.pairing_events, grant_ref)?;
2906 match grant {
2907 Some(grant)
2908 if grant.status == Issue31GrantStatus::Active
2909 && grant.device_public_key_hex == *device_public_key_hex
2910 && grant.generation == *expected_generation
2911 && grant.expires_at.is_some_and(|expires_at| now < expires_at) =>
2912 {
2913 match required_scope(action_ref) {
2914 Some(scope) if grant.scopes.contains(&scope) => {
2915 // The idempotency reference travels with the action
2916 // so the executor can make one command map to one
2917 // host record however many times it is replayed
2918 // (omega#91).
2919 execute(action_ref, arguments_ref, idempotency_ref)
2920 }
2921 Some(_) => refusal("reason.omega.scope_denied"),
2922 None => Issue31CommandExecution {
2923 status: Issue31CommandStatus::Unavailable,
2924 outcome_ref: "outcome.omega.unavailable".into(),
2925 reason_ref: Some("reason.omega.action_unsupported".into()),
2926 },
2927 }
2928 }
2929 _ => refusal("reason.omega.grant_invalid"),
2930 }
2931 };
2932 let result = Issue31CommandRecord::CommandResult {
2933 schema: ISSUE31_COMMAND_SCHEMA.into(),
2934 host_ref: host_ref.clone(),
2935 host_public_key_hex: host_public_key_hex.clone(),
2936 device_public_key_hex: device_public_key_hex.clone(),
2937 grant_ref: grant_ref.clone(),
2938 intent_event_id: event.event_id.clone(),
2939 action_ref: action_ref.clone(),
2940 idempotency_ref: idempotency_ref.clone(),
2941 expected_generation: *expected_generation,
2942 status: execution.status,
2943 outcome_ref: execution.outcome_ref,
2944 reason_ref: execution.reason_ref,
2945 completed_at: now,
2946 };
2947 result.validate()?;
2948 self.processed_command_event_ids.insert(event.event_id);
2949 self.command_results
2950 .insert(idempotency_ref.clone(), (event.record, result.clone()));
2951 Ok(Some(result))
2952 }
2953
2954 pub fn handle_command_event_v2<F>(
2955 &mut self,
2956 event_id: String,
2957 record: Issue31CommandRecordV2,
2958 now: u64,
2959 execute: F,
2960 ) -> Result<Option<Issue31CommandRecordV2>, Issue31NostrError>
2961 where
2962 F: FnOnce(&Issue31CommandArguments, &str, &str, &str, u64) -> Issue31CommandExecutionV2,
2963 {
2964 if !valid_hex64(&event_id) {
2965 return Err(Issue31NostrError::Invalid(
2966 "Issue 31 command v2 event id is invalid".into(),
2967 ));
2968 }
2969 if self.processed_command_event_ids.contains(&event_id) {
2970 return Ok(None);
2971 }
2972 record.validate()?;
2973 if self.processed_command_event_ids.len() >= MAX_ISSUE31_PROCESSED_EVENTS {
2974 return Err(Issue31NostrError::Invalid(
2975 "Issue 31 processed command event bound is exhausted".into(),
2976 ));
2977 }
2978 let Issue31CommandRecordV2::CommandIntent {
2979 host_ref,
2980 host_public_key_hex,
2981 device_public_key_hex,
2982 grant_ref,
2983 idempotency_ref,
2984 expected_generation,
2985 arguments,
2986 issued_at,
2987 expires_at,
2988 ..
2989 } = &record
2990 else {
2991 self.processed_command_event_ids.insert(event_id);
2992 return Ok(None);
2993 };
2994 if let Some((prior_intent, prior_result)) = self.command_results_v2.get(idempotency_ref) {
2995 if prior_intent == &record {
2996 self.processed_command_event_ids.insert(event_id);
2997 return Ok(Some(prior_result.clone()));
2998 }
2999 return Err(Issue31NostrError::Invalid(format!(
3000 "idempotency ref {idempotency_ref} conflicts with an earlier command v2"
3001 )));
3002 }
3003 if self.command_results_v2.len() >= MAX_ISSUE31_COMMAND_RESULTS {
3004 return Err(Issue31NostrError::Invalid(
3005 "Issue 31 command v2 result bound is exhausted".into(),
3006 ));
3007 }
3008
3009 let refusal = |reason: &str| Issue31CommandExecutionV2 {
3010 status: Issue31CommandHandlingStatus::Refused,
3011 handling_ref: "handling.omega.refused".into(),
3012 reason_ref: Some(reason.into()),
3013 source_event_id: None,
3014 };
3015 let execution = if host_ref != &self.configuration.host_ref
3016 || host_public_key_hex != &self.configuration.host_public_key_hex
3017 {
3018 refusal("reason.omega.host_binding_mismatch")
3019 } else if require_live_record(*issued_at, *expires_at, now, "command v2 intent").is_err() {
3020 refusal("reason.omega.command_expired")
3021 } else {
3022 let grant = fold_issue31_grant(&self.pairing_events, grant_ref)?;
3023 match grant {
3024 Some(grant)
3025 if grant.status == Issue31GrantStatus::Active
3026 && grant.device_public_key_hex == *device_public_key_hex
3027 && grant.generation == *expected_generation
3028 && grant.expires_at.is_some_and(|expires_at| now < expires_at) =>
3029 {
3030 let required_scope = required_scope(arguments.action_ref());
3031 match required_scope {
3032 Some(scope) if grant.scopes.contains(&scope) => execute(
3033 arguments,
3034 idempotency_ref,
3035 grant_ref,
3036 device_public_key_hex,
3037 *expected_generation,
3038 ),
3039 Some(_) => refusal("reason.omega.scope_denied"),
3040 None => Issue31CommandExecutionV2 {
3041 status: Issue31CommandHandlingStatus::Unavailable,
3042 handling_ref: "handling.omega.unavailable".into(),
3043 reason_ref: Some("reason.omega.action_unsupported".into()),
3044 source_event_id: None,
3045 },
3046 }
3047 }
3048 _ => refusal("reason.omega.grant_invalid"),
3049 }
3050 };
3051 let result = Issue31CommandRecordV2::CommandResult {
3052 schema: ISSUE31_COMMAND_SCHEMA_V2.into(),
3053 host_ref: host_ref.clone(),
3054 host_public_key_hex: host_public_key_hex.clone(),
3055 device_public_key_hex: device_public_key_hex.clone(),
3056 grant_ref: grant_ref.clone(),
3057 intent_event_id: event_id.clone(),
3058 action_ref: arguments.action_ref().into(),
3059 idempotency_ref: idempotency_ref.clone(),
3060 expected_generation: *expected_generation,
3061 status: execution.status,
3062 handling_ref: execution.handling_ref,
3063 reason_ref: execution.reason_ref,
3064 source_event_id: execution.source_event_id,
3065 handled_at: now,
3066 };
3067 result.validate()?;
3068 self.processed_command_event_ids.insert(event_id);
3069 self.command_results_v2
3070 .insert(idempotency_ref.clone(), (record, result.clone()));
3071 Ok(Some(result))
3072 }
3073
3074 pub fn active_grants(&self, now: u64) -> Result<Vec<Issue31GrantState>, Issue31NostrError> {
3075 let grant_refs = self
3076 .pairing_events
3077 .iter()
3078 .filter_map(|event| {
3079 event
3080 .record
3081 .lifecycle_binding()
3082 .map(|binding| binding.4.to_string())
3083 })
3084 .collect::<BTreeSet<_>>();
3085 let mut grants = Vec::new();
3086 for grant_ref in grant_refs {
3087 let Some(grant) = fold_issue31_grant(&self.pairing_events, &grant_ref)? else {
3088 continue;
3089 };
3090 if grant.status == Issue31GrantStatus::Active
3091 && grant.expires_at.is_some_and(|expires_at| now < expires_at)
3092 && grant.scopes.contains(&Issue31PairingScope::ObserveIssue31)
3093 {
3094 grants.push(grant);
3095 }
3096 }
3097 Ok(grants)
3098 }
3099
3100 pub fn source_was_projected(
3101 &self,
3102 grant_ref: &str,
3103 generation: u64,
3104 source_event_id: &str,
3105 ) -> bool {
3106 let projection_ref = format!("{grant_ref}:{generation}");
3107 self.projected_source_event_ids
3108 .get(&projection_ref)
3109 .is_some_and(|event_ids| event_ids.contains(source_event_id))
3110 }
3111
3112 pub fn record_source_projection(
3113 &mut self,
3114 grant_ref: String,
3115 generation: u64,
3116 source_event_id: String,
3117 ) -> Result<(), Issue31NostrError> {
3118 validate_ref_value(&grant_ref, "projected grant")?;
3119 validate_generation(generation)?;
3120 validate_hex_value(&source_event_id, "projected source event")?;
3121 let projection_ref = format!("{grant_ref}:{generation}");
3122 let projected_count = self
3123 .projected_source_event_ids
3124 .values()
3125 .map(BTreeSet::len)
3126 .sum::<usize>();
3127 if projected_count >= MAX_ISSUE31_PROJECTED_SOURCE_EVENTS
3128 && !self.source_was_projected(&grant_ref, generation, &source_event_id)
3129 {
3130 return Err(Issue31NostrError::Invalid(
3131 "Issue 31 projected source event bound is exhausted".into(),
3132 ));
3133 }
3134 self.projected_source_event_ids
3135 .entry(projection_ref)
3136 .or_default()
3137 .insert(source_event_id);
3138 Ok(())
3139 }
3140
3141 fn insert_pairing_event(
3142 &mut self,
3143 event: Issue31PairingEvent,
3144 ) -> Result<(), Issue31NostrError> {
3145 if !valid_hex64(&event.event_id) {
3146 return Err(Issue31NostrError::Invalid(
3147 "Issue 31 pairing event id is invalid".into(),
3148 ));
3149 }
3150 if let Some(prior) = self
3151 .pairing_events
3152 .iter()
3153 .find(|prior| prior.event_id == event.event_id)
3154 {
3155 if prior.record != event.record {
3156 return Err(Issue31NostrError::Invalid(format!(
3157 "Issue 31 event {} has conflicting records",
3158 event.event_id
3159 )));
3160 }
3161 return Ok(());
3162 }
3163 if self.pairing_events.len() >= MAX_ISSUE31_PAIRING_EVENTS
3164 || self.processed_pairing_event_ids.len() >= MAX_ISSUE31_PROCESSED_EVENTS
3165 {
3166 return Err(Issue31NostrError::Invalid(
3167 "Issue 31 pairing event bound is exhausted".into(),
3168 ));
3169 }
3170 self.processed_pairing_event_ids
3171 .insert(event.event_id.clone());
3172 self.pairing_events.push(event);
3173 Ok(())
3174 }
3175}
3176
3177fn ensure_pairing_targets_host(
3178 record: &Issue31PairingRecord,
3179 configuration: &Issue31HostConfiguration,
3180) -> Result<(), Issue31NostrError> {
3181 let (_, host_ref, host_public_key_hex, _, _) = record.base();
3182 if host_ref != configuration.host_ref
3183 || host_public_key_hex != configuration.host_public_key_hex
3184 {
3185 return Err(Issue31NostrError::Invalid(
3186 "pairing record targets another host".into(),
3187 ));
3188 }
3189 let sarah_public_key_hex = match record {
3190 Issue31PairingRecord::ScopedGrant {
3191 sarah_public_key_hex,
3192 ..
3193 }
3194 | Issue31PairingRecord::GrantRenewal {
3195 sarah_public_key_hex,
3196 ..
3197 }
3198 | Issue31PairingRecord::GrantRevocation {
3199 sarah_public_key_hex,
3200 ..
3201 } => Some(sarah_public_key_hex),
3202 Issue31PairingRecord::PairingRequest { .. }
3203 | Issue31PairingRecord::PairingChallenge { .. }
3204 | Issue31PairingRecord::PairingResponse { .. } => None,
3205 };
3206 if sarah_public_key_hex.is_some_and(|key| key != &configuration.sarah_public_key_hex) {
3207 return Err(Issue31NostrError::Invalid(
3208 "pairing grant binds another Sarah identity".into(),
3209 ));
3210 }
3211 Ok(())
3212}
3213
3214fn record_device_public_key(record: &Issue31PairingRecord) -> &str {
3215 let (_, _, _, device_public_key_hex, _) = record.base();
3216 device_public_key_hex
3217}
3218
3219fn require_live_record(
3220 issued_at: u64,
3221 expires_at: u64,
3222 now: u64,
3223 label: &str,
3224) -> Result<(), Issue31NostrError> {
3225 validate_lifetime(issued_at, expires_at)?;
3226 if issued_at > now.saturating_add(300) || now >= expires_at {
3227 return Err(Issue31NostrError::Invalid(format!(
3228 "{label} is future-dated or expired"
3229 )));
3230 }
3231 Ok(())
3232}
3233
3234fn required_scope(action_ref: &str) -> Option<Issue31PairingScope> {
3235 match action_ref {
3236 "action.omega.send_message" | ISSUE31_ACTION_SEND_MESSAGE => {
3237 Some(Issue31PairingScope::SendMessage)
3238 }
3239 "action.omega.interrupt_turn" | ISSUE31_ACTION_INTERRUPT_TURN => {
3240 Some(Issue31PairingScope::InterruptTurn)
3241 }
3242 ISSUE31_ACTION_ADVANCE_READ_STATE
3243 | ISSUE31_ACTION_CREATE_REMINDER
3244 | ISSUE31_ACTION_CHANGE_REMINDER
3245 | ISSUE31_ACTION_COMPLETE_REMINDER
3246 | ISSUE31_ACTION_CANCEL_REMINDER => Some(Issue31PairingScope::ObserveIssue31),
3247 "action.omega.full_auto.stop"
3248 | "action.omega.full_auto.pause"
3249 | "action.omega.full_auto.resume" => Some(Issue31PairingScope::ControlFullAuto),
3250 "action.omega.provider_handoff" => Some(Issue31PairingScope::RequestProviderHandoff),
3251 "action.omega.community.act" => Some(Issue31PairingScope::ActInCommunity),
3252 _ => None,
3253 }
3254}
3255
3256fn digest_ref_suffix(bytes: &[u8]) -> String {
3257 let digest = format!("{:x}", Sha256::digest(bytes));
3258 digest[..24].to_string()
3259}
3260
3261/// One device paired all the way to a signed scoped grant, through the real
3262/// pairing state machine.
3263///
3264/// Shared so a test that needs a differently-scoped grant does not grow a
3265/// second, subtly different copy of the chain.
3266#[cfg(test)]
3267pub(crate) fn paired_fixture(
3268 scopes: Vec<Issue31PairingScope>,
3269) -> (
3270 Issue31HostConfiguration,
3271 Issue31HostController,
3272 String,
3273 String,
3274) {
3275 let host_public_key_hex = "1".repeat(64);
3276 let device_public_key_hex = "2".repeat(64);
3277 let configuration = Issue31HostConfiguration {
3278 host_ref: "omega.host.local".into(),
3279 host_public_key_hex: host_public_key_hex.clone(),
3280 sarah_public_key_hex: "3".repeat(64),
3281 conversation: "sarah.0123456789abcdef01234567".into(),
3282 display_name: "Local Omega".into(),
3283 relay_urls: vec!["wss://relay.example.com".into()],
3284 generation: 1,
3285 };
3286 let mut controller = Issue31HostController::new(configuration.clone()).expect("controller");
3287 controller
3288 .set_admitted_device_policy(vec![device_public_key_hex.clone()], scopes.clone())
3289 .expect("admit device");
3290 let challenge = controller
3291 .handle_pairing_event(
3292 Issue31PairingEvent {
3293 event_id: "a".repeat(64),
3294 record: Issue31PairingRecord::PairingRequest {
3295 schema: ISSUE31_PAIRING_SCHEMA.into(),
3296 host_ref: configuration.host_ref.clone(),
3297 host_public_key_hex: host_public_key_hex.clone(),
3298 device_public_key_hex: device_public_key_hex.clone(),
3299 issued_at: 100,
3300 pairing_request_ref: "pairing_request.restart.device".into(),
3301 requested_scopes: scopes,
3302 expires_at: 1_000,
3303 },
3304 },
3305 101,
3306 )
3307 .expect("request")
3308 .expect("challenge");
3309 let challenge_value = match &challenge {
3310 Issue31PairingRecord::PairingChallenge { challenge, .. } => challenge.clone(),
3311 _ => panic!("challenge"),
3312 };
3313 controller
3314 .record_emitted_pairing("b".repeat(64), challenge)
3315 .expect("record challenge");
3316 let grant = controller
3317 .handle_pairing_event(
3318 Issue31PairingEvent {
3319 event_id: "c".repeat(64),
3320 record: Issue31PairingRecord::PairingResponse {
3321 schema: ISSUE31_PAIRING_SCHEMA.into(),
3322 host_ref: configuration.host_ref.clone(),
3323 host_public_key_hex,
3324 device_public_key_hex: device_public_key_hex.clone(),
3325 issued_at: 102,
3326 pairing_response_ref: "pairing_response.restart.device".into(),
3327 pairing_challenge_event_id: "b".repeat(64),
3328 challenge: challenge_value,
3329 expires_at: 1_000,
3330 },
3331 },
3332 102,
3333 )
3334 .expect("response")
3335 .expect("grant");
3336 let grant_ref = match &grant {
3337 Issue31PairingRecord::ScopedGrant { grant_ref, .. } => grant_ref.clone(),
3338 _ => panic!("grant"),
3339 };
3340 controller
3341 .record_emitted_pairing("d".repeat(64), grant)
3342 .expect("record grant");
3343 (configuration, controller, device_public_key_hex, grant_ref)
3344}
3345
3346#[cfg(test)]
3347pub(crate) fn restart_fixture() -> (
3348 Issue31HostConfiguration,
3349 Issue31HostController,
3350 String,
3351 String,
3352) {
3353 let (configuration, mut controller, device_public_key_hex, grant_ref) =
3354 paired_fixture(vec![Issue31PairingScope::ControlFullAuto]);
3355 let host_public_key_hex = configuration.host_public_key_hex.clone();
3356 controller
3357 .handle_command_event(
3358 Issue31CommandEvent {
3359 event_id: "e".repeat(64),
3360 record: Issue31CommandRecord::CommandIntent {
3361 schema: ISSUE31_COMMAND_SCHEMA.into(),
3362 host_ref: configuration.host_ref.clone(),
3363 host_public_key_hex,
3364 device_public_key_hex: device_public_key_hex.clone(),
3365 grant_ref: grant_ref.clone(),
3366 action_ref: "action.omega.full_auto.stop".into(),
3367 idempotency_ref: "idempotency.restart.first_stop".into(),
3368 expected_generation: 1,
3369 arguments_ref: "arguments.omega.none".into(),
3370 issued_at: 103,
3371 expires_at: 200,
3372 },
3373 },
3374 104,
3375 |_, _, _| Issue31CommandExecution {
3376 status: Issue31CommandStatus::Stopped,
3377 outcome_ref: "outcome.omega.stopped".into(),
3378 reason_ref: None,
3379 },
3380 )
3381 .expect("command")
3382 .expect("result");
3383 let revocation = controller
3384 .revoke_grant(&grant_ref, 105, Some("reason.omega.owner_revoked".into()))
3385 .expect("revoke");
3386 controller
3387 .record_emitted_pairing("f".repeat(64), revocation)
3388 .expect("record revoke");
3389 (configuration, controller, device_public_key_hex, grant_ref)
3390}
3391
3392fn validate_sarah_binding(
3393 host_public_key_hex: &str,
3394 sarah_public_key_hex: &str,
3395) -> Result<(), Issue31NostrError> {
3396 if !valid_hex64(sarah_public_key_hex) || sarah_public_key_hex == host_public_key_hex {
3397 return Err(Issue31NostrError::Invalid(
3398 "grant Sarah identity is invalid or aliases the host".into(),
3399 ));
3400 }
3401 Ok(())
3402}
3403
3404fn validate_generation(generation: u64) -> Result<(), Issue31NostrError> {
3405 if generation == 0 {
3406 Err(Issue31NostrError::Invalid(
3407 "generation must be positive".into(),
3408 ))
3409 } else {
3410 Ok(())
3411 }
3412}
3413
3414fn validate_lifetime(issued_at: u64, expires_at: u64) -> Result<(), Issue31NostrError> {
3415 if expires_at <= issued_at {
3416 Err(Issue31NostrError::Invalid(
3417 "expiration must follow issue time".into(),
3418 ))
3419 } else {
3420 Ok(())
3421 }
3422}
3423
3424fn validate_ref_value(value: &str, field: &str) -> Result<(), Issue31NostrError> {
3425 if valid_ref(value) {
3426 Ok(())
3427 } else {
3428 Err(Issue31NostrError::Invalid(format!("invalid {field} ref")))
3429 }
3430}
3431
3432fn validate_hex_value(value: &str, field: &str) -> Result<(), Issue31NostrError> {
3433 if valid_hex64(value) {
3434 Ok(())
3435 } else {
3436 Err(Issue31NostrError::Invalid(format!("invalid {field} id")))
3437 }
3438}
3439
3440fn validate_reminder_arguments(
3441 reminder_id: &str,
3442 note: Option<&str>,
3443 target_event_id: Option<&str>,
3444 not_before: u64,
3445 expiration: Option<u64>,
3446) -> Result<(), Issue31NostrError> {
3447 if !valid_hex32(reminder_id)
3448 || note.is_some_and(|note| note.len() > 4_096)
3449 || target_event_id.is_some_and(|event_id| !valid_hex64(event_id))
3450 || expiration.is_some_and(|expiration| expiration <= not_before)
3451 {
3452 return Err(Issue31NostrError::Invalid(
3453 "invalid Issue 31 reminder arguments".into(),
3454 ));
3455 }
3456 Ok(())
3457}
3458
3459fn validate_authority_projection(
3460 decision: &Issue31AuthorityDecisionProjection,
3461 outcome: &Issue31TargetOutcomeProjection,
3462) -> Result<(), Issue31NostrError> {
3463 if !matches!(decision.state.as_str(), "allowed" | "refused")
3464 || !valid_ref(&decision.decision_ref)
3465 || decision
3466 .reason_ref
3467 .as_ref()
3468 .is_some_and(|reason| !valid_ref(reason))
3469 || !matches!(
3470 outcome.state.as_str(),
3471 "pending" | "succeeded" | "failed" | "stopped" | "unavailable"
3472 )
3473 || outcome
3474 .outcome_ref
3475 .as_ref()
3476 .is_some_and(|reference| !valid_ref(reference))
3477 || outcome
3478 .reason_ref
3479 .as_ref()
3480 .is_some_and(|reason| !valid_ref(reason))
3481 || (outcome.state != "pending" && outcome.outcome_ref.is_none())
3482 {
3483 return Err(Issue31NostrError::Invalid(
3484 "invalid Issue 31 authority receipt projection".into(),
3485 ));
3486 }
3487 Ok(())
3488}
3489
3490fn valid_turn_payload(payload: &serde_json::Value) -> bool {
3491 let Some(object) = payload.as_object() else {
3492 return false;
3493 };
3494 let allowed_keys = [
3495 "schema",
3496 "entry",
3497 "conversation",
3498 "turnRef",
3499 "seq",
3500 "timestamp",
3501 "parents",
3502 "payload",
3503 ];
3504 if object.len() != allowed_keys.len()
3505 || object
3506 .keys()
3507 .any(|key| !allowed_keys.contains(&key.as_str()))
3508 || object.get("schema").and_then(serde_json::Value::as_str)
3509 != Some("openagents.sarah.turn_record.v1")
3510 || !object
3511 .get("entry")
3512 .and_then(serde_json::Value::as_str)
3513 .is_some_and(|entry| {
3514 matches!(
3515 entry,
3516 "turn.started"
3517 | "tool.call"
3518 | "tool.result"
3519 | "tool.error"
3520 | "turn.finished"
3521 | "turn.interrupted"
3522 )
3523 })
3524 || !object
3525 .get("conversation")
3526 .and_then(serde_json::Value::as_str)
3527 .is_some_and(valid_conversation_tag)
3528 || !object
3529 .get("turnRef")
3530 .and_then(serde_json::Value::as_str)
3531 .is_some_and(|value| !value.is_empty() && value.len() <= 256)
3532 || !object
3533 .get("seq")
3534 .and_then(serde_json::Value::as_u64)
3535 .is_some_and(|sequence| sequence >= 1)
3536 || !object
3537 .get("timestamp")
3538 .and_then(serde_json::Value::as_str)
3539 .is_some_and(|timestamp| !timestamp.is_empty())
3540 || !object
3541 .get("payload")
3542 .is_some_and(serde_json::Value::is_object)
3543 {
3544 return false;
3545 }
3546 object
3547 .get("parents")
3548 .and_then(serde_json::Value::as_array)
3549 .is_some_and(|parents| {
3550 parents.iter().all(|parent| {
3551 let Some(parent) = parent.as_object() else {
3552 return false;
3553 };
3554 parent.len() == 2
3555 && parent
3556 .get("eventId")
3557 .and_then(serde_json::Value::as_str)
3558 .is_some_and(valid_hex64)
3559 && parent
3560 .get("marker")
3561 .and_then(serde_json::Value::as_str)
3562 .is_some_and(|marker| {
3563 matches!(
3564 marker,
3565 "prompt" | "reply" | "root" | "mention" | "tool" | "prior"
3566 )
3567 })
3568 })
3569 })
3570}
3571
3572fn valid_engram_plaintext(plaintext: &str) -> bool {
3573 let Ok(value) = serde_json::from_str::<serde_json::Value>(plaintext) else {
3574 return false;
3575 };
3576 let Some(object) = value.as_object() else {
3577 return false;
3578 };
3579 let Some(slug) = object.get("slug").and_then(serde_json::Value::as_str) else {
3580 return false;
3581 };
3582 if slug == "core" {
3583 return object
3584 .get("profile")
3585 .and_then(serde_json::Value::as_str)
3586 .is_some();
3587 }
3588 let valid_slug = slug.starts_with("mem/")
3589 && slug.len() <= 255
3590 && slug.split('/').skip(1).all(|segment| {
3591 !segment.is_empty()
3592 && segment.len() <= 64
3593 && segment.bytes().enumerate().all(|(index, byte)| {
3594 byte.is_ascii_lowercase()
3595 || byte.is_ascii_digit()
3596 || (index > 0 && matches!(byte, b'_' | b'-'))
3597 })
3598 });
3599 valid_slug
3600 && object
3601 .get("value")
3602 .is_some_and(|value| value.is_null() || value.is_string())
3603}
3604
3605fn valid_read_state_plaintext(plaintext: &str) -> bool {
3606 let Ok(value) = serde_json::from_str::<serde_json::Value>(plaintext) else {
3607 return false;
3608 };
3609 let Some(object) = value.as_object() else {
3610 return false;
3611 };
3612 object.get("v").and_then(serde_json::Value::as_u64) == Some(1)
3613 && object
3614 .get("client_id")
3615 .and_then(serde_json::Value::as_str)
3616 .is_some_and(|client_id| !client_id.is_empty() && client_id.len() <= 64)
3617 && object
3618 .get("contexts")
3619 .and_then(serde_json::Value::as_object)
3620 .is_some_and(|contexts| contexts.len() <= 10_000)
3621}
3622
3623fn valid_reminder_plaintext(plaintext: &str, not_before: Option<u64>) -> bool {
3624 let Ok(value) = serde_json::from_str::<serde_json::Value>(plaintext) else {
3625 return false;
3626 };
3627 let Some(status) = value
3628 .as_object()
3629 .and_then(|object| object.get("status"))
3630 .and_then(serde_json::Value::as_str)
3631 else {
3632 return false;
3633 };
3634 matches!(status, "pending" | "done" | "cancelled")
3635 && (status != "pending" || not_before.is_some())
3636}
3637
3638fn validate_scopes(scopes: &[Issue31PairingScope], field: &str) -> Result<(), Issue31NostrError> {
3639 let unique: BTreeSet<Issue31PairingScope> = scopes.iter().copied().collect();
3640 if scopes.is_empty() || scopes.len() > 6 || unique.len() != scopes.len() {
3641 Err(Issue31NostrError::Invalid(format!("invalid {field}")))
3642 } else {
3643 Ok(())
3644 }
3645}
3646
3647fn valid_hex64(value: &str) -> bool {
3648 value.len() == 64
3649 && value
3650 .bytes()
3651 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3652}
3653
3654fn valid_hex32(value: &str) -> bool {
3655 value.len() == 32
3656 && value
3657 .bytes()
3658 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3659}
3660
3661fn valid_ref(value: &str) -> bool {
3662 if value.len() < 3 || value.len() > 256 {
3663 return false;
3664 }
3665 let mut colon_parts = value.split(':');
3666 let Some(path) = colon_parts.next() else {
3667 return false;
3668 };
3669 if let Some(suffix) = colon_parts.next()
3670 && (suffix.is_empty()
3671 || !suffix
3672 .bytes()
3673 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')))
3674 {
3675 return false;
3676 }
3677 if colon_parts.next().is_some() {
3678 return false;
3679 }
3680 let segments: Vec<&str> = path.split('.').collect();
3681 if segments.len() < 2 {
3682 return false;
3683 }
3684 let Some(first) = segments.first() else {
3685 return false;
3686 };
3687 if !first
3688 .bytes()
3689 .next()
3690 .is_some_and(|byte| byte.is_ascii_lowercase())
3691 || !first.bytes().all(|byte| {
3692 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
3693 })
3694 {
3695 return false;
3696 }
3697 segments.iter().skip(1).all(|segment| {
3698 segment
3699 .bytes()
3700 .next()
3701 .is_some_and(|byte| byte.is_ascii_alphanumeric())
3702 && segment
3703 .bytes()
3704 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
3705 })
3706}
3707
3708pub fn is_issue31_public_ref(value: &str) -> bool {
3709 valid_ref(value)
3710}
3711
3712fn valid_relay_url(value: &str) -> bool {
3713 if value.len() < 6 || value.len() > 512 {
3714 return false;
3715 }
3716 let Ok(url) = url::Url::parse(value) else {
3717 return false;
3718 };
3719 matches!(url.scheme(), "ws" | "wss")
3720 && url.host_str().is_some()
3721 && url.username().is_empty()
3722 && url.password().is_none()
3723 && url.query().is_none()
3724 && url.fragment().is_none()
3725}
3726
3727fn valid_conversation_tag(value: &str) -> bool {
3728 let Some(hex_part) = value.strip_prefix("sarah.") else {
3729 return false;
3730 };
3731 hex_part.len() == 24
3732 && hex_part
3733 .bytes()
3734 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3735}
3736
3737fn all_unique(values: &[String]) -> bool {
3738 values.iter().collect::<BTreeSet<_>>().len() == values.len()
3739}
3740
3741#[cfg(test)]
3742mod tests {
3743 use std::cell::Cell;
3744
3745 use super::*;
3746
3747 #[test]
3748 fn shared_fixtures_decode_and_reconcile() {
3749 let discovery = Issue31HostDiscovery::decode(include_bytes!(
3750 "../fixtures/openagents.omega.issue31.host_discovery.v1.canonical.json"
3751 ))
3752 .expect("host fixture");
3753 let pairing = Issue31PairingRecord::decode(include_bytes!(
3754 "../fixtures/openagents.omega.issue31.pairing.v1.canonical.json"
3755 ))
3756 .expect("pairing fixture");
3757 let intent = Issue31CommandRecord::decode(include_bytes!(
3758 "../fixtures/openagents.omega.issue31.command.v1.canonical-intent.json"
3759 ))
3760 .expect("intent fixture");
3761 let result = Issue31CommandRecord::decode(include_bytes!(
3762 "../fixtures/openagents.omega.issue31.command.v1.canonical-result.json"
3763 ))
3764 .expect("result fixture");
3765 assert_eq!(discovery.generation, 4);
3766 assert_eq!(discovery.sarah_public_key_hex, "3".repeat(64));
3767 assert!(matches!(
3768 pairing,
3769 Issue31PairingRecord::ScopedGrant {
3770 sarah_public_key_hex,
3771 ..
3772 } if sarah_public_key_hex == "3".repeat(64)
3773 ));
3774 let states = reconcile_issue31_commands(&[
3775 Issue31CommandEvent {
3776 event_id: "4".repeat(64),
3777 record: intent,
3778 },
3779 Issue31CommandEvent {
3780 event_id: "5".repeat(64),
3781 record: result,
3782 },
3783 ])
3784 .expect("reconcile");
3785 assert_eq!(states.len(), 1);
3786 assert!(states[0].result.is_some());
3787 }
3788
3789 #[test]
3790 fn shared_v2_fixtures_decode_with_canonical_hashes() {
3791 let discovery_bytes =
3792 include_bytes!("../fixtures/openagents.omega.issue31.host_discovery.v2.canonical.json");
3793 let intent_bytes =
3794 include_bytes!("../fixtures/openagents.omega.issue31.command.v2.canonical-intent.json");
3795 let result_bytes =
3796 include_bytes!("../fixtures/openagents.omega.issue31.command.v2.canonical-result.json");
3797 let projection_bytes = include_bytes!(
3798 "../fixtures/openagents.omega.issue31.owner_projection.v1.canonical.json"
3799 );
3800 let discovery = Issue31HostDiscoveryV2::decode(discovery_bytes).expect("v2 discovery");
3801 let intent = Issue31CommandRecordV2::decode(intent_bytes).expect("v2 intent");
3802 let result = Issue31CommandRecordV2::decode(result_bytes).expect("v2 result");
3803 let projection =
3804 Issue31OwnerProjectionRecord::decode(projection_bytes).expect("owner projection");
3805 projection
3806 .validate_private_binding(&"1".repeat(64), &"2".repeat(64), &"3".repeat(64))
3807 .expect("projection source binding");
3808
3809 assert_eq!(discovery.conversation, "sarah.0123456789abcdef01234567");
3810 assert!(matches!(
3811 intent,
3812 Issue31CommandRecordV2::CommandIntent { .. }
3813 ));
3814 assert!(matches!(
3815 result,
3816 Issue31CommandRecordV2::CommandResult {
3817 status: Issue31CommandHandlingStatus::Accepted,
3818 ..
3819 }
3820 ));
3821 assert!(matches!(
3822 projection.projection,
3823 Issue31OwnerProjectionBody::Message {
3824 role: Issue31SourceRole::Owner,
3825 ..
3826 }
3827 ));
3828 assert_eq!(
3829 format!("{:x}", Sha256::digest(discovery_bytes)),
3830 "a5604d4c792a5ed556f023e150f01b371c5cf702b95b72786e0c7a9adbbdcb1c"
3831 );
3832 assert_eq!(
3833 format!("{:x}", Sha256::digest(intent_bytes)),
3834 "7bb7b23680be10756184668ae7722c09c634a1941b086f66d0425da4e8371bbe"
3835 );
3836 assert_eq!(
3837 format!("{:x}", Sha256::digest(result_bytes)),
3838 "51bca57e14c3d45518c342c2d1f848972281de848f809c34566ed183c7e4e387"
3839 );
3840 assert_eq!(
3841 format!("{:x}", Sha256::digest(projection_bytes)),
3842 "2a8bec5fa23f27d20db35f3d76bd59817672431328f191bc4302dfa37e7f804d"
3843 );
3844 }
3845
3846 const OWNER_PROJECTION_BODY_FIXTURES: &[(&str, &str, &[u8])] = &[
3847 (
3848 "read-state",
3849 "efd96dbe997e021c8e77300a802ab929b8c05981a1029c509b94d47410afc264",
3850 include_bytes!(
3851 "../fixtures/openagents.omega.issue31.owner_projection.v1.canonical-read-state.json"
3852 ),
3853 ),
3854 (
3855 "reminder",
3856 "d21b2168d32d3c8e76294502b9a30a75a6f5f4f15c5195ac0c160fad15539fe3",
3857 include_bytes!(
3858 "../fixtures/openagents.omega.issue31.owner_projection.v1.canonical-reminder.json"
3859 ),
3860 ),
3861 (
3862 "authority-receipt",
3863 "50d97118aec8931e624856246ae5e187bb6944f750052383f89472c4e9e27733",
3864 include_bytes!(
3865 "../fixtures/openagents.omega.issue31.owner_projection.v1.canonical-authority-receipt.json"
3866 ),
3867 ),
3868 (
3869 "engram",
3870 "d499644feb77cb7d61b35fda9a4fbafe0b06fcc86e33a61985fbe36c1a819dae",
3871 include_bytes!(
3872 "../fixtures/openagents.omega.issue31.owner_projection.v1.canonical-engram.json"
3873 ),
3874 ),
3875 ];
3876
3877 const OWNER_PROJECTION_NEGATIVE_FIXTURES: &[(&str, &str, &[u8])] = &[
3878 (
3879 "read-state-role",
3880 "1073644580d2c8d8768866c81a26cb8b044b13da4297383d54962ff949982baa",
3881 include_bytes!(
3882 "../fixtures/openagents.omega.issue31.owner_projection.v1.negative-read-state-role.json"
3883 ),
3884 ),
3885 (
3886 "read-state-version",
3887 "9b134615eb992b2395c59dfc72abe8be3d472dd69c8dff73b7ec670757ebcfba",
3888 include_bytes!(
3889 "../fixtures/openagents.omega.issue31.owner_projection.v1.negative-read-state-version.json"
3890 ),
3891 ),
3892 (
3893 "reminder-pending-without-not-before",
3894 "e0f8a8c6a6f22c4b53326dbeb193eeda0832b8b8cbe0c2aae02ca7e1b70a1cec",
3895 include_bytes!(
3896 "../fixtures/openagents.omega.issue31.owner_projection.v1.negative-reminder-pending-without-not-before.json"
3897 ),
3898 ),
3899 (
3900 "authority-receipt-terminal-without-outcome",
3901 "737bbaa8fece20bf9b898f4f76f5bf6d4369a1b0a487984302f0cf9a8bd9cc3b",
3902 include_bytes!(
3903 "../fixtures/openagents.omega.issue31.owner_projection.v1.negative-authority-receipt-terminal-without-outcome.json"
3904 ),
3905 ),
3906 (
3907 "engram-slug",
3908 "3997fb6d470f20e4796f4765d2406e6e3f4a629f090b3c4ef26e77274cf7b6ed",
3909 include_bytes!(
3910 "../fixtures/openagents.omega.issue31.owner_projection.v1.negative-engram-slug.json"
3911 ),
3912 ),
3913 ];
3914
3915 /// The read-state, reminder, authority-receipt, and engram projection bodies
3916 /// are the three omega#46 exits that had no shared fixture and therefore no
3917 /// agreement between this host and the device reader. The pinned digests are
3918 /// the byte-sharing mechanism: the same digests are asserted by the
3919 /// TypeScript peer, so a one-sided edit fails on both sides.
3920 #[test]
3921 fn owner_projection_body_fixtures_decode_and_bind() {
3922 for (label, digest, bytes) in OWNER_PROJECTION_BODY_FIXTURES {
3923 assert_eq!(
3924 &format!("{:x}", Sha256::digest(bytes)),
3925 digest,
3926 "{label} fixture bytes changed"
3927 );
3928 let record = Issue31OwnerProjectionRecord::decode(bytes)
3929 .unwrap_or_else(|error| panic!("{label} fixture decodes: {error}"));
3930 record
3931 .validate_private_binding(&"1".repeat(64), &"2".repeat(64), &"3".repeat(64))
3932 .unwrap_or_else(|error| panic!("{label} fixture binds: {error}"));
3933 }
3934 }
3935
3936 #[test]
3937 fn owner_projection_negative_fixtures_are_refused() {
3938 for (label, digest, bytes) in OWNER_PROJECTION_NEGATIVE_FIXTURES {
3939 assert_eq!(
3940 &format!("{:x}", Sha256::digest(bytes)),
3941 digest,
3942 "{label} fixture bytes changed"
3943 );
3944 assert!(
3945 Issue31OwnerProjectionRecord::decode(bytes).is_err(),
3946 "{label} negative fixture must be refused"
3947 );
3948 }
3949 }
3950
3951 /// The fixtures are only shared truth if the host actually emits them. This
3952 /// drives the real emitter with each fixture's own inputs and requires the
3953 /// emitted bytes back.
3954 #[test]
3955 fn the_emitter_reproduces_every_canonical_body_fixture() {
3956 for (label, _, bytes) in OWNER_PROJECTION_BODY_FIXTURES {
3957 let expected =
3958 Issue31OwnerProjectionRecord::decode(bytes).expect("canonical fixture decodes");
3959 let emission = emit_issue31_owner_projection(Issue31OwnerProjectionInput {
3960 host_ref: &expected.host_ref,
3961 host_public_key_hex: &expected.host_public_key_hex,
3962 device_public_key_hex: &expected.device_public_key_hex,
3963 sarah_public_key_hex: &"3".repeat(64),
3964 grant_ref: &expected.grant_ref,
3965 expected_generation: expected.expected_generation,
3966 source_event_id: &expected.source_event_id,
3967 source_author_public_key_hex: &expected.source_author_public_key_hex,
3968 source_kind: expected.source_kind,
3969 source_created_at: expected.source_created_at,
3970 projected_at: expected.projected_at,
3971 projection: expected.projection.clone(),
3972 })
3973 .unwrap_or_else(|error| panic!("{label} emits: {error}"));
3974 assert_eq!(emission.record, expected, "{label} emitted record differs");
3975 let emitted: serde_json::Value =
3976 serde_json::from_str(&emission.content).expect("emitted content is JSON");
3977 let fixture: serde_json::Value =
3978 serde_json::from_slice(bytes).expect("fixture is JSON");
3979 assert_eq!(emitted, fixture, "{label} emitted bytes differ from fixture");
3980 }
3981 }
3982
3983 const WITHHELD_SOURCES_FIXTURES: &[(&str, &str, &[u8])] = &[
3984 (
3985 "complete",
3986 "c1339d6da3b99ca83c099cf87d3cf93a81fa1c90aac25ab54af8f886ce36c28a",
3987 include_bytes!(
3988 "../fixtures/openagents.omega.issue31.withheld_sources.v1.canonical-complete.json"
3989 ),
3990 ),
3991 (
3992 "partial",
3993 "acb28484bf4d8722d774837abda0cc36edce0c74dc599edff820e4e70476bb01",
3994 include_bytes!(
3995 "../fixtures/openagents.omega.issue31.withheld_sources.v1.canonical-partial.json"
3996 ),
3997 ),
3998 ];
3999
4000 /// Each negative fixture is one specific way a coverage statement could lie
4001 /// about how much of the owner's view reached the device.
4002 const WITHHELD_SOURCES_NEGATIVE_FIXTURES: &[(&str, &str, &[u8])] = &[
4003 (
4004 "complete-with-counts",
4005 "8d5e2b9a718a65b2c808343e0723707acb423928652d3b399c5ef3b396a506fa",
4006 include_bytes!(
4007 "../fixtures/openagents.omega.issue31.withheld_sources.v1.negative-complete-with-counts.json"
4008 ),
4009 ),
4010 (
4011 "scan-bound-exact",
4012 "8ece8427b2d133bf13ace98a8eee7e1755a58a4dceccf45c507f15f3351a5b54",
4013 include_bytes!(
4014 "../fixtures/openagents.omega.issue31.withheld_sources.v1.negative-scan-bound-exact.json"
4015 ),
4016 ),
4017 (
4018 "zero-count",
4019 "02babd0fb35243371e977d3db394ed71b91de7df2ea60270bf5f19d08ed40c81",
4020 include_bytes!(
4021 "../fixtures/openagents.omega.issue31.withheld_sources.v1.negative-zero-count.json"
4022 ),
4023 ),
4024 (
4025 "unreadable-cause",
4026 "c8eca6e40d9555c329a537eb14e667e6191f71267ca09db5885f9820630e7033",
4027 include_bytes!(
4028 "../fixtures/openagents.omega.issue31.withheld_sources.v1.negative-unreadable-cause.json"
4029 ),
4030 ),
4031 ];
4032
4033 /// The coverage statement is the only thing that lets a device tell "this is
4034 /// everything" from "this is what arrived". The digests are pinned
4035 /// identically in the TypeScript peer, so a one-sided edit fails on both
4036 /// sides rather than drifting into disagreement about what the phone is
4037 /// being told.
4038 #[test]
4039 fn withheld_sources_fixtures_decode_and_bind() {
4040 for (label, digest, bytes) in WITHHELD_SOURCES_FIXTURES {
4041 assert_eq!(
4042 &format!("{:x}", Sha256::digest(bytes)),
4043 digest,
4044 "{label} fixture bytes changed"
4045 );
4046 let record = Issue31WithheldSourcesRecord::decode(bytes)
4047 .unwrap_or_else(|error| panic!("{label} fixture decodes: {error}"));
4048 record
4049 .validate_private_binding(&"1".repeat(64), &"2".repeat(64))
4050 .unwrap_or_else(|error| panic!("{label} fixture binds: {error}"));
4051 }
4052 }
4053
4054 #[test]
4055 fn a_complete_statement_and_a_partial_statement_are_not_the_same_record() {
4056 let complete = Issue31WithheldSourcesRecord::decode(WITHHELD_SOURCES_FIXTURES[0].2)
4057 .expect("the complete fixture decodes");
4058 let partial = Issue31WithheldSourcesRecord::decode(WITHHELD_SOURCES_FIXTURES[1].2)
4059 .expect("the partial fixture decodes");
4060 assert_eq!(complete.coverage, ISSUE31_WITHHELD_COVERAGE_COMPLETE);
4061 assert!(complete.withheld.is_empty());
4062 assert_eq!(partial.coverage, ISSUE31_WITHHELD_COVERAGE_PARTIAL);
4063 assert_ne!(complete.coverage, partial.coverage);
4064 // Every count names why, because "3 missing" without a reason is
4065 // nearly as unhelpful as silence.
4066 for entry in &partial.withheld {
4067 assert!(is_issue31_public_ref(&entry.reason_ref));
4068 assert!(entry.count > 0);
4069 }
4070 assert_eq!(
4071 partial
4072 .withheld
4073 .iter()
4074 .map(|entry| entry.cause)
4075 .collect::<Vec<_>>(),
4076 vec![
4077 Issue31WithheldCause::Quarantined,
4078 Issue31WithheldCause::ScanBound
4079 ]
4080 );
4081 }
4082
4083 /// Each named refusal gets its own assertion, so no case can be carried by
4084 /// a neighbour's evidence.
4085 #[test]
4086 fn a_complete_coverage_over_a_non_empty_count_list_is_refused() {
4087 let (label, digest, bytes) = WITHHELD_SOURCES_NEGATIVE_FIXTURES[0];
4088 assert_eq!(
4089 &format!("{:x}", Sha256::digest(bytes)),
4090 digest,
4091 "{label} fixture bytes changed"
4092 );
4093 assert!(
4094 Issue31WithheldSourcesRecord::decode(bytes).is_err(),
4095 "a record that says complete while withholding sources must be refused"
4096 );
4097 }
4098
4099 #[test]
4100 fn an_exact_scan_bound_count_is_refused() {
4101 let (label, digest, bytes) = WITHHELD_SOURCES_NEGATIVE_FIXTURES[1];
4102 assert_eq!(
4103 &format!("{:x}", Sha256::digest(bytes)),
4104 digest,
4105 "{label} fixture bytes changed"
4106 );
4107 assert!(
4108 Issue31WithheldSourcesRecord::decode(bytes).is_err(),
4109 "a host that stopped reading cannot state an exact number"
4110 );
4111 }
4112
4113 #[test]
4114 fn a_withheld_count_of_zero_is_refused() {
4115 let (label, digest, bytes) = WITHHELD_SOURCES_NEGATIVE_FIXTURES[2];
4116 assert_eq!(
4117 &format!("{:x}", Sha256::digest(bytes)),
4118 digest,
4119 "{label} fixture bytes changed"
4120 );
4121 assert!(
4122 Issue31WithheldSourcesRecord::decode(bytes).is_err(),
4123 "a zero count is not a withheld source"
4124 );
4125 }
4126
4127 /// A device-side read failure is real, but only the device can observe it.
4128 /// The wire vocabulary has no such cause, so a host cannot assert one.
4129 #[test]
4130 fn a_host_cannot_claim_a_cause_only_the_device_can_observe() {
4131 let (label, digest, bytes) = WITHHELD_SOURCES_NEGATIVE_FIXTURES[3];
4132 assert_eq!(
4133 &format!("{:x}", Sha256::digest(bytes)),
4134 digest,
4135 "{label} fixture bytes changed"
4136 );
4137 assert!(
4138 Issue31WithheldSourcesRecord::decode(bytes).is_err(),
4139 "a device-observed cause must not be assertable by the host"
4140 );
4141 }
4142
4143 #[test]
4144 fn the_withheld_emitter_reproduces_every_canonical_fixture() {
4145 for (label, _, bytes) in WITHHELD_SOURCES_FIXTURES {
4146 let expected =
4147 Issue31WithheldSourcesRecord::decode(bytes).expect("canonical fixture decodes");
4148 let emission = emit_issue31_withheld_sources(Issue31WithheldSourcesInput {
4149 host_ref: &expected.host_ref,
4150 host_public_key_hex: &expected.host_public_key_hex,
4151 device_public_key_hex: &expected.device_public_key_hex,
4152 grant_ref: &expected.grant_ref,
4153 expected_generation: expected.expected_generation,
4154 observed_at: expected.observed_at,
4155 withheld: expected.withheld.clone(),
4156 })
4157 .unwrap_or_else(|error| panic!("{label} emits: {error}"));
4158 assert_eq!(emission.record, expected, "{label} emitted record differs");
4159 let emitted: serde_json::Value =
4160 serde_json::from_str(&emission.content).expect("emitted content is JSON");
4161 let fixture: serde_json::Value =
4162 serde_json::from_slice(bytes).expect("fixture is JSON");
4163 assert_eq!(emitted, fixture, "{label} emitted bytes differ from fixture");
4164 }
4165 }
4166
4167 /// Falsification. `coverage` is derived from the counts rather than
4168 /// supplied, so the one field a bug would set to "complete" over a
4169 /// non-empty list cannot be set at all. Feeding the emitter a count list it
4170 /// must refuse proves the routing back through its own decoder is load
4171 /// bearing rather than decorative.
4172 #[test]
4173 fn the_withheld_emitter_cannot_produce_a_record_its_own_decoder_refuses() {
4174 let repeated = vec![
4175 Issue31WithheldSourceCount {
4176 cause: Issue31WithheldCause::Quarantined,
4177 count: 1,
4178 exact: true,
4179 reason_ref: "reason.omega.invalid_projection_source".into(),
4180 },
4181 Issue31WithheldSourceCount {
4182 cause: Issue31WithheldCause::Quarantined,
4183 count: 4,
4184 exact: true,
4185 reason_ref: "reason.omega.invalid_projection_source".into(),
4186 },
4187 ];
4188 let error = emit_issue31_withheld_sources(Issue31WithheldSourcesInput {
4189 host_ref: "omega.host.local",
4190 host_public_key_hex: &"1".repeat(64),
4191 device_public_key_hex: &"2".repeat(64),
4192 grant_ref: "grant.omega.device_1",
4193 expected_generation: 3,
4194 observed_at: 1_784_937_651,
4195 withheld: repeated,
4196 })
4197 .expect_err("two counts for one cause and reason are ambiguous");
4198 assert!(matches!(error, Issue31NostrError::Invalid(_)));
4199 }
4200
4201 /// Falsification, on the other half. A cause that cannot state an exact
4202 /// number must not be emitted as though it could, even when the caller asks
4203 /// for it.
4204 #[test]
4205 fn the_withheld_emitter_refuses_a_precision_the_cause_cannot_have() {
4206 let error = emit_issue31_withheld_sources(Issue31WithheldSourcesInput {
4207 host_ref: "omega.host.local",
4208 host_public_key_hex: &"1".repeat(64),
4209 device_public_key_hex: &"2".repeat(64),
4210 grant_ref: "grant.omega.device_1",
4211 expected_generation: 3,
4212 observed_at: 1_784_937_651,
4213 withheld: vec![Issue31WithheldSourceCount {
4214 cause: Issue31WithheldCause::ScanBound,
4215 count: 900,
4216 exact: true,
4217 reason_ref: "reason.omega.projection_scan_bound".into(),
4218 }],
4219 })
4220 .expect_err("the scan bound cannot be exact");
4221 assert!(matches!(error, Issue31NostrError::Invalid(_)));
4222
4223 emit_issue31_withheld_sources(Issue31WithheldSourcesInput {
4224 host_ref: "omega.host.local",
4225 host_public_key_hex: &"1".repeat(64),
4226 device_public_key_hex: &"2".repeat(64),
4227 grant_ref: "grant.omega.device_1",
4228 expected_generation: 3,
4229 observed_at: 1_784_937_651,
4230 withheld: vec![Issue31WithheldSourceCount {
4231 cause: Issue31WithheldCause::ScanBound,
4232 count: 900,
4233 exact: false,
4234 reason_ref: "reason.omega.projection_scan_bound".into(),
4235 }],
4236 })
4237 .expect("the same count as a lower bound is exactly what the host knows");
4238 }
4239
4240 /// Falsification. A read-state body can sit inside every per-field bound the
4241 /// projection contract states and still serialize past the record budget once
4242 /// JSON escaping is applied, because the escaping happens outside the body.
4243 /// `validate` accepts that body. The emitter must not, because the device
4244 /// reader applies the budget and would refuse the published record.
4245 #[test]
4246 fn the_emitter_cannot_produce_a_record_its_own_decoder_refuses() {
4247 let mut contexts = String::new();
4248 for index in 0..1_800 {
4249 if index > 0 {
4250 contexts.push(',');
4251 }
4252 // Every escaped quote in a context id costs one byte in the body and
4253 // three in the record, so a legal body crosses the record budget.
4254 contexts.push_str(&format!("\"{}\":1784937608", "\\\"".repeat(120)));
4255 let _ = index;
4256 }
4257 let plaintext = format!("{{\"v\":1,\"client_id\":\"omega-host\",\"contexts\":{{{contexts}}}}}");
4258 assert!(
4259 plaintext.len() <= 524_288,
4260 "the oversized body must stay inside its own plaintext bound"
4261 );
4262 let projection = Issue31OwnerProjectionBody::ReadState {
4263 d_tag: "read-state:owner-private".into(),
4264 plaintext,
4265 };
4266 projection
4267 .validate()
4268 .expect("the body is legal on its own terms");
4269
4270 let error = emit_issue31_owner_projection(Issue31OwnerProjectionInput {
4271 host_ref: "omega.host.local",
4272 host_public_key_hex: &"1".repeat(64),
4273 device_public_key_hex: &"2".repeat(64),
4274 sarah_public_key_hex: &"3".repeat(64),
4275 grant_ref: "grant.omega.device_1",
4276 expected_generation: 3,
4277 source_event_id: &"c".repeat(64),
4278 source_author_public_key_hex: &"1".repeat(64),
4279 source_kind: SARAH_READ_STATE_KIND,
4280 source_created_at: 1_784_937_620,
4281 projected_at: 1_784_937_621,
4282 projection,
4283 })
4284 .expect_err("the emitter must refuse a record its own decoder refuses");
4285 assert!(matches!(error, Issue31NostrError::Invalid(_)));
4286 }
4287
4288 /// A reference the host builds from a Sarah-authored tag is still untrusted
4289 /// input. The emitter refuses it rather than publishing a record the device
4290 /// reader would reject.
4291 #[test]
4292 fn the_emitter_refuses_an_unsafe_reference_taken_from_a_source_event() {
4293 let projection = Issue31OwnerProjectionBody::AuthorityReceipt {
4294 receipt_ref: format!("receipt.issue31.{}", "a".repeat(24)),
4295 turn_ref: "turn.issue31.release_evidence".into(),
4296 authority_decision: Issue31AuthorityDecisionProjection {
4297 state: "refused".into(),
4298 decision_ref: format!("decision.issue31.{}", "a".repeat(24)),
4299 reason_ref: Some("reason.openagents._reserved".into()),
4300 },
4301 target_outcome: Issue31TargetOutcomeProjection {
4302 state: "pending".into(),
4303 outcome_ref: None,
4304 reason_ref: None,
4305 },
4306 };
4307 emit_issue31_owner_projection(Issue31OwnerProjectionInput {
4308 host_ref: "omega.host.local",
4309 host_public_key_hex: &"1".repeat(64),
4310 device_public_key_hex: &"2".repeat(64),
4311 sarah_public_key_hex: &"3".repeat(64),
4312 grant_ref: "grant.omega.device_1",
4313 expected_generation: 3,
4314 source_event_id: &("a".repeat(24) + &"b".repeat(40)),
4315 source_author_public_key_hex: &"3".repeat(64),
4316 source_kind: SARAH_AUTHORITY_RECEIPT_KIND,
4317 source_created_at: 1_784_937_640,
4318 projected_at: 1_784_937_641,
4319 projection,
4320 })
4321 .expect_err("a leading underscore segment is not a public reference");
4322 }
4323
4324 #[test]
4325 fn v2_host_discovery_validation() {
4326 let v2 = Issue31HostDiscoveryV2 {
4327 schema: ISSUE31_HOST_DISCOVERY_SCHEMA_V2.into(),
4328 host_ref: "omega.host.local".into(),
4329 host_public_key_hex: "1".repeat(64),
4330 sarah_public_key_hex: "3".repeat(64),
4331 conversation: format!("sarah.{}", "a".repeat(24)),
4332 display_name: "Omega Primary Host".into(),
4333 protocols: vec![
4334 ISSUE31_PAIRING_SCHEMA.into(),
4335 ISSUE31_COMMAND_SCHEMA.into(),
4336 ISSUE31_COMMAND_SCHEMA_V2.into(),
4337 ],
4338 relay_urls: vec!["wss://relay.openagents.com".into()],
4339 generation: 1,
4340 issued_at: 100,
4341 expires_at: 200,
4342 };
4343 v2.validate().expect("v2 discovery valid");
4344 let encoded = serde_json::to_vec(&v2).expect("serialize");
4345 let decoded = Issue31HostDiscoveryV2::decode(&encoded).expect("decode v2");
4346 assert_eq!(decoded.conversation, format!("sarah.{}", "a".repeat(24)));
4347 }
4348
4349 #[test]
4350 fn persisted_v1_controller_adopts_the_configured_conversation() {
4351 let configuration = Issue31HostConfiguration {
4352 host_ref: "omega.host.local".into(),
4353 host_public_key_hex: "1".repeat(64),
4354 sarah_public_key_hex: "3".repeat(64),
4355 conversation: "sarah.0123456789abcdef01234567".into(),
4356 display_name: "Local Omega".into(),
4357 relay_urls: vec!["wss://relay.example.com".into()],
4358 generation: 1,
4359 };
4360 let controller = Issue31HostController::new(configuration.clone()).expect("controller");
4361 let mut value = serde_json::to_value(controller).expect("controller json");
4362 value["configuration"]
4363 .as_object_mut()
4364 .expect("configuration object")
4365 .remove("conversation");
4366 let mut restored: Issue31HostController =
4367 serde_json::from_value(value).expect("deserialize v1 state");
4368 restored
4369 .adopt_conversation_if_missing(&configuration.conversation)
4370 .expect("adopt conversation");
4371 assert!(restored.matches_configuration(&configuration));
4372 restored
4373 .validate_persisted_state()
4374 .expect("validate migrated state");
4375 }
4376
4377 #[test]
4378 fn excess_fields_and_unsafe_relays_fail_closed() {
4379 let bytes =
4380 include_bytes!("../fixtures/openagents.omega.issue31.host_discovery.v1.canonical.json");
4381 let mut value: serde_json::Value = serde_json::from_slice(bytes).expect("fixture json");
4382 value["secret"] = serde_json::Value::String("nsec1forbidden".into());
4383 assert!(Issue31HostDiscovery::decode(value.to_string().as_bytes()).is_err());
4384 value.as_object_mut().expect("object").remove("secret");
4385 value["relayUrls"] = serde_json::json!(["wss://owner:secret@relay.example.com"]);
4386 assert!(Issue31HostDiscovery::decode(value.to_string().as_bytes()).is_err());
4387 value["relayUrls"] = serde_json::json!(["wss://relay.example.com/?token=forbidden"]);
4388 assert!(Issue31HostDiscovery::decode(value.to_string().as_bytes()).is_err());
4389
4390 let pairing_bytes =
4391 include_bytes!("../fixtures/openagents.omega.issue31.pairing.v1.canonical.json");
4392 let mut pairing: serde_json::Value =
4393 serde_json::from_slice(pairing_bytes).expect("pairing fixture json");
4394 pairing["sarahPublicKeyHex"] = pairing["hostPublicKeyHex"].clone();
4395 assert!(Issue31PairingRecord::decode(pairing.to_string().as_bytes()).is_err());
4396 pairing
4397 .as_object_mut()
4398 .expect("pairing object")
4399 .remove("sarahPublicKeyHex");
4400 assert!(Issue31PairingRecord::decode(pairing.to_string().as_bytes()).is_err());
4401 }
4402
4403 /// Drive a full pairing handshake for `device_public_key_hex` and return the
4404 /// grant the host issued, or `Err` if the host refused at any step.
4405 fn attempt_pairing(
4406 controller: &mut Issue31HostController,
4407 configuration: &Issue31HostConfiguration,
4408 device_public_key_hex: &str,
4409 seed: &str,
4410 now: u64,
4411 ) -> Result<Option<Issue31PairingRecord>, Issue31NostrError> {
4412 let request_event_id = format!("{:x}", Sha256::digest(format!("{seed}.request")));
4413 let challenge_event_id = format!("{:x}", Sha256::digest(format!("{seed}.challenge")));
4414 let response_event_id = format!("{:x}", Sha256::digest(format!("{seed}.response")));
4415 let challenge = controller.handle_pairing_event(
4416 Issue31PairingEvent {
4417 event_id: request_event_id,
4418 record: Issue31PairingRecord::PairingRequest {
4419 schema: ISSUE31_PAIRING_SCHEMA.into(),
4420 host_ref: configuration.host_ref.clone(),
4421 host_public_key_hex: configuration.host_public_key_hex.clone(),
4422 device_public_key_hex: device_public_key_hex.to_string(),
4423 issued_at: now,
4424 pairing_request_ref: format!("pairing_request.{seed}"),
4425 requested_scopes: vec![Issue31PairingScope::ControlFullAuto],
4426 expires_at: now.saturating_add(600),
4427 },
4428 },
4429 now,
4430 )?;
4431 let Some(challenge) = challenge else {
4432 return Ok(None);
4433 };
4434 let challenge_value = match &challenge {
4435 Issue31PairingRecord::PairingChallenge { challenge, .. } => challenge.clone(),
4436 _ => panic!("expected a pairing challenge"),
4437 };
4438 controller.record_emitted_pairing(challenge_event_id.clone(), challenge)?;
4439 controller.handle_pairing_event(
4440 Issue31PairingEvent {
4441 event_id: response_event_id,
4442 record: Issue31PairingRecord::PairingResponse {
4443 schema: ISSUE31_PAIRING_SCHEMA.into(),
4444 host_ref: configuration.host_ref.clone(),
4445 host_public_key_hex: configuration.host_public_key_hex.clone(),
4446 device_public_key_hex: device_public_key_hex.to_string(),
4447 issued_at: now,
4448 pairing_response_ref: format!("pairing_response.{seed}"),
4449 pairing_challenge_event_id: challenge_event_id,
4450 challenge: challenge_value,
4451 expires_at: now.saturating_add(600),
4452 },
4453 },
4454 now,
4455 )
4456 }
4457
4458 /// A revoked device must not be able to restore its authority by pairing
4459 /// again. The grant fold is keyed by `grant_ref`, so a fresh handshake would
4460 /// otherwise mint a brand-new `grant_ref` carrying the same scopes the owner
4461 /// just took away, with no owner action in between.
4462 #[test]
4463 fn revoked_device_cannot_repair_without_owner_readmission() {
4464 let (configuration, mut controller, device_public_key_hex, revoked_grant_ref) =
4465 restart_fixture();
4466 assert!(controller.device_admission_is_revoked(&device_public_key_hex));
4467 let refusal = attempt_pairing(
4468 &mut controller,
4469 &configuration,
4470 &device_public_key_hex,
4471 "revoked.repair",
4472 200,
4473 )
4474 .expect_err("a revoked device must be refused a pairing challenge");
4475 assert!(matches!(refusal, Issue31NostrError::Invalid(message) if message
4476 .contains("device admission was revoked")));
4477 // The only grant on record is still the revoked one.
4478 let projections = controller.grant_projections(200).expect("grant list");
4479 assert_eq!(projections.len(), 1);
4480 assert_eq!(projections[0].grant_ref, revoked_grant_ref);
4481 assert_eq!(projections[0].status, "revoked");
4482 }
4483
4484 /// The runtime admission allowlist is not persisted — it is re-applied from
4485 /// configuration on every start. So an in-memory block would evaporate on
4486 /// restart. The block has to live in the durable pairing log.
4487 #[test]
4488 fn revocation_block_survives_restart_and_allowlist_rebind() {
4489 let (configuration, controller, device_public_key_hex, _) = restart_fixture();
4490 let encoded = serde_json::to_vec(&controller).expect("serialize controller");
4491 let mut reloaded: Issue31HostController =
4492 serde_json::from_slice(&encoded).expect("deserialize controller");
4493 reloaded.validate_persisted_state().expect("persisted state");
4494 // Exactly what start-up does: re-apply the owner's configured allowlist.
4495 reloaded
4496 .set_admitted_device_policy(
4497 vec![device_public_key_hex.clone()],
4498 vec![Issue31PairingScope::ControlFullAuto],
4499 )
4500 .expect("rebind runtime policy");
4501 assert!(reloaded.device_admission_is_revoked(&device_public_key_hex));
4502 attempt_pairing(
4503 &mut reloaded,
4504 &configuration,
4505 &device_public_key_hex,
4506 "restart.repair",
4507 200,
4508 )
4509 .expect_err("the durable revocation must outlive a restart");
4510 }
4511
4512 /// Re-admission is an explicit owner act, and it clears the revocations that
4513 /// exist at that moment by event id. Replaying a cleared revocation must not
4514 /// re-block the device, and a later revocation must block it again.
4515 #[test]
4516 fn owner_readmission_clears_only_the_revocations_it_saw() {
4517 let (configuration, mut controller, device_public_key_hex, revoked_grant_ref) =
4518 restart_fixture();
4519 let cleared = controller
4520 .readmit_device(&device_public_key_hex)
4521 .expect("readmit");
4522 assert_eq!(cleared.len(), 1);
4523 assert!(!controller.device_admission_is_revoked(&device_public_key_hex));
4524 let grant = attempt_pairing(
4525 &mut controller,
4526 &configuration,
4527 &device_public_key_hex,
4528 "readmitted.repair",
4529 200,
4530 )
4531 .expect("re-admitted device pairs")
4532 .expect("re-admitted device receives a grant");
4533 let Issue31PairingRecord::ScopedGrant {
4534 grant_ref: new_grant_ref,
4535 ..
4536 } = &grant
4537 else {
4538 panic!("expected a scoped grant");
4539 };
4540 assert_ne!(new_grant_ref, &revoked_grant_ref);
4541 let new_grant_ref = new_grant_ref.clone();
4542 controller
4543 .record_emitted_pairing(format!("{:x}", Sha256::digest("readmitted.grant")), grant)
4544 .expect("record grant");
4545 // Replaying the already-cleared revocation cannot re-block the device.
4546 assert!(!controller.device_admission_is_revoked(&device_public_key_hex));
4547 // A *new* revocation is a new event id, so it is not cleared and blocks again.
4548 let revocation = controller
4549 .revoke_grant(&new_grant_ref, 300, Some("reason.omega.owner_revoked".into()))
4550 .expect("revoke the new grant");
4551 controller
4552 .record_emitted_pairing(
4553 format!("{:x}", Sha256::digest("readmitted.revocation")),
4554 revocation,
4555 )
4556 .expect("record revocation");
4557 assert!(controller.device_admission_is_revoked(&device_public_key_hex));
4558 attempt_pairing(
4559 &mut controller,
4560 &configuration,
4561 &device_public_key_hex,
4562 "second.repair",
4563 400,
4564 )
4565 .expect_err("a second revocation must fail closed again");
4566 }
4567
4568 /// Persisted state must not be able to pre-clear a revocation that does not
4569 /// exist, which would silently unblock a device the moment its revocation
4570 /// arrived from the relay.
4571 #[test]
4572 fn persisted_readmission_must_name_a_real_revocation() {
4573 let (_, controller, _, _) = restart_fixture();
4574 let mut encoded: serde_json::Value =
4575 serde_json::from_slice(&serde_json::to_vec(&controller).expect("serialize"))
4576 .expect("controller json");
4577 encoded["clearedDeviceRevocationEventIds"] =
4578 serde_json::json!([serde_json::Value::String("9".repeat(64))]);
4579 let forged: Issue31HostController =
4580 serde_json::from_value(encoded).expect("deserialize forged controller");
4581 assert!(forged.validate_persisted_state().is_err());
4582 }
4583
4584 #[test]
4585 fn production_host_controller_pairs_executes_renews_and_revokes() {
4586 let host_public_key_hex = "1".repeat(64);
4587 let device_public_key_hex = "2".repeat(64);
4588 let mut controller = Issue31HostController::new(Issue31HostConfiguration {
4589 host_ref: "omega.host.local".into(),
4590 host_public_key_hex: host_public_key_hex.clone(),
4591 sarah_public_key_hex: "3".repeat(64),
4592 conversation: "sarah.0123456789abcdef01234567".into(),
4593 display_name: "Local Omega".into(),
4594 relay_urls: vec!["wss://relay.example.com".into()],
4595 generation: 1,
4596 })
4597 .expect("controller");
4598 assert!(
4599 controller
4600 .set_admitted_device_policy(Vec::new(), vec![Issue31PairingScope::ObserveIssue31],)
4601 .is_err()
4602 );
4603 assert!(
4604 controller
4605 .set_admitted_device_policy(
4606 vec!["A".repeat(64)],
4607 vec![Issue31PairingScope::ObserveIssue31],
4608 )
4609 .is_err()
4610 );
4611 controller
4612 .set_admitted_device_policy(
4613 vec![device_public_key_hex.clone()],
4614 vec![
4615 Issue31PairingScope::ObserveIssue31,
4616 Issue31PairingScope::ControlFullAuto,
4617 ],
4618 )
4619 .expect("admit device");
4620 let expected_fingerprint =
4621 format!("{:x}", Sha256::digest(device_public_key_hex.as_bytes()))[..16]
4622 .to_ascii_uppercase();
4623 assert_eq!(
4624 controller.admitted_device_fingerprints(),
4625 vec![expected_fingerprint]
4626 );
4627 assert!(
4628 !serde_json::to_string(&controller)
4629 .expect("serialize runtime policy boundary")
4630 .contains(&device_public_key_hex)
4631 );
4632 let attacker_request = Issue31PairingRecord::PairingRequest {
4633 schema: ISSUE31_PAIRING_SCHEMA.into(),
4634 host_ref: "omega.host.local".into(),
4635 host_public_key_hex: host_public_key_hex.clone(),
4636 device_public_key_hex: "3".repeat(64),
4637 issued_at: 100,
4638 pairing_request_ref: "pairing_request.attacker.one".into(),
4639 requested_scopes: vec![
4640 Issue31PairingScope::ObserveIssue31,
4641 Issue31PairingScope::SendMessage,
4642 Issue31PairingScope::InterruptTurn,
4643 Issue31PairingScope::ControlFullAuto,
4644 Issue31PairingScope::RequestProviderHandoff,
4645 Issue31PairingScope::ActInCommunity,
4646 ],
4647 expires_at: 1_000,
4648 };
4649 assert!(
4650 controller
4651 .handle_pairing_event(
4652 Issue31PairingEvent {
4653 event_id: "9".repeat(64),
4654 record: attacker_request,
4655 },
4656 101,
4657 )
4658 .expect("ignore attacker")
4659 .is_none()
4660 );
4661 assert!(controller.pairing_events.is_empty());
4662 let request_event_id = "a".repeat(64);
4663 let request = Issue31PairingRecord::PairingRequest {
4664 schema: ISSUE31_PAIRING_SCHEMA.into(),
4665 host_ref: "omega.host.local".into(),
4666 host_public_key_hex: host_public_key_hex.clone(),
4667 device_public_key_hex: device_public_key_hex.clone(),
4668 issued_at: 100,
4669 pairing_request_ref: "pairing_request.device.one".into(),
4670 requested_scopes: vec![
4671 Issue31PairingScope::ObserveIssue31,
4672 Issue31PairingScope::ControlFullAuto,
4673 ],
4674 expires_at: 1_000,
4675 };
4676 let challenge = controller
4677 .handle_pairing_event(
4678 Issue31PairingEvent {
4679 event_id: request_event_id,
4680 record: request,
4681 },
4682 101,
4683 )
4684 .expect("request")
4685 .expect("challenge");
4686 let challenge_value = match &challenge {
4687 Issue31PairingRecord::PairingChallenge { challenge, .. } => challenge.clone(),
4688 _ => panic!("expected challenge"),
4689 };
4690 let challenge_event_id = "b".repeat(64);
4691 controller
4692 .record_emitted_pairing(challenge_event_id.clone(), challenge)
4693 .expect("record challenge");
4694 let response_event_id = "c".repeat(64);
4695 let grant = controller
4696 .handle_pairing_event(
4697 Issue31PairingEvent {
4698 event_id: response_event_id,
4699 record: Issue31PairingRecord::PairingResponse {
4700 schema: ISSUE31_PAIRING_SCHEMA.into(),
4701 host_ref: "omega.host.local".into(),
4702 host_public_key_hex: host_public_key_hex.clone(),
4703 device_public_key_hex: device_public_key_hex.clone(),
4704 issued_at: 102,
4705 pairing_response_ref: "pairing_response.device.one".into(),
4706 pairing_challenge_event_id: challenge_event_id,
4707 challenge: challenge_value,
4708 expires_at: 1_000,
4709 },
4710 },
4711 102,
4712 )
4713 .expect("response")
4714 .expect("grant");
4715 let grant_ref = match &grant {
4716 Issue31PairingRecord::ScopedGrant {
4717 grant_ref,
4718 sarah_public_key_hex,
4719 scopes,
4720 ..
4721 } => {
4722 assert_eq!(sarah_public_key_hex, &"3".repeat(64));
4723 assert_eq!(
4724 scopes,
4725 &vec![
4726 Issue31PairingScope::ObserveIssue31,
4727 Issue31PairingScope::ControlFullAuto,
4728 ]
4729 );
4730 grant_ref.clone()
4731 }
4732 _ => panic!("expected scoped grant"),
4733 };
4734 controller
4735 .record_emitted_pairing("d".repeat(64), grant)
4736 .expect("record grant");
4737
4738 let executions = Cell::new(0_u32);
4739 let result = controller
4740 .handle_command_event(
4741 Issue31CommandEvent {
4742 event_id: "e".repeat(64),
4743 record: Issue31CommandRecord::CommandIntent {
4744 schema: ISSUE31_COMMAND_SCHEMA.into(),
4745 host_ref: "omega.host.local".into(),
4746 host_public_key_hex: host_public_key_hex.clone(),
4747 device_public_key_hex: device_public_key_hex.clone(),
4748 grant_ref: grant_ref.clone(),
4749 action_ref: "action.omega.full_auto.stop".into(),
4750 idempotency_ref: "idempotency.device.stop_one".into(),
4751 expected_generation: 1,
4752 arguments_ref: "arguments.omega.none".into(),
4753 issued_at: 103,
4754 expires_at: 200,
4755 },
4756 },
4757 104,
4758 |_, _, _| {
4759 executions.set(executions.get().saturating_add(1));
4760 Issue31CommandExecution {
4761 status: Issue31CommandStatus::Stopped,
4762 outcome_ref: "outcome.omega.stopped".into(),
4763 reason_ref: None,
4764 }
4765 },
4766 )
4767 .expect("command")
4768 .expect("terminal result");
4769 assert_eq!(executions.get(), 1);
4770 assert!(matches!(
4771 result,
4772 Issue31CommandRecord::CommandResult {
4773 status: Issue31CommandStatus::Stopped,
4774 ..
4775 }
4776 ));
4777
4778 let v2_executions = Cell::new(0_u32);
4779 let v2_result = controller
4780 .handle_command_event_v2(
4781 "7".repeat(64),
4782 Issue31CommandRecordV2::CommandIntent {
4783 schema: ISSUE31_COMMAND_SCHEMA_V2.into(),
4784 host_ref: "omega.host.local".into(),
4785 host_public_key_hex,
4786 device_public_key_hex,
4787 grant_ref: grant_ref.clone(),
4788 idempotency_ref: "idempotency.device.read_one".into(),
4789 expected_generation: 1,
4790 arguments: Issue31CommandArguments::ReadStatePatch {
4791 action_ref: ISSUE31_ACTION_ADVANCE_READ_STATE.into(),
4792 slot_id: "mobile".into(),
4793 client_id: "iphone".into(),
4794 context_ref: "sarah-conversation:sarah.0123456789abcdef01234567".into(),
4795 read_at: 104,
4796 },
4797 issued_at: 103,
4798 expires_at: 200,
4799 },
4800 104,
4801 |arguments, _, _, _, _| {
4802 assert!(matches!(
4803 arguments,
4804 Issue31CommandArguments::ReadStatePatch { .. }
4805 ));
4806 v2_executions.set(v2_executions.get().saturating_add(1));
4807 Issue31CommandExecutionV2 {
4808 status: Issue31CommandHandlingStatus::Accepted,
4809 handling_ref: "handling.omega.read_one".into(),
4810 reason_ref: None,
4811 source_event_id: Some("8".repeat(64)),
4812 }
4813 },
4814 )
4815 .expect("command v2")
4816 .expect("handling result");
4817 assert_eq!(v2_executions.get(), 1);
4818 assert!(matches!(
4819 v2_result,
4820 Issue31CommandRecordV2::CommandResult {
4821 status: Issue31CommandHandlingStatus::Accepted,
4822 source_event_id: Some(_),
4823 ..
4824 }
4825 ));
4826 assert_eq!(
4827 controller.active_grants(104).expect("active grants").len(),
4828 1
4829 );
4830 controller
4831 .record_source_projection(grant_ref.clone(), 1, "8".repeat(64))
4832 .expect("record projection");
4833 assert!(controller.source_was_projected(&grant_ref, 1, &"8".repeat(64)));
4834
4835 let renewal = controller
4836 .renew_grant(
4837 &grant_ref,
4838 vec![Issue31PairingScope::ControlFullAuto],
4839 105,
4840 2_000,
4841 )
4842 .expect("renewal");
4843 controller
4844 .record_emitted_pairing("f".repeat(64), renewal)
4845 .expect("record renewal");
4846 assert!(!controller.source_was_projected(&grant_ref, 2, &"8".repeat(64)));
4847 controller
4848 .record_source_projection(grant_ref.clone(), 2, "8".repeat(64))
4849 .expect("record renewed projection");
4850 assert!(controller.source_was_projected(&grant_ref, 2, &"8".repeat(64)));
4851 let revocation = controller
4852 .revoke_grant(&grant_ref, 106, Some("reason.omega.owner_revoked".into()))
4853 .expect("revocation");
4854 controller
4855 .record_emitted_pairing("0".repeat(64), revocation)
4856 .expect("record revocation");
4857 let state = fold_issue31_grant(&controller.pairing_events, &grant_ref)
4858 .expect("fold")
4859 .expect("state");
4860 assert_eq!(state.status, Issue31GrantStatus::Revoked);
4861 }
4862
4863 #[test]
4864 fn scoped_grant_requires_the_complete_pairing_chain() {
4865 let grant = Issue31PairingRecord::decode(include_bytes!(
4866 "../fixtures/openagents.omega.issue31.pairing.v1.canonical.json"
4867 ))
4868 .expect("grant fixture");
4869 let error = fold_issue31_grant(
4870 &[Issue31PairingEvent {
4871 event_id: "1".repeat(64),
4872 record: grant,
4873 }],
4874 "grant.omega.device_1",
4875 )
4876 .expect_err("unsolicited grant must fail");
4877 assert!(error.to_string().contains("pairing response"));
4878 }
4879
4880 #[test]
4881 fn runtime_admission_policy_rebind_does_not_change_durable_grants() {
4882 let (_, controller, device_public_key_hex, grant_ref) = restart_fixture();
4883 let projections = controller.grant_projections(107).expect("grant list");
4884 assert_eq!(projections.len(), 1);
4885 assert_eq!(projections[0].grant_ref, grant_ref);
4886 assert_eq!(projections[0].status, "revoked");
4887 let public_projection = serde_json::to_string(&projections).expect("projection json");
4888 assert!(!public_projection.contains(&device_public_key_hex));
4889 let encoded = serde_json::to_vec(&controller).expect("serialize controller");
4890 let mut reloaded: Issue31HostController =
4891 serde_json::from_slice(&encoded).expect("deserialize controller");
4892 assert!(reloaded.admitted_device_fingerprints().is_empty());
4893 let before = fold_issue31_grant(&reloaded.pairing_events, &grant_ref)
4894 .expect("fold before policy rebind")
4895 .expect("durable grant");
4896 reloaded
4897 .set_admitted_device_policy(
4898 vec!["4".repeat(64)],
4899 vec![Issue31PairingScope::ObserveIssue31],
4900 )
4901 .expect("rebind runtime policy");
4902 let after = fold_issue31_grant(&reloaded.pairing_events, &grant_ref)
4903 .expect("fold after policy rebind")
4904 .expect("durable grant");
4905 assert_eq!(before, after);
4906 }
4907
4908 #[test]
4909 fn controller_bounds_refuse_before_execution_or_mutation() {
4910 let host_public_key_hex = "1".repeat(64);
4911 let mut controller = Issue31HostController::new(Issue31HostConfiguration {
4912 host_ref: "omega.host.local".into(),
4913 host_public_key_hex: host_public_key_hex.clone(),
4914 sarah_public_key_hex: "3".repeat(64),
4915 conversation: "sarah.0123456789abcdef01234567".into(),
4916 display_name: "Local Omega".into(),
4917 relay_urls: vec!["wss://relay.example.com".into()],
4918 generation: 1,
4919 })
4920 .expect("controller");
4921 controller.processed_command_event_ids = (0..MAX_ISSUE31_PROCESSED_EVENTS)
4922 .map(|index| format!("{index:064x}"))
4923 .collect();
4924 let executed = Cell::new(false);
4925 let error = controller
4926 .handle_command_event(
4927 Issue31CommandEvent {
4928 event_id: "f".repeat(64),
4929 record: Issue31CommandRecord::CommandIntent {
4930 schema: ISSUE31_COMMAND_SCHEMA.into(),
4931 host_ref: "omega.host.local".into(),
4932 host_public_key_hex,
4933 device_public_key_hex: "2".repeat(64),
4934 grant_ref: "grant.omega.bound_test".into(),
4935 action_ref: "action.omega.full_auto.stop".into(),
4936 idempotency_ref: "idempotency.omega.bound_test".into(),
4937 expected_generation: 1,
4938 arguments_ref: "arguments.omega.none".into(),
4939 issued_at: 100,
4940 expires_at: 200,
4941 },
4942 },
4943 101,
4944 |_, _, _| {
4945 executed.set(true);
4946 Issue31CommandExecution {
4947 status: Issue31CommandStatus::Stopped,
4948 outcome_ref: "outcome.omega.stopped".into(),
4949 reason_ref: None,
4950 }
4951 },
4952 )
4953 .expect_err("processed event bound");
4954 assert!(error.to_string().contains("bound"));
4955 assert!(!executed.get());
4956 assert!(controller.command_results.is_empty());
4957
4958 controller.processed_command_event_ids.clear();
4959 controller.command_results = (0..MAX_ISSUE31_COMMAND_RESULTS)
4960 .map(|index| {
4961 let idempotency_ref = format!("idempotency.omega.bound_{index}");
4962 let intent = Issue31CommandRecord::CommandIntent {
4963 schema: ISSUE31_COMMAND_SCHEMA.into(),
4964 host_ref: "omega.host.local".into(),
4965 host_public_key_hex: "1".repeat(64),
4966 device_public_key_hex: "2".repeat(64),
4967 grant_ref: "grant.omega.bound_test".into(),
4968 action_ref: "action.omega.full_auto.stop".into(),
4969 idempotency_ref: idempotency_ref.clone(),
4970 expected_generation: 1,
4971 arguments_ref: "arguments.omega.none".into(),
4972 issued_at: 100,
4973 expires_at: 200,
4974 };
4975 let result = Issue31CommandRecord::CommandResult {
4976 schema: ISSUE31_COMMAND_SCHEMA.into(),
4977 host_ref: "omega.host.local".into(),
4978 host_public_key_hex: "1".repeat(64),
4979 device_public_key_hex: "2".repeat(64),
4980 grant_ref: "grant.omega.bound_test".into(),
4981 intent_event_id: format!("{index:064x}"),
4982 action_ref: "action.omega.full_auto.stop".into(),
4983 idempotency_ref: idempotency_ref.clone(),
4984 expected_generation: 1,
4985 status: Issue31CommandStatus::Unavailable,
4986 outcome_ref: "outcome.omega.unavailable".into(),
4987 reason_ref: Some("reason.omega.controller_not_bound".into()),
4988 completed_at: 101,
4989 };
4990 (idempotency_ref, (intent, result))
4991 })
4992 .collect();
4993 controller.processed_command_event_ids = (0..MAX_ISSUE31_COMMAND_RESULTS)
4994 .map(|index| format!("{index:064x}"))
4995 .collect();
4996 let command_result_count = controller.command_results.len();
4997 let executed = Cell::new(false);
4998 let error = controller
4999 .handle_command_event(
5000 Issue31CommandEvent {
5001 event_id: "f".repeat(64),
5002 record: Issue31CommandRecord::CommandIntent {
5003 schema: ISSUE31_COMMAND_SCHEMA.into(),
5004 host_ref: "omega.host.local".into(),
5005 host_public_key_hex: "1".repeat(64),
5006 device_public_key_hex: "2".repeat(64),
5007 grant_ref: "grant.omega.bound_test".into(),
5008 action_ref: "action.omega.full_auto.stop".into(),
5009 idempotency_ref: "idempotency.omega.bound_overflow".into(),
5010 expected_generation: 1,
5011 arguments_ref: "arguments.omega.none".into(),
5012 issued_at: 100,
5013 expires_at: 200,
5014 },
5015 },
5016 101,
5017 |_, _, _| {
5018 executed.set(true);
5019 Issue31CommandExecution {
5020 status: Issue31CommandStatus::Stopped,
5021 outcome_ref: "outcome.omega.stopped".into(),
5022 reason_ref: None,
5023 }
5024 },
5025 )
5026 .expect_err("command result bound");
5027 assert!(error.to_string().contains("bound"));
5028 assert!(!executed.get());
5029 assert_eq!(controller.command_results.len(), command_result_count);
5030 assert!(
5031 !controller
5032 .processed_command_event_ids
5033 .contains(&"f".repeat(64))
5034 );
5035 let encoded = serde_json::to_vec(&controller).expect("serialize bounded controller");
5036 let reloaded: Issue31HostController =
5037 serde_json::from_slice(&encoded).expect("reload bounded controller");
5038 reloaded
5039 .validate_persisted_state()
5040 .expect("bounded controller remains persistable");
5041
5042 controller.processed_pairing_event_ids = (0..MAX_ISSUE31_PROCESSED_EVENTS)
5043 .map(|index| format!("{index:064x}"))
5044 .collect();
5045 let error = controller
5046 .record_emitted_pairing(
5047 "e".repeat(64),
5048 Issue31PairingRecord::PairingChallenge {
5049 schema: ISSUE31_PAIRING_SCHEMA.into(),
5050 host_ref: "omega.host.local".into(),
5051 host_public_key_hex: "1".repeat(64),
5052 device_public_key_hex: "2".repeat(64),
5053 issued_at: 100,
5054 pairing_challenge_ref: "pairing_challenge.omega.bound_test".into(),
5055 pairing_request_event_id: "d".repeat(64),
5056 challenge: "3".repeat(64),
5057 expires_at: 200,
5058 },
5059 )
5060 .expect_err("pairing event bound");
5061 assert!(error.to_string().contains("bound"));
5062 assert!(controller.pairing_events.is_empty());
5063 }
5064}
5065