1use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19 decode_message, encode_message, parse_control, render_message, render_rows_segment,
20 ControlMessage,
21};
22use syncular_client::{
23 ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24 LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25 SyncClient, Transport, WindowBase, WindowCoverage,
26};
27
28pub fn bytes_to_hex(bytes: &[u8]) -> String {
31 syncular_client::values::bytes_to_hex(bytes)
32}
33
34pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
35 syncular_client::values::hex_to_bytes(hex)
36}
37
38pub fn bytes_value(bytes: &[u8]) -> Value {
39 json!({ "$bytes": bytes_to_hex(bytes) })
40}
41
42pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
43 let hex = value
44 .and_then(|v| v.get("$bytes"))
45 .and_then(Value::as_str)
46 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
47 hex_to_bytes(hex)
48}
49
50pub type CommandError = (String, String);
52
53#[derive(Debug, Default, Clone)]
57pub struct CreateEffects {
58 pub signed_urls: bool,
61 pub security_preflight_pending: bool,
77}
78
79fn client_err(message: String) -> CommandError {
80 let code = message
81 .split_once(':')
82 .map(|(candidate, _)| candidate)
83 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
84 .unwrap_or("client.failed");
85 (code.to_owned(), message)
86}
87
88fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
89 client
90 .as_mut()
91 .ok_or_else(|| client_err("no client instance created".to_owned()))
92}
93
94pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
95 let mut limits = ClientLimits::default();
96 let Some(object) = value.and_then(Value::as_object) else {
97 return limits;
98 };
99 limits.limit_commits = object
100 .get("limitCommits")
101 .and_then(Value::as_i64)
102 .map(|v| v as i32);
103 limits.limit_snapshot_rows = object
104 .get("limitSnapshotRows")
105 .and_then(Value::as_i64)
106 .map(|v| v as i32);
107 limits.max_snapshot_pages = object
108 .get("maxSnapshotPages")
109 .and_then(Value::as_i64)
110 .map(|v| v as i32);
111 limits.accept = object
112 .get("accept")
113 .and_then(Value::as_u64)
114 .map(|v| v as u8);
115 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
116 limits.outcome_retention_max_entries = object
117 .get("outcomeRetentionMaxEntries")
118 .and_then(Value::as_u64)
119 .map(|value| value as usize);
120 limits
121}
122
123pub fn parse_encryption(
127 value: &Value,
128) -> Result<syncular_client::values::EncryptionConfig, String> {
129 let mut config = syncular_client::values::EncryptionConfig::default();
130 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
131 return Ok(config);
132 };
133 for (key_id, key_val) in keys {
134 let bytes =
135 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
136 if bytes.len() != 32 {
137 return Err(format!(
138 "encryption key {key_id:?} must be 32 bytes, got {}",
139 bytes.len()
140 ));
141 }
142 config.keys.insert(key_id.clone(), bytes);
143 }
144 if let Some(columns) = value.get("keyIdColumns") {
145 let columns = columns
146 .as_object()
147 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
148 for (table, column) in columns {
149 let column = column.as_str().ok_or_else(|| {
150 format!("encryption keyIdColumns entry for {table:?} must be a string")
151 })?;
152 if column.is_empty() {
153 return Err(format!(
154 "encryption keyIdColumns entry for {table:?} must not be empty"
155 ));
156 }
157 config
158 .key_id_columns
159 .insert(table.clone(), column.to_owned());
160 }
161 }
162 Ok(config)
163}
164
165pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
170 let object = value.as_object().ok_or_else(|| {
171 "sync.invalid_request: headers must be an object of string values".to_owned()
172 })?;
173 let mut headers = Vec::with_capacity(object.len());
174 for (name, value) in object {
175 let value = value
176 .as_str()
177 .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
178 headers.push((name.clone(), value.to_owned()));
179 }
180 Ok(headers)
181}
182
183pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
184 let list = value
185 .and_then(Value::as_array)
186 .ok_or_else(|| "mutations must be a list".to_owned())?;
187 let mut out = Vec::with_capacity(list.len());
188 for entry in list {
189 let op = entry
190 .get("op")
191 .and_then(Value::as_str)
192 .ok_or_else(|| "mutation missing op".to_owned())?;
193 let table = entry
194 .get("table")
195 .and_then(Value::as_str)
196 .ok_or_else(|| "mutation missing table".to_owned())?
197 .to_owned();
198 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
199 match op {
200 "upsert" => {
201 let mut values = entry
202 .get("values")
203 .and_then(Value::as_object)
204 .cloned()
205 .ok_or_else(|| "upsert missing values".to_owned())?;
206 decode_bigint_members(&mut values)?;
207 out.push(Mutation::Upsert {
208 table,
209 values,
210 base_version,
211 });
212 }
213 "delete" => {
214 let row_id = entry
215 .get("rowId")
216 .and_then(Value::as_str)
217 .ok_or_else(|| "delete missing rowId".to_owned())?
218 .to_owned();
219 out.push(Mutation::Delete {
220 table,
221 row_id,
222 base_version,
223 });
224 }
225 other => return Err(format!("unknown mutation op {other:?}")),
226 }
227 }
228 Ok(out)
229}
230
231fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
232 for value in values.values_mut() {
233 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
234 continue;
235 };
236 let integer = decimal
237 .parse::<i64>()
238 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
239 *value = Value::from(integer);
240 }
241 Ok(())
242}
243
244fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
245 match value {
246 Some(v) => syncular_client::values::json_to_scope_map(v),
247 None => Ok(Vec::new()),
248 }
249}
250
251fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
254 let object = value
255 .and_then(Value::as_object)
256 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
257 let table = object
258 .get("table")
259 .and_then(Value::as_str)
260 .ok_or_else(|| "window base missing table".to_owned())?
261 .to_owned();
262 let variable = object
263 .get("variable")
264 .and_then(Value::as_str)
265 .ok_or_else(|| "window base missing variable".to_owned())?
266 .to_owned();
267 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
268 let params = object
269 .get("params")
270 .and_then(Value::as_str)
271 .map(str::to_owned);
272 Ok(WindowBase {
273 table,
274 variable,
275 fixed_scopes,
276 params,
277 })
278}
279
280#[cfg(feature = "crdt-yjs")]
284fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
285 let table = params
286 .get("table")
287 .and_then(Value::as_str)
288 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
289 .to_owned();
290 let row_id = params
291 .get("rowId")
292 .and_then(Value::as_str)
293 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
294 .to_owned();
295 let column = params
296 .get("column")
297 .and_then(Value::as_str)
298 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
299 .to_owned();
300 let name = params
301 .get("name")
302 .and_then(Value::as_str)
303 .unwrap_or("text")
304 .to_owned();
305 Ok((table, row_id, column, name))
306}
307
308pub fn dispatch<T: Transport>(
318 transport: &mut T,
319 client: &mut Option<SyncClient>,
320 effects: &mut CreateEffects,
321 method: &str,
322 params: &Value,
323) -> Result<Value, CommandError> {
324 if method == "create" {
325 let requests_preflight = params
332 .get("securityPreflight")
333 .and_then(Value::as_bool)
334 .unwrap_or(false);
335 let preflight_engaged = client.as_ref().map_or(
336 effects.security_preflight_pending,
337 SyncClient::security_preflight,
338 );
339 if preflight_engaged && !requests_preflight {
340 return Err((
341 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
342 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
343 ));
344 }
345 } else {
346 let allowed_during_preflight = matches!(
347 method,
348 "securityLifecycle"
349 | "beginSecurityPreflight"
350 | "activateSecurity"
351 | "purgeLocalData"
352 | "localRevision"
353 | "statusSnapshot"
354 | "shutdown"
355 );
356 if client
357 .as_ref()
358 .is_some_and(|running| running.security_preflight())
359 && !allowed_during_preflight
360 {
361 return Err((
362 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
363 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
364 ));
365 }
366 }
367 match method {
368 "create" => {
369 let client_id = params
370 .get("clientId")
371 .and_then(Value::as_str)
372 .map(str::to_owned);
373 let schema = params
374 .get("schema")
375 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
376 let limits = parse_limits(params.get("limits"));
377 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
381 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
382 .map_err(client_err)?,
383 None => {
384 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
385 }
386 };
387 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
389 instance.set_now_ms(now_ms);
390 }
391 effects.signed_urls = params
394 .get("signedUrls")
395 .and_then(Value::as_bool)
396 .unwrap_or(false);
397 let security_preflight = params
401 .get("securityPreflight")
402 .and_then(Value::as_bool)
403 .unwrap_or(false);
404 if instance.security_preflight() && !security_preflight {
412 return Err((
413 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
414 "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
415 ));
416 }
417 if security_preflight && params.get("encryption").is_some() {
418 return Err(client_err(
419 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
420 .to_owned(),
421 ));
422 }
423 if let Some(enc) = params.get("encryption") {
424 let config = parse_encryption(enc).map_err(client_err)?;
425 instance.set_encryption(config);
426 }
427 if security_preflight {
428 instance.begin_security_preflight();
429 }
430 effects.security_preflight_pending = security_preflight;
431 *client = Some(instance);
432 Ok(json!({}))
433 }
434 "securityLifecycle" => Ok(json!({
435 "state": need_client(client)?.security_lifecycle()
436 })),
437 "beginSecurityPreflight" => {
438 let running = need_client(client)?;
439 running.disconnect_realtime(transport);
440 running.begin_security_preflight();
441 effects.security_preflight_pending = true;
442 Ok(json!({}))
443 }
444 "activateSecurity" => {
445 let encryption = match params.get("encryption") {
446 Some(value) => parse_encryption(value).map_err(client_err)?,
447 None => syncular_client::values::EncryptionConfig::default(),
448 };
449 if let Some(headers) = params.get("headers") {
455 parse_headers(headers).map_err(client_err)?;
456 }
457 need_client(client)?
458 .activate_security(encryption)
459 .map_err(client_err)?;
460 effects.security_preflight_pending = false;
461 Ok(json!({}))
462 }
463 "setHeaders" => {
464 let headers = params
465 .get("headers")
466 .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
467 parse_headers(headers).map_err(client_err)?;
468 Ok(json!({}))
469 }
470 "shutdown" => {
471 if let Some(running) = client.as_mut() {
472 effects.security_preflight_pending = running.security_preflight();
477 running.disconnect_realtime(transport);
478 running.seal_security_on_teardown();
482 }
483 *client = None;
484 Ok(json!({}))
485 }
486 "subscribe" => {
487 let id = params
488 .get("id")
489 .and_then(Value::as_str)
490 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
491 .to_owned();
492 let table = params
493 .get("table")
494 .and_then(Value::as_str)
495 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
496 .to_owned();
497 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
498 let sub_params = params
499 .get("params")
500 .and_then(Value::as_str)
501 .map(str::to_owned);
502 need_client(client)?
503 .subscribe(id, table, scopes, sub_params)
504 .map_err(client_err)?;
505 Ok(json!({}))
506 }
507 "unsubscribe" => {
508 let id = params
509 .get("id")
510 .and_then(Value::as_str)
511 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
512 need_client(client)?.unsubscribe(id);
513 Ok(json!({}))
514 }
515 "setWindow" => {
516 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
517 let units: Vec<String> = params
518 .get("units")
519 .and_then(Value::as_array)
520 .map(|arr| {
521 arr.iter()
522 .filter_map(|v| v.as_str().map(str::to_owned))
523 .collect()
524 })
525 .unwrap_or_default();
526 let command_effects = need_client(client)?
527 .set_window(&base, &units)
528 .map_err(client_err)?;
529 Ok(json!({ "effects": command_effects }))
530 }
531 "windowState" => {
532 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
533 let state = need_client(client)?.window_state(&base);
534 Ok(json!({ "units": state.units, "pending": state.pending }))
535 }
536 "timeWindowSugar" => {
537 let created_at_ms = params
538 .get("createdAtMs")
539 .and_then(Value::as_i64)
540 .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
541 let count = params
542 .get("count")
543 .and_then(Value::as_u64)
544 .and_then(|value| usize::try_from(value).ok())
545 .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
546 let now_ms = params
547 .get("nowMs")
548 .and_then(Value::as_i64)
549 .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
550 if params.get("unit").and_then(Value::as_str) != Some("month") {
551 return Err(client_err(
552 "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
553 ));
554 }
555 let unit = syncular_client::TimeBucketUnit::Month;
556 Ok(json!({
557 "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
558 .map_err(client_err)?,
559 "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
560 }))
561 }
562 "mutate" => {
563 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
564 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
565 Ok(json!({
566 "clientCommitId": id,
567 "effects": CommandEffects::interactive()
568 }))
569 }
570 "patch" => {
571 let table = params
572 .get("table")
573 .and_then(Value::as_str)
574 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
575 let row_id = params
576 .get("rowId")
577 .and_then(Value::as_str)
578 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
579 let mut partial = params
580 .get("partial")
581 .and_then(Value::as_object)
582 .cloned()
583 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
584 decode_bigint_members(&mut partial).map_err(client_err)?;
585 let base_version = params.get("baseVersion").and_then(Value::as_i64);
586 let id = need_client(client)?
587 .patch(table, row_id, partial, base_version)
588 .map_err(client_err)?;
589 Ok(json!({
590 "clientCommitId": id,
591 "effects": CommandEffects::interactive()
592 }))
593 }
594 "purgeLocalData" => {
595 let input: LocalDataPurgeInput =
596 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
597 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
598 })?)
599 .map_err(|error| {
600 client_err(format!(
601 "sync.invalid_request: invalid purgeLocalData input: {error}"
602 ))
603 })?;
604 let result = need_client(client)?
605 .purge_local_data(&input)
606 .map_err(client_err)?;
607 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
608 }
609 "rebootstrapLocalData" => {
610 let input: LocalDataRebootstrapInput =
611 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
612 client_err(
613 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
614 )
615 })?)
616 .map_err(|error| {
617 client_err(format!(
618 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
619 ))
620 })?;
621 let result = need_client(client)?
622 .rebootstrap_local_data(&input)
623 .map_err(client_err)?;
624 Ok(json!({
625 "alreadyApplied": result.already_applied,
626 "retainedCommits": result.retained_commits,
627 "resetSubscriptions": result.reset_subscriptions,
628 "effects": if result.already_applied {
629 CommandEffects::none()
630 } else {
631 CommandEffects::interactive()
632 }
633 }))
634 }
635 "sync" => {
636 let outcome = need_client(client)?.sync(transport);
637 Ok(outcome.to_json())
638 }
639 "syncUntilIdle" => {
640 let max_rounds = params
641 .get("maxRounds")
642 .and_then(Value::as_u64)
643 .map(|v| v as u32);
644 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
645 Ok(outcome.to_json())
646 }
647 "readRows" => {
648 let table = params
649 .get("table")
650 .and_then(Value::as_str)
651 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
652 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
653 Ok(json!({ "rows": rows }))
654 }
655 "query" => {
656 let sql = params
660 .get("sql")
661 .and_then(Value::as_str)
662 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
663 let bind = match params.get("params") {
664 Some(Value::Array(list)) => list.clone(),
665 None | Some(Value::Null) => Vec::new(),
666 Some(_) => return Err(client_err("query params must be a list".to_owned())),
667 };
668 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
669 Ok(json!({ "rows": rows }))
670 }
671 "querySnapshot" => {
672 let sql = params
673 .get("sql")
674 .and_then(Value::as_str)
675 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
676 let bind = match params.get("params") {
677 Some(Value::Array(list)) => list.clone(),
678 None | Some(Value::Null) => Vec::new(),
679 Some(_) => {
680 return Err(client_err("querySnapshot params must be a list".to_owned()))
681 }
682 };
683 let mut coverage = Vec::new();
684 for entry in params
685 .get("coverage")
686 .and_then(Value::as_array)
687 .into_iter()
688 .flatten()
689 {
690 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
691 let units = entry
692 .get("units")
693 .and_then(Value::as_array)
694 .map(|values| {
695 values
696 .iter()
697 .filter_map(|value| value.as_str().map(str::to_owned))
698 .collect()
699 })
700 .unwrap_or_default();
701 coverage.push(WindowCoverage { base, units });
702 }
703 let snapshot = need_client(client)?
704 .query_snapshot(sql, &bind, &coverage)
705 .map_err(client_err)?;
706 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
707 }
708 "localRevision" => Ok(json!({
709 "revision": need_client(client)?.local_revision().to_string()
710 })),
711 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
712 .expect("status serializes")),
713 "diagnosticsSnapshot" => {
714 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
715 .map_err(|error| {
716 client_err(format!(
717 "sync.invalid_request: invalid diagnostics request: {error}"
718 ))
719 })?;
720 let snapshot = need_client(client)?
721 .diagnostics_snapshot(&request)
722 .map_err(client_err)?;
723 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
724 }
725 "drainChangeBatches" => Ok(json!({
730 "batches": need_client(client)?.drain_change_batches()
731 })),
732 "drainSyncIntents" => Ok(json!({
733 "intents": need_client(client)?.drain_sync_intents()
734 })),
735 #[cfg(feature = "crdt-yjs")]
741 "crdtText" => {
742 let (table, row_id, column, name) = crdt_target(params)?;
743 let text = need_client(client)?
744 .crdt_text(&table, &row_id, &column, &name)
745 .map_err(client_err)?;
746 Ok(json!({ "text": text }))
747 }
748 #[cfg(feature = "crdt-yjs")]
749 "crdtInsertText" => {
750 let (table, row_id, column, name) = crdt_target(params)?;
751 let index = params
752 .get("index")
753 .and_then(Value::as_u64)
754 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
755 as u32;
756 let value = params
757 .get("value")
758 .and_then(Value::as_str)
759 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
760 let id = need_client(client)?
761 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
762 .map_err(client_err)?;
763 Ok(json!({ "clientCommitId": id }))
764 }
765 #[cfg(feature = "crdt-yjs")]
766 "crdtDeleteText" => {
767 let (table, row_id, column, name) = crdt_target(params)?;
768 let index = params
769 .get("index")
770 .and_then(Value::as_u64)
771 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
772 as u32;
773 let len = params
774 .get("len")
775 .and_then(Value::as_u64)
776 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
777 as u32;
778 let id = need_client(client)?
779 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
780 .map_err(client_err)?;
781 Ok(json!({ "clientCommitId": id }))
782 }
783 #[cfg(feature = "crdt-yjs")]
784 "crdtApplyUpdate" => {
785 let (table, row_id, column, _name) = crdt_target(params)?;
786 let update = value_bytes(params.get("update")).map_err(client_err)?;
787 let id = need_client(client)?
788 .crdt_apply_update(&table, &row_id, &column, &update)
789 .map_err(client_err)?;
790 Ok(json!({ "clientCommitId": id }))
791 }
792 #[cfg(not(feature = "crdt-yjs"))]
793 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
794 "client.crdt_unavailable".to_owned(),
795 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
796 )),
797
798 "uploadBlob" => {
799 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
800 let media_type = params
801 .get("mediaType")
802 .and_then(Value::as_str)
803 .map(str::to_owned);
804 let name = params
805 .get("name")
806 .and_then(Value::as_str)
807 .map(str::to_owned);
808 let reference = need_client(client)?
809 .upload_blob(&bytes, media_type, name)
810 .map_err(client_err)?;
811 Ok(json!({ "ref": reference }))
812 }
813 "fetchBlob" => {
814 let blob = params
815 .get("blob")
816 .and_then(Value::as_str)
817 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
818 .to_owned();
819 let value = need_client(client)?.fetch_blob(transport, &blob)?;
822 Ok(json!({ "blob": value }))
823 }
824 "conflicts" => {
825 let conflicts = need_client(client)?.conflicts().to_vec();
826 Ok(json!({ "conflicts": conflicts }))
827 }
828 "rejections" => {
829 let rejections = need_client(client)?.rejections().to_vec();
830 Ok(json!({ "rejections": rejections }))
831 }
832 "commitOutcome" => {
833 let client_commit_id = params
834 .get("clientCommitId")
835 .and_then(Value::as_str)
836 .ok_or_else(|| {
837 client_err(
838 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
839 )
840 })?;
841 let outcome = need_client(client)?
842 .commit_outcome(client_commit_id)
843 .map_err(client_err)?;
844 Ok(json!({ "outcome": outcome }))
845 }
846 "commitOutcomes" => {
847 let query = serde_json::from_value::<CommitOutcomeQuery>(
848 params.get("query").cloned().unwrap_or_else(|| json!({})),
849 )
850 .map_err(|error| {
851 client_err(format!(
852 "sync.invalid_request: invalid commit outcome query: {error}"
853 ))
854 })?;
855 let outcomes = need_client(client)?
856 .commit_outcomes(query)
857 .map_err(client_err)?;
858 Ok(json!({ "outcomes": outcomes }))
859 }
860 "resolveCommitOutcome" => {
861 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
862 params.get("input").cloned().ok_or_else(|| {
863 client_err(
864 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
865 )
866 })?,
867 )
868 .map_err(|error| {
869 client_err(format!(
870 "sync.invalid_request: invalid outcome resolution: {error}"
871 ))
872 })?;
873 let outcome = need_client(client)?
874 .resolve_commit_outcome(input)
875 .map_err(client_err)?;
876 Ok(json!({ "outcome": outcome }))
877 }
878 "pendingCommitIds" => {
879 let ids = need_client(client)?.pending_commit_ids();
880 Ok(json!({ "ids": ids }))
881 }
882 "subscriptionState" => {
883 let id = params
884 .get("id")
885 .and_then(Value::as_str)
886 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
887 let state = need_client(client)?.subscription_state(id);
888 Ok(json!({ "state": state }))
889 }
890 "schemaFloor" => {
891 let floor = need_client(client)?.schema_floor().cloned();
892 Ok(json!({ "floor": floor }))
893 }
894 "leaseState" => {
895 let lease = need_client(client)?.lease_state().cloned();
896 Ok(json!({ "lease": lease }))
897 }
898 "upgrading" => {
899 let value = need_client(client)?.upgrading();
901 Ok(json!({ "value": value }))
902 }
903 "recreateWithSchema" => {
904 let schema = params
908 .get("schema")
909 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
910 need_client(client)?
911 .recreate_with_schema(schema)
912 .map_err(client_err)?;
913 Ok(json!({}))
914 }
915 "connectRealtime" => {
916 need_client(client)?
917 .connect_realtime(transport)
918 .map_err(client_err)?;
919 Ok(json!({}))
920 }
921 "disconnectRealtime" => {
922 need_client(client)?.disconnect_realtime(transport);
923 Ok(json!({}))
924 }
925 "syncNeeded" => {
926 let value = need_client(client)?.sync_needed();
927 Ok(json!({ "value": value }))
928 }
929 "setPresence" => {
930 let scope_key = params
931 .get("scopeKey")
932 .and_then(Value::as_str)
933 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
934 .to_owned();
935 let doc = params.get("doc");
937 let doc_ref = match doc {
938 None | Some(Value::Null) => None,
939 Some(v) => Some(v),
940 };
941 need_client(client)?
942 .set_presence(transport, &scope_key, doc_ref)
943 .map_err(client_err)?;
944 Ok(json!({}))
945 }
946 "presence" => {
947 let scope_key = params
948 .get("scopeKey")
949 .and_then(Value::as_str)
950 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
951 let peers = need_client(client)?.presence(scope_key);
952 Ok(json!({ "peers": peers }))
953 }
954
955 "messageRoundtrip" => {
957 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
958 match decode_message(&bytes) {
959 Ok(message) => Ok(json!({
960 "ok": true,
961 "bytes": bytes_value(&encode_message(&message)),
962 "renderedJson": render_message(&message).to_string(),
963 })),
964 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
965 }
966 }
967 "segmentRoundtrip" => {
968 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
969 match decode_rows_segment(&bytes) {
970 Ok(segment) => Ok(json!({
971 "ok": true,
972 "bytes": bytes_value(&encode_rows_segment(&segment)),
973 "renderedJson": render_rows_segment(&segment).to_string(),
974 })),
975 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
976 }
977 }
978 "realtimeKnown" => {
979 let text = params
980 .get("text")
981 .and_then(Value::as_str)
982 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
983 let known = matches!(
984 parse_control(text),
985 Ok(ControlMessage::Hello { .. })
986 | Ok(ControlMessage::Wake { .. })
987 | Ok(ControlMessage::Heartbeat { .. })
988 | Ok(ControlMessage::Presence { .. })
989 );
990 Ok(json!({ "value": known }))
991 }
992
993 other => Err(client_err(format!("unknown method {other:?}"))),
994 }
995}
996
997#[cfg(test)]
998mod tests {
999 use serde_json::{json, Value};
1000 use syncular_client::{
1001 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
1002 };
1003
1004 use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
1005
1006 #[derive(Default)]
1007 struct NoNetwork {
1008 realtime_connects: usize,
1009 realtime_closes: usize,
1010 }
1011
1012 impl Transport for NoNetwork {
1013 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1014 Err(TransportError::new("sync.transport_failed", "offline"))
1015 }
1016
1017 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1018 Err(TransportError::new("sync.transport_failed", "offline"))
1019 }
1020
1021 fn download_segment(
1022 &mut self,
1023 _request: &SegmentRequest,
1024 ) -> Result<Vec<u8>, TransportError> {
1025 Err(TransportError::new("sync.transport_failed", "offline"))
1026 }
1027
1028 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1029 self.realtime_connects += 1;
1030 Ok(())
1031 }
1032
1033 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1034 Ok(())
1035 }
1036
1037 fn realtime_close(&mut self) -> Result<(), TransportError> {
1038 self.realtime_closes += 1;
1039 Ok(())
1040 }
1041 }
1042
1043 fn schema() -> Value {
1044 json!({
1045 "version": 1,
1046 "tables": [{
1047 "name": "todos",
1048 "columns": [
1049 { "name": "id", "type": "string", "nullable": false },
1050 { "name": "list_id", "type": "string", "nullable": false }
1051 ],
1052 "primaryKey": "id",
1053 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1054 }]
1055 })
1056 }
1057
1058 #[test]
1059 fn native_command_realtime_connection_is_idempotent() {
1060 let mut transport = NoNetwork::default();
1061 let mut client: Option<SyncClient> = None;
1062 let mut effects = CreateEffects::default();
1063 dispatch(
1064 &mut transport,
1065 &mut client,
1066 &mut effects,
1067 "create",
1068 &json!({ "schema": schema() }),
1069 )
1070 .expect("create client");
1071
1072 for _ in 0..2 {
1073 dispatch(
1074 &mut transport,
1075 &mut client,
1076 &mut effects,
1077 "connectRealtime",
1078 &json!({}),
1079 )
1080 .expect("idempotent connect command");
1081 }
1082 assert_eq!(transport.realtime_connects, 1);
1083
1084 dispatch(
1085 &mut transport,
1086 &mut client,
1087 &mut effects,
1088 "disconnectRealtime",
1089 &json!({}),
1090 )
1091 .expect("disconnect command");
1092 dispatch(
1093 &mut transport,
1094 &mut client,
1095 &mut effects,
1096 "disconnectRealtime",
1097 &json!({}),
1098 )
1099 .expect("idempotent disconnect command");
1100 assert_eq!(transport.realtime_closes, 1);
1101 dispatch(
1102 &mut transport,
1103 &mut client,
1104 &mut effects,
1105 "connectRealtime",
1106 &json!({}),
1107 )
1108 .expect("deliberate reconnect command");
1109 assert_eq!(transport.realtime_connects, 2);
1110 }
1111
1112 #[test]
1113 fn parses_portable_encryption_keyring_and_key_id_columns() {
1114 let key_hex = "2a".repeat(32);
1115 let config = parse_encryption(&json!({
1116 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1117 "keyIdColumns": { "patients": "encryption_key_id" }
1118 }))
1119 .expect("portable keyring parses");
1120 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1121 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1122 }
1123
1124 #[test]
1125 fn rejects_non_string_key_id_columns() {
1126 let error = parse_encryption(&json!({
1127 "keys": {},
1128 "keyIdColumns": { "patients": 7 }
1129 }))
1130 .expect_err("invalid selector must fail");
1131 assert!(error.contains("must be a string"), "{error}");
1132 }
1133
1134 #[test]
1135 fn native_command_hosts_reject_subscription_identity_rebinds() {
1136 let mut transport = NoNetwork::default();
1137 let mut client: Option<SyncClient> = None;
1138 let mut effects = CreateEffects::default();
1139 dispatch(
1140 &mut transport,
1141 &mut client,
1142 &mut effects,
1143 "create",
1144 &json!({ "schema": schema() }),
1145 )
1146 .expect("create client");
1147
1148 let original = json!({
1149 "id": "stable-subscription",
1150 "table": "todos",
1151 "scopes": { "list_id": ["list-2", "list-1"] },
1152 "params": "{\"view\":\"v1\"}"
1153 });
1154 dispatch(
1155 &mut transport,
1156 &mut client,
1157 &mut effects,
1158 "subscribe",
1159 &original,
1160 )
1161 .expect("register subscription");
1162 dispatch(
1163 &mut transport,
1164 &mut client,
1165 &mut effects,
1166 "subscribe",
1167 &json!({
1168 "id": "stable-subscription",
1169 "table": "todos",
1170 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1171 "params": "{\"view\":\"v1\"}"
1172 }),
1173 )
1174 .expect("canonical re-declaration is idempotent");
1175
1176 let error = dispatch(
1177 &mut transport,
1178 &mut client,
1179 &mut effects,
1180 "subscribe",
1181 &json!({
1182 "id": "stable-subscription",
1183 "table": "todos",
1184 "scopes": { "list_id": ["list-2"] },
1185 "params": "{\"view\":\"v1\"}"
1186 }),
1187 )
1188 .expect_err("changed native query identity must fail");
1189 assert_eq!(error.0, "client.subscription_intent_mismatch");
1190
1191 let state = dispatch(
1192 &mut transport,
1193 &mut client,
1194 &mut effects,
1195 "subscriptionState",
1196 &json!({ "id": "stable-subscription" }),
1197 )
1198 .expect("subscription state");
1199 assert_eq!(state["state"]["cursor"], -1);
1200 assert_eq!(state["state"]["table"], "todos");
1201 }
1202
1203 #[test]
1204 fn security_preflight_is_fail_closed_until_exact_activation() {
1205 let mut transport = NoNetwork::default();
1206 let mut client: Option<SyncClient> = None;
1207 let mut effects = CreateEffects::default();
1208 dispatch(
1209 &mut transport,
1210 &mut client,
1211 &mut effects,
1212 "create",
1213 &json!({ "schema": schema(), "securityPreflight": true }),
1214 )
1215 .expect("preflight create");
1216
1217 let lifecycle = dispatch(
1218 &mut transport,
1219 &mut client,
1220 &mut effects,
1221 "securityLifecycle",
1222 &json!({}),
1223 )
1224 .expect("lifecycle");
1225 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1226
1227 let query_error = dispatch(
1228 &mut transport,
1229 &mut client,
1230 &mut effects,
1231 "query",
1232 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1233 )
1234 .expect_err("protected query must fail");
1235 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1236 let diagnostics_error = dispatch(
1237 &mut transport,
1238 &mut client,
1239 &mut effects,
1240 "diagnosticsSnapshot",
1241 &json!({}),
1242 )
1243 .expect_err("diagnostics table/subscription evidence remains protected");
1244 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1245
1246 dispatch(
1247 &mut transport,
1248 &mut client,
1249 &mut effects,
1250 "purgeLocalData",
1251 &json!({
1252 "input": {
1253 "purgeId": "directive-1",
1254 "targets": [{
1255 "table": "todos",
1256 "selectors": { "list_id": ["list-1"] }
1257 }]
1258 }
1259 }),
1260 )
1261 .expect("authorized local purge remains available");
1262
1263 let repair_error = dispatch(
1264 &mut transport,
1265 &mut client,
1266 &mut effects,
1267 "rebootstrapLocalData",
1268 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1269 )
1270 .expect_err("projection repair must remain protected during preflight");
1271 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1272
1273 dispatch(
1274 &mut transport,
1275 &mut client,
1276 &mut effects,
1277 "activateSecurity",
1278 &json!({}),
1279 )
1280 .expect("activation");
1281 let rows = dispatch(
1282 &mut transport,
1283 &mut client,
1284 &mut effects,
1285 "query",
1286 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1287 )
1288 .expect("active query");
1289 assert_eq!(rows, json!({ "rows": [] }));
1290
1291 dispatch(
1292 &mut transport,
1293 &mut client,
1294 &mut effects,
1295 "beginSecurityPreflight",
1296 &json!({}),
1297 )
1298 .expect("re-enter preflight");
1299 let blocked = dispatch(
1300 &mut transport,
1301 &mut client,
1302 &mut effects,
1303 "mutate",
1304 &json!({ "mutations": [] }),
1305 )
1306 .expect_err("mutation must be gated");
1307 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1308 }
1309
1310 #[test]
1311 fn preflight_refuses_a_replacement_create_without_the_flag() {
1312 let mut transport = NoNetwork::default();
1313 let mut client: Option<SyncClient> = None;
1314 let mut effects = CreateEffects::default();
1315 dispatch(
1316 &mut transport,
1317 &mut client,
1318 &mut effects,
1319 "create",
1320 &json!({ "schema": schema(), "securityPreflight": true }),
1321 )
1322 .expect("preflight create");
1323
1324 let escape = dispatch(
1326 &mut transport,
1327 &mut client,
1328 &mut effects,
1329 "create",
1330 &json!({ "schema": schema() }),
1331 )
1332 .expect_err("plain create must be refused during preflight");
1333 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1334 let query_error = dispatch(
1336 &mut transport,
1337 &mut client,
1338 &mut effects,
1339 "query",
1340 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1341 )
1342 .expect_err("protected query stays gated");
1343 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1344
1345 dispatch(
1347 &mut transport,
1348 &mut client,
1349 &mut effects,
1350 "create",
1351 &json!({ "schema": schema(), "securityPreflight": true }),
1352 )
1353 .expect("preflighted replacement create");
1354 let still_gated = dispatch(
1355 &mut transport,
1356 &mut client,
1357 &mut effects,
1358 "query",
1359 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1360 )
1361 .expect_err("replacement stays gated");
1362 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1363
1364 dispatch(
1366 &mut transport,
1367 &mut client,
1368 &mut effects,
1369 "activateSecurity",
1370 &json!({}),
1371 )
1372 .expect("activation");
1373 dispatch(
1374 &mut transport,
1375 &mut client,
1376 &mut effects,
1377 "create",
1378 &json!({ "schema": schema() }),
1379 )
1380 .expect("plain create after activation");
1381 let rows = dispatch(
1382 &mut transport,
1383 &mut client,
1384 &mut effects,
1385 "query",
1386 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1387 )
1388 .expect("active query");
1389 assert_eq!(rows, json!({ "rows": [] }));
1390 }
1391
1392 fn temp_db_path(tag: &str) -> String {
1395 std::env::temp_dir()
1396 .join(format!(
1397 "syncular-command-{tag}-{}-{:?}.db",
1398 std::process::id(),
1399 std::time::SystemTime::now()
1400 .duration_since(std::time::UNIX_EPOCH)
1401 .expect("clock")
1402 .as_nanos()
1403 ))
1404 .to_string_lossy()
1405 .into_owned()
1406 }
1407
1408 #[test]
1409 fn an_activated_replica_reopens_plainly_after_shutdown() {
1410 let path = temp_db_path("activated-reopen");
1416 let mut transport = NoNetwork::default();
1417 let mut client: Option<SyncClient> = None;
1418 let mut effects = CreateEffects::default();
1419
1420 dispatch(
1421 &mut transport,
1422 &mut client,
1423 &mut effects,
1424 "create",
1425 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1426 )
1427 .expect("preflight create");
1428 dispatch(
1429 &mut transport,
1430 &mut client,
1431 &mut effects,
1432 "activateSecurity",
1433 &json!({}),
1434 )
1435 .expect("activation clears the gate");
1436 dispatch(
1437 &mut transport,
1438 &mut client,
1439 &mut effects,
1440 "shutdown",
1441 &json!({}),
1442 )
1443 .expect("shutdown after activation");
1444
1445 dispatch(
1447 &mut transport,
1448 &mut client,
1449 &mut effects,
1450 "create",
1451 &json!({ "schema": schema(), "dbPath": path }),
1452 )
1453 .expect("an activated replica must reopen plainly after shutdown");
1454 let state = dispatch(
1455 &mut transport,
1456 &mut client,
1457 &mut effects,
1458 "securityLifecycle",
1459 &json!({}),
1460 )
1461 .expect("lifecycle");
1462 assert_eq!(state, json!({ "state": "active" }));
1463
1464 let _ = std::fs::remove_file(&path);
1465 }
1466
1467 #[test]
1468 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1469 let path = temp_db_path("quarantined-reopen");
1474 let mut transport = NoNetwork::default();
1475 let mut client: Option<SyncClient> = None;
1476 let mut effects = CreateEffects::default();
1477
1478 dispatch(
1479 &mut transport,
1480 &mut client,
1481 &mut effects,
1482 "create",
1483 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1484 )
1485 .expect("preflight create");
1486 dispatch(
1487 &mut transport,
1488 &mut client,
1489 &mut effects,
1490 "shutdown",
1491 &json!({}),
1492 )
1493 .expect("shutdown during preflight");
1494
1495 let mut rebuilt = CreateEffects::default();
1498 let escape = dispatch(
1499 &mut transport,
1500 &mut client,
1501 &mut rebuilt,
1502 "create",
1503 &json!({ "schema": schema(), "dbPath": path }),
1504 )
1505 .expect_err("a quarantined replica must refuse a plain re-create");
1506 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1507
1508 let _ = std::fs::remove_file(&path);
1509 }
1510
1511 #[test]
1512 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1513 let mut transport = NoNetwork::default();
1514 let mut client: Option<SyncClient> = None;
1515 let mut effects = CreateEffects::default();
1516 dispatch(
1517 &mut transport,
1518 &mut client,
1519 &mut effects,
1520 "create",
1521 &json!({ "schema": schema(), "securityPreflight": true }),
1522 )
1523 .expect("preflight create");
1524 dispatch(
1525 &mut transport,
1526 &mut client,
1527 &mut effects,
1528 "shutdown",
1529 &json!({}),
1530 )
1531 .expect("shutdown during preflight");
1532 assert!(client.is_none());
1533
1534 let escape = dispatch(
1536 &mut transport,
1537 &mut client,
1538 &mut effects,
1539 "create",
1540 &json!({ "schema": schema() }),
1541 )
1542 .expect_err("shutdown + plain create must stay refused");
1543 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1544
1545 dispatch(
1546 &mut transport,
1547 &mut client,
1548 &mut effects,
1549 "create",
1550 &json!({ "schema": schema(), "securityPreflight": true }),
1551 )
1552 .expect("preflighted re-create");
1553 dispatch(
1554 &mut transport,
1555 &mut client,
1556 &mut effects,
1557 "activateSecurity",
1558 &json!({}),
1559 )
1560 .expect("activation");
1561
1562 dispatch(
1564 &mut transport,
1565 &mut client,
1566 &mut effects,
1567 "shutdown",
1568 &json!({}),
1569 )
1570 .expect("shutdown after activation");
1571 dispatch(
1572 &mut transport,
1573 &mut client,
1574 &mut effects,
1575 "create",
1576 &json!({ "schema": schema() }),
1577 )
1578 .expect("plain create after an activated shutdown");
1579 }
1580
1581 #[test]
1582 fn activate_security_validates_optional_headers_atomically() {
1583 let mut transport = NoNetwork::default();
1584 let mut client: Option<SyncClient> = None;
1585 let mut effects = CreateEffects::default();
1586 dispatch(
1587 &mut transport,
1588 &mut client,
1589 &mut effects,
1590 "create",
1591 &json!({ "schema": schema(), "securityPreflight": true }),
1592 )
1593 .expect("preflight create");
1594
1595 let invalid = dispatch(
1597 &mut transport,
1598 &mut client,
1599 &mut effects,
1600 "activateSecurity",
1601 &json!({ "headers": { "authorization": 7 } }),
1602 )
1603 .expect_err("non-string header must fail");
1604 assert_eq!(invalid.0, "sync.invalid_request");
1605 let lifecycle = dispatch(
1606 &mut transport,
1607 &mut client,
1608 &mut effects,
1609 "securityLifecycle",
1610 &json!({}),
1611 )
1612 .expect("lifecycle");
1613 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1614
1615 dispatch(
1617 &mut transport,
1618 &mut client,
1619 &mut effects,
1620 "activateSecurity",
1621 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1622 )
1623 .expect("activation with fresh headers");
1624 let lifecycle = dispatch(
1625 &mut transport,
1626 &mut client,
1627 &mut effects,
1628 "securityLifecycle",
1629 &json!({}),
1630 )
1631 .expect("lifecycle");
1632 assert_eq!(lifecycle, json!({ "state": "active" }));
1633 }
1634
1635 #[test]
1636 fn parse_headers_reads_the_full_replacement_set() {
1637 let parsed = parse_headers(&json!({
1638 "authorization": "Bearer fresh",
1639 "x-tenant": "t1"
1640 }))
1641 .expect("valid headers parse");
1642 assert_eq!(
1643 parsed,
1644 vec![
1645 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1646 ("x-tenant".to_owned(), "t1".to_owned())
1647 ]
1648 );
1649 assert!(parse_headers(&json!(["authorization"])).is_err());
1650 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1651 }
1652
1653 #[test]
1654 fn set_headers_validates_replacement_and_respects_preflight() {
1655 let mut transport = NoNetwork::default();
1656 let mut client: Option<SyncClient> = None;
1657 let mut effects = CreateEffects::default();
1658 dispatch(
1659 &mut transport,
1660 &mut client,
1661 &mut effects,
1662 "create",
1663 &json!({ "schema": schema() }),
1664 )
1665 .expect("create");
1666 dispatch(
1667 &mut transport,
1668 &mut client,
1669 &mut effects,
1670 "setHeaders",
1671 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1672 )
1673 .expect("valid replacement");
1674 let invalid = dispatch(
1675 &mut transport,
1676 &mut client,
1677 &mut effects,
1678 "setHeaders",
1679 &json!({ "headers": { "authorization": 7 } }),
1680 )
1681 .expect_err("invalid replacement");
1682 assert_eq!(invalid.0, "sync.invalid_request");
1683
1684 dispatch(
1685 &mut transport,
1686 &mut client,
1687 &mut effects,
1688 "beginSecurityPreflight",
1689 &json!({}),
1690 )
1691 .expect("preflight");
1692 let gated = dispatch(
1693 &mut transport,
1694 &mut client,
1695 &mut effects,
1696 "setHeaders",
1697 &json!({ "headers": {} }),
1698 )
1699 .expect_err("direct rotation must respect preflight");
1700 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1701 }
1702}