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 "pendingPayloads" => {
907 let payloads = need_client(client)?.pending_payloads();
908 Ok(json!({
909 "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
910 }))
911 }
912 "subscriptionState" => {
913 let id = params
914 .get("id")
915 .and_then(Value::as_str)
916 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
917 let state = need_client(client)?.subscription_state(id);
918 Ok(json!({ "state": state }))
919 }
920 "recreateWithSchema" => {
921 let schema = params
925 .get("schema")
926 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
927 need_client(client)?
928 .recreate_with_schema(schema)
929 .map_err(client_err)?;
930 Ok(json!({}))
931 }
932 "connectRealtime" => {
933 need_client(client)?
934 .connect_realtime(transport)
935 .map_err(client_err)?;
936 Ok(json!({}))
937 }
938 "disconnectRealtime" => {
939 need_client(client)?.disconnect_realtime(transport);
940 Ok(json!({}))
941 }
942 "setPresence" => {
943 let scope_key = params
944 .get("scopeKey")
945 .and_then(Value::as_str)
946 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
947 .to_owned();
948 let doc = params.get("doc");
950 let doc_ref = match doc {
951 None | Some(Value::Null) => None,
952 Some(v) => Some(v),
953 };
954 need_client(client)?
955 .set_presence(transport, &scope_key, doc_ref)
956 .map_err(client_err)?;
957 Ok(json!({}))
958 }
959 "presence" => {
960 let scope_key = params
961 .get("scopeKey")
962 .and_then(Value::as_str)
963 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
964 let peers = need_client(client)?.presence(scope_key);
965 Ok(json!({ "peers": peers }))
966 }
967
968 "messageRoundtrip" => {
970 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
971 match decode_message(&bytes) {
972 Ok(message) => Ok(json!({
973 "ok": true,
974 "bytes": bytes_value(&encode_message(&message)),
975 "renderedJson": render_message(&message).to_string(),
976 })),
977 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
978 }
979 }
980 "segmentRoundtrip" => {
981 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
982 match decode_rows_segment(&bytes) {
983 Ok(segment) => Ok(json!({
984 "ok": true,
985 "bytes": bytes_value(&encode_rows_segment(&segment)),
986 "renderedJson": render_rows_segment(&segment).to_string(),
987 })),
988 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
989 }
990 }
991 "realtimeKnown" => {
992 let text = params
993 .get("text")
994 .and_then(Value::as_str)
995 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
996 let known = matches!(
997 parse_control(text),
998 Ok(ControlMessage::Hello { .. })
999 | Ok(ControlMessage::Wake { .. })
1000 | Ok(ControlMessage::Heartbeat { .. })
1001 | Ok(ControlMessage::Presence { .. })
1002 );
1003 Ok(json!({ "value": known }))
1004 }
1005
1006 other => Err(client_err(format!("unknown method {other:?}"))),
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use serde_json::{json, Value};
1013 use syncular_client::{
1014 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
1015 };
1016
1017 use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
1018
1019 #[derive(Default)]
1020 struct NoNetwork {
1021 realtime_connects: usize,
1022 realtime_closes: usize,
1023 }
1024
1025 impl Transport for NoNetwork {
1026 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1027 Err(TransportError::new("sync.transport_failed", "offline"))
1028 }
1029
1030 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1031 Err(TransportError::new("sync.transport_failed", "offline"))
1032 }
1033
1034 fn download_segment(
1035 &mut self,
1036 _request: &SegmentRequest,
1037 _on_progress: &mut dyn FnMut(u64),
1038 ) -> Result<Vec<u8>, TransportError> {
1039 Err(TransportError::new("sync.transport_failed", "offline"))
1040 }
1041
1042 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1043 self.realtime_connects += 1;
1044 Ok(())
1045 }
1046
1047 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1048 Ok(())
1049 }
1050
1051 fn realtime_close(&mut self) -> Result<(), TransportError> {
1052 self.realtime_closes += 1;
1053 Ok(())
1054 }
1055 }
1056
1057 fn schema() -> Value {
1058 json!({
1059 "version": 1,
1060 "tables": [{
1061 "name": "todos",
1062 "columns": [
1063 { "name": "id", "type": "string", "nullable": false },
1064 { "name": "list_id", "type": "string", "nullable": false }
1065 ],
1066 "primaryKey": "id",
1067 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1068 }]
1069 })
1070 }
1071
1072 #[test]
1073 fn native_command_realtime_connection_is_idempotent() {
1074 let mut transport = NoNetwork::default();
1075 let mut client: Option<SyncClient> = None;
1076 let mut effects = CreateEffects::default();
1077 dispatch(
1078 &mut transport,
1079 &mut client,
1080 &mut effects,
1081 "create",
1082 &json!({ "schema": schema() }),
1083 )
1084 .expect("create client");
1085
1086 for _ in 0..2 {
1087 dispatch(
1088 &mut transport,
1089 &mut client,
1090 &mut effects,
1091 "connectRealtime",
1092 &json!({}),
1093 )
1094 .expect("idempotent connect command");
1095 }
1096 assert_eq!(transport.realtime_connects, 1);
1097
1098 dispatch(
1099 &mut transport,
1100 &mut client,
1101 &mut effects,
1102 "disconnectRealtime",
1103 &json!({}),
1104 )
1105 .expect("disconnect command");
1106 dispatch(
1107 &mut transport,
1108 &mut client,
1109 &mut effects,
1110 "disconnectRealtime",
1111 &json!({}),
1112 )
1113 .expect("idempotent disconnect command");
1114 assert_eq!(transport.realtime_closes, 1);
1115 dispatch(
1116 &mut transport,
1117 &mut client,
1118 &mut effects,
1119 "connectRealtime",
1120 &json!({}),
1121 )
1122 .expect("deliberate reconnect command");
1123 assert_eq!(transport.realtime_connects, 2);
1124 }
1125
1126 #[test]
1127 fn parses_portable_encryption_keyring_and_key_id_columns() {
1128 let key_hex = "2a".repeat(32);
1129 let config = parse_encryption(&json!({
1130 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1131 "keyIdColumns": { "patients": "encryption_key_id" }
1132 }))
1133 .expect("portable keyring parses");
1134 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1135 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1136 }
1137
1138 #[test]
1139 fn rejects_non_string_key_id_columns() {
1140 let error = parse_encryption(&json!({
1141 "keys": {},
1142 "keyIdColumns": { "patients": 7 }
1143 }))
1144 .expect_err("invalid selector must fail");
1145 assert!(error.contains("must be a string"), "{error}");
1146 }
1147
1148 #[test]
1149 fn native_command_hosts_reject_subscription_identity_rebinds() {
1150 let mut transport = NoNetwork::default();
1151 let mut client: Option<SyncClient> = None;
1152 let mut effects = CreateEffects::default();
1153 dispatch(
1154 &mut transport,
1155 &mut client,
1156 &mut effects,
1157 "create",
1158 &json!({ "schema": schema() }),
1159 )
1160 .expect("create client");
1161
1162 let original = json!({
1163 "id": "stable-subscription",
1164 "table": "todos",
1165 "scopes": { "list_id": ["list-2", "list-1"] },
1166 "params": "{\"view\":\"v1\"}"
1167 });
1168 dispatch(
1169 &mut transport,
1170 &mut client,
1171 &mut effects,
1172 "subscribe",
1173 &original,
1174 )
1175 .expect("register subscription");
1176 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-1", "list-2", "list-1"] },
1185 "params": "{\"view\":\"v1\"}"
1186 }),
1187 )
1188 .expect("canonical re-declaration is idempotent");
1189
1190 let error = dispatch(
1191 &mut transport,
1192 &mut client,
1193 &mut effects,
1194 "subscribe",
1195 &json!({
1196 "id": "stable-subscription",
1197 "table": "todos",
1198 "scopes": { "list_id": ["list-2"] },
1199 "params": "{\"view\":\"v1\"}"
1200 }),
1201 )
1202 .expect_err("changed native query identity must fail");
1203 assert_eq!(error.0, "client.subscription_intent_mismatch");
1204
1205 let state = dispatch(
1206 &mut transport,
1207 &mut client,
1208 &mut effects,
1209 "subscriptionState",
1210 &json!({ "id": "stable-subscription" }),
1211 )
1212 .expect("subscription state");
1213 assert_eq!(state["state"]["cursor"], -1);
1214 assert_eq!(state["state"]["table"], "todos");
1215 }
1216
1217 #[test]
1218 fn security_preflight_is_fail_closed_until_exact_activation() {
1219 let mut transport = NoNetwork::default();
1220 let mut client: Option<SyncClient> = None;
1221 let mut effects = CreateEffects::default();
1222 dispatch(
1223 &mut transport,
1224 &mut client,
1225 &mut effects,
1226 "create",
1227 &json!({ "schema": schema(), "securityPreflight": true }),
1228 )
1229 .expect("preflight create");
1230
1231 let lifecycle = dispatch(
1232 &mut transport,
1233 &mut client,
1234 &mut effects,
1235 "securityLifecycle",
1236 &json!({}),
1237 )
1238 .expect("lifecycle");
1239 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1240
1241 let query_error = dispatch(
1242 &mut transport,
1243 &mut client,
1244 &mut effects,
1245 "query",
1246 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1247 )
1248 .expect_err("protected query must fail");
1249 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1250 let diagnostics_error = dispatch(
1251 &mut transport,
1252 &mut client,
1253 &mut effects,
1254 "diagnosticsSnapshot",
1255 &json!({}),
1256 )
1257 .expect_err("diagnostics table/subscription evidence remains protected");
1258 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1259
1260 dispatch(
1261 &mut transport,
1262 &mut client,
1263 &mut effects,
1264 "purgeLocalData",
1265 &json!({
1266 "input": {
1267 "purgeId": "directive-1",
1268 "targets": [{
1269 "table": "todos",
1270 "selectors": { "list_id": ["list-1"] }
1271 }]
1272 }
1273 }),
1274 )
1275 .expect("authorized local purge remains available");
1276
1277 let repair_error = dispatch(
1278 &mut transport,
1279 &mut client,
1280 &mut effects,
1281 "rebootstrapLocalData",
1282 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1283 )
1284 .expect_err("projection repair must remain protected during preflight");
1285 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1286
1287 dispatch(
1288 &mut transport,
1289 &mut client,
1290 &mut effects,
1291 "activateSecurity",
1292 &json!({}),
1293 )
1294 .expect("activation");
1295 let rows = dispatch(
1296 &mut transport,
1297 &mut client,
1298 &mut effects,
1299 "query",
1300 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1301 )
1302 .expect("active query");
1303 assert_eq!(rows, json!({ "rows": [] }));
1304
1305 dispatch(
1306 &mut transport,
1307 &mut client,
1308 &mut effects,
1309 "beginSecurityPreflight",
1310 &json!({}),
1311 )
1312 .expect("re-enter preflight");
1313 let blocked = dispatch(
1314 &mut transport,
1315 &mut client,
1316 &mut effects,
1317 "mutate",
1318 &json!({ "mutations": [] }),
1319 )
1320 .expect_err("mutation must be gated");
1321 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1322 }
1323
1324 #[test]
1325 fn preflight_refuses_a_replacement_create_without_the_flag() {
1326 let mut transport = NoNetwork::default();
1327 let mut client: Option<SyncClient> = None;
1328 let mut effects = CreateEffects::default();
1329 dispatch(
1330 &mut transport,
1331 &mut client,
1332 &mut effects,
1333 "create",
1334 &json!({ "schema": schema(), "securityPreflight": true }),
1335 )
1336 .expect("preflight create");
1337
1338 let escape = dispatch(
1340 &mut transport,
1341 &mut client,
1342 &mut effects,
1343 "create",
1344 &json!({ "schema": schema() }),
1345 )
1346 .expect_err("plain create must be refused during preflight");
1347 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1348 let query_error = dispatch(
1350 &mut transport,
1351 &mut client,
1352 &mut effects,
1353 "query",
1354 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1355 )
1356 .expect_err("protected query stays gated");
1357 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1358
1359 dispatch(
1361 &mut transport,
1362 &mut client,
1363 &mut effects,
1364 "create",
1365 &json!({ "schema": schema(), "securityPreflight": true }),
1366 )
1367 .expect("preflighted replacement create");
1368 let still_gated = dispatch(
1369 &mut transport,
1370 &mut client,
1371 &mut effects,
1372 "query",
1373 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1374 )
1375 .expect_err("replacement stays gated");
1376 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1377
1378 dispatch(
1380 &mut transport,
1381 &mut client,
1382 &mut effects,
1383 "activateSecurity",
1384 &json!({}),
1385 )
1386 .expect("activation");
1387 dispatch(
1388 &mut transport,
1389 &mut client,
1390 &mut effects,
1391 "create",
1392 &json!({ "schema": schema() }),
1393 )
1394 .expect("plain create after activation");
1395 let rows = dispatch(
1396 &mut transport,
1397 &mut client,
1398 &mut effects,
1399 "query",
1400 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1401 )
1402 .expect("active query");
1403 assert_eq!(rows, json!({ "rows": [] }));
1404 }
1405
1406 fn temp_db_path(tag: &str) -> String {
1409 std::env::temp_dir()
1410 .join(format!(
1411 "syncular-command-{tag}-{}-{:?}.db",
1412 std::process::id(),
1413 std::time::SystemTime::now()
1414 .duration_since(std::time::UNIX_EPOCH)
1415 .expect("clock")
1416 .as_nanos()
1417 ))
1418 .to_string_lossy()
1419 .into_owned()
1420 }
1421
1422 #[test]
1423 fn an_activated_replica_reopens_plainly_after_shutdown() {
1424 let path = temp_db_path("activated-reopen");
1430 let mut transport = NoNetwork::default();
1431 let mut client: Option<SyncClient> = None;
1432 let mut effects = CreateEffects::default();
1433
1434 dispatch(
1435 &mut transport,
1436 &mut client,
1437 &mut effects,
1438 "create",
1439 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1440 )
1441 .expect("preflight create");
1442 dispatch(
1443 &mut transport,
1444 &mut client,
1445 &mut effects,
1446 "activateSecurity",
1447 &json!({}),
1448 )
1449 .expect("activation clears the gate");
1450 dispatch(
1451 &mut transport,
1452 &mut client,
1453 &mut effects,
1454 "shutdown",
1455 &json!({}),
1456 )
1457 .expect("shutdown after activation");
1458
1459 dispatch(
1461 &mut transport,
1462 &mut client,
1463 &mut effects,
1464 "create",
1465 &json!({ "schema": schema(), "dbPath": path }),
1466 )
1467 .expect("an activated replica must reopen plainly after shutdown");
1468 let state = dispatch(
1469 &mut transport,
1470 &mut client,
1471 &mut effects,
1472 "securityLifecycle",
1473 &json!({}),
1474 )
1475 .expect("lifecycle");
1476 assert_eq!(state, json!({ "state": "active" }));
1477
1478 let _ = std::fs::remove_file(&path);
1479 }
1480
1481 #[test]
1482 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1483 let path = temp_db_path("quarantined-reopen");
1488 let mut transport = NoNetwork::default();
1489 let mut client: Option<SyncClient> = None;
1490 let mut effects = CreateEffects::default();
1491
1492 dispatch(
1493 &mut transport,
1494 &mut client,
1495 &mut effects,
1496 "create",
1497 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1498 )
1499 .expect("preflight create");
1500 dispatch(
1501 &mut transport,
1502 &mut client,
1503 &mut effects,
1504 "shutdown",
1505 &json!({}),
1506 )
1507 .expect("shutdown during preflight");
1508
1509 let mut rebuilt = CreateEffects::default();
1512 let escape = dispatch(
1513 &mut transport,
1514 &mut client,
1515 &mut rebuilt,
1516 "create",
1517 &json!({ "schema": schema(), "dbPath": path }),
1518 )
1519 .expect_err("a quarantined replica must refuse a plain re-create");
1520 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1521
1522 let _ = std::fs::remove_file(&path);
1523 }
1524
1525 #[test]
1526 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1527 let mut transport = NoNetwork::default();
1528 let mut client: Option<SyncClient> = None;
1529 let mut effects = CreateEffects::default();
1530 dispatch(
1531 &mut transport,
1532 &mut client,
1533 &mut effects,
1534 "create",
1535 &json!({ "schema": schema(), "securityPreflight": true }),
1536 )
1537 .expect("preflight create");
1538 dispatch(
1539 &mut transport,
1540 &mut client,
1541 &mut effects,
1542 "shutdown",
1543 &json!({}),
1544 )
1545 .expect("shutdown during preflight");
1546 assert!(client.is_none());
1547
1548 let escape = dispatch(
1550 &mut transport,
1551 &mut client,
1552 &mut effects,
1553 "create",
1554 &json!({ "schema": schema() }),
1555 )
1556 .expect_err("shutdown + plain create must stay refused");
1557 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1558
1559 dispatch(
1560 &mut transport,
1561 &mut client,
1562 &mut effects,
1563 "create",
1564 &json!({ "schema": schema(), "securityPreflight": true }),
1565 )
1566 .expect("preflighted re-create");
1567 dispatch(
1568 &mut transport,
1569 &mut client,
1570 &mut effects,
1571 "activateSecurity",
1572 &json!({}),
1573 )
1574 .expect("activation");
1575
1576 dispatch(
1578 &mut transport,
1579 &mut client,
1580 &mut effects,
1581 "shutdown",
1582 &json!({}),
1583 )
1584 .expect("shutdown after activation");
1585 dispatch(
1586 &mut transport,
1587 &mut client,
1588 &mut effects,
1589 "create",
1590 &json!({ "schema": schema() }),
1591 )
1592 .expect("plain create after an activated shutdown");
1593 }
1594
1595 #[test]
1596 fn activate_security_validates_optional_headers_atomically() {
1597 let mut transport = NoNetwork::default();
1598 let mut client: Option<SyncClient> = None;
1599 let mut effects = CreateEffects::default();
1600 dispatch(
1601 &mut transport,
1602 &mut client,
1603 &mut effects,
1604 "create",
1605 &json!({ "schema": schema(), "securityPreflight": true }),
1606 )
1607 .expect("preflight create");
1608
1609 let invalid = dispatch(
1611 &mut transport,
1612 &mut client,
1613 &mut effects,
1614 "activateSecurity",
1615 &json!({ "headers": { "authorization": 7 } }),
1616 )
1617 .expect_err("non-string header must fail");
1618 assert_eq!(invalid.0, "sync.invalid_request");
1619 let lifecycle = dispatch(
1620 &mut transport,
1621 &mut client,
1622 &mut effects,
1623 "securityLifecycle",
1624 &json!({}),
1625 )
1626 .expect("lifecycle");
1627 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1628
1629 dispatch(
1631 &mut transport,
1632 &mut client,
1633 &mut effects,
1634 "activateSecurity",
1635 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1636 )
1637 .expect("activation with fresh headers");
1638 let lifecycle = dispatch(
1639 &mut transport,
1640 &mut client,
1641 &mut effects,
1642 "securityLifecycle",
1643 &json!({}),
1644 )
1645 .expect("lifecycle");
1646 assert_eq!(lifecycle, json!({ "state": "active" }));
1647 }
1648
1649 #[test]
1650 fn parse_headers_reads_the_full_replacement_set() {
1651 let parsed = parse_headers(&json!({
1652 "authorization": "Bearer fresh",
1653 "x-tenant": "t1"
1654 }))
1655 .expect("valid headers parse");
1656 assert_eq!(
1657 parsed,
1658 vec![
1659 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1660 ("x-tenant".to_owned(), "t1".to_owned())
1661 ]
1662 );
1663 assert!(parse_headers(&json!(["authorization"])).is_err());
1664 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1665 }
1666
1667 #[test]
1668 fn set_headers_validates_replacement_and_respects_preflight() {
1669 let mut transport = NoNetwork::default();
1670 let mut client: Option<SyncClient> = None;
1671 let mut effects = CreateEffects::default();
1672 dispatch(
1673 &mut transport,
1674 &mut client,
1675 &mut effects,
1676 "create",
1677 &json!({ "schema": schema() }),
1678 )
1679 .expect("create");
1680 dispatch(
1681 &mut transport,
1682 &mut client,
1683 &mut effects,
1684 "setHeaders",
1685 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1686 )
1687 .expect("valid replacement");
1688 let invalid = dispatch(
1689 &mut transport,
1690 &mut client,
1691 &mut effects,
1692 "setHeaders",
1693 &json!({ "headers": { "authorization": 7 } }),
1694 )
1695 .expect_err("invalid replacement");
1696 assert_eq!(invalid.0, "sync.invalid_request");
1697
1698 dispatch(
1699 &mut transport,
1700 &mut client,
1701 &mut effects,
1702 "beginSecurityPreflight",
1703 &json!({}),
1704 )
1705 .expect("preflight");
1706 let gated = dispatch(
1707 &mut transport,
1708 &mut client,
1709 &mut effects,
1710 "setHeaders",
1711 &json!({ "headers": {} }),
1712 )
1713 .expect_err("direct rotation must respect preflight");
1714 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1715 }
1716}