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