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 "drainChangeBatches" => Ok(json!({
739 "batches": need_client(client)?.drain_change_batches()
740 })),
741 "drainSyncIntents" => Ok(json!({
742 "intents": need_client(client)?.drain_sync_intents()
743 })),
744 #[cfg(feature = "crdt-yjs")]
750 "crdtText" => {
751 let (table, row_id, column, name) = crdt_target(params)?;
752 let text = need_client(client)?
753 .crdt_text(&table, &row_id, &column, &name)
754 .map_err(client_err)?;
755 Ok(json!({ "text": text }))
756 }
757 #[cfg(feature = "crdt-yjs")]
758 "crdtInsertText" => {
759 let (table, row_id, column, name) = crdt_target(params)?;
760 let index = params
761 .get("index")
762 .and_then(Value::as_u64)
763 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
764 as u32;
765 let value = params
766 .get("value")
767 .and_then(Value::as_str)
768 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
769 let id = need_client(client)?
770 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
771 .map_err(client_err)?;
772 Ok(json!({ "clientCommitId": id }))
773 }
774 #[cfg(feature = "crdt-yjs")]
775 "crdtDeleteText" => {
776 let (table, row_id, column, name) = crdt_target(params)?;
777 let index = params
778 .get("index")
779 .and_then(Value::as_u64)
780 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
781 as u32;
782 let len = params
783 .get("len")
784 .and_then(Value::as_u64)
785 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
786 as u32;
787 let id = need_client(client)?
788 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
789 .map_err(client_err)?;
790 Ok(json!({ "clientCommitId": id }))
791 }
792 #[cfg(feature = "crdt-yjs")]
793 "crdtApplyUpdate" => {
794 let (table, row_id, column, _name) = crdt_target(params)?;
795 let update = value_bytes(params.get("update")).map_err(client_err)?;
796 let id = need_client(client)?
797 .crdt_apply_update(&table, &row_id, &column, &update)
798 .map_err(client_err)?;
799 Ok(json!({ "clientCommitId": id }))
800 }
801 #[cfg(not(feature = "crdt-yjs"))]
802 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
803 "client.crdt_unavailable".to_owned(),
804 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
805 )),
806
807 "uploadBlob" => {
808 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
809 let media_type = params
810 .get("mediaType")
811 .and_then(Value::as_str)
812 .map(str::to_owned);
813 let name = params
814 .get("name")
815 .and_then(Value::as_str)
816 .map(str::to_owned);
817 let reference = need_client(client)?
818 .upload_blob(&bytes, media_type, name)
819 .map_err(client_err)?;
820 Ok(json!({ "ref": reference }))
821 }
822 "fetchBlob" => {
823 let blob = params
824 .get("blob")
825 .and_then(Value::as_str)
826 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
827 .to_owned();
828 let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
832 let mut value = serde_json::Map::new();
833 value.insert("blobId".to_owned(), Value::from(blob.blob_id));
834 value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
835 value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
836 if let Some(media_type) = blob.media_type {
837 value.insert("mediaType".to_owned(), Value::from(media_type));
838 }
839 Ok(Value::Object(serde_json::Map::from_iter([(
840 "blob".to_owned(),
841 Value::Object(value),
842 )])))
843 }
844 "conflicts" => {
845 let conflicts = need_client(client)?.conflicts().to_vec();
846 Ok(json!({ "conflicts": conflicts }))
847 }
848 "rejections" => {
849 let rejections = need_client(client)?.rejections().to_vec();
850 Ok(json!({ "rejections": rejections }))
851 }
852 "commitOutcome" => {
853 let client_commit_id = params
854 .get("clientCommitId")
855 .and_then(Value::as_str)
856 .ok_or_else(|| {
857 client_err(
858 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
859 )
860 })?;
861 let outcome = need_client(client)?
862 .commit_outcome(client_commit_id)
863 .map_err(client_err)?;
864 Ok(json!({ "outcome": outcome }))
865 }
866 "commitOutcomes" => {
867 let query = serde_json::from_value::<CommitOutcomeQuery>(
868 params.get("query").cloned().unwrap_or_else(|| json!({})),
869 )
870 .map_err(|error| {
871 client_err(format!(
872 "sync.invalid_request: invalid commit outcome query: {error}"
873 ))
874 })?;
875 let outcomes = need_client(client)?
876 .commit_outcomes(query)
877 .map_err(client_err)?;
878 Ok(json!({ "outcomes": outcomes }))
879 }
880 "resolveCommitOutcome" => {
881 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
882 params.get("input").cloned().ok_or_else(|| {
883 client_err(
884 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
885 )
886 })?,
887 )
888 .map_err(|error| {
889 client_err(format!(
890 "sync.invalid_request: invalid outcome resolution: {error}"
891 ))
892 })?;
893 let outcome = need_client(client)?
894 .resolve_commit_outcome(input)
895 .map_err(client_err)?;
896 Ok(json!({ "outcome": outcome }))
897 }
898 "pendingCommitIds" => {
899 let ids = need_client(client)?.pending_commit_ids();
900 Ok(json!({ "ids": ids }))
901 }
902 "subscriptionState" => {
903 let id = params
904 .get("id")
905 .and_then(Value::as_str)
906 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
907 let state = need_client(client)?.subscription_state(id);
908 Ok(json!({ "state": state }))
909 }
910 "recreateWithSchema" => {
911 let schema = params
915 .get("schema")
916 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
917 need_client(client)?
918 .recreate_with_schema(schema)
919 .map_err(client_err)?;
920 Ok(json!({}))
921 }
922 "connectRealtime" => {
923 need_client(client)?
924 .connect_realtime(transport)
925 .map_err(client_err)?;
926 Ok(json!({}))
927 }
928 "disconnectRealtime" => {
929 need_client(client)?.disconnect_realtime(transport);
930 Ok(json!({}))
931 }
932 "setPresence" => {
933 let scope_key = params
934 .get("scopeKey")
935 .and_then(Value::as_str)
936 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
937 .to_owned();
938 let doc = params.get("doc");
940 let doc_ref = match doc {
941 None | Some(Value::Null) => None,
942 Some(v) => Some(v),
943 };
944 need_client(client)?
945 .set_presence(transport, &scope_key, doc_ref)
946 .map_err(client_err)?;
947 Ok(json!({}))
948 }
949 "presence" => {
950 let scope_key = params
951 .get("scopeKey")
952 .and_then(Value::as_str)
953 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
954 let peers = need_client(client)?.presence(scope_key);
955 Ok(json!({ "peers": peers }))
956 }
957
958 "messageRoundtrip" => {
960 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
961 match decode_message(&bytes) {
962 Ok(message) => Ok(json!({
963 "ok": true,
964 "bytes": bytes_value(&encode_message(&message)),
965 "renderedJson": render_message(&message).to_string(),
966 })),
967 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
968 }
969 }
970 "segmentRoundtrip" => {
971 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
972 match decode_rows_segment(&bytes) {
973 Ok(segment) => Ok(json!({
974 "ok": true,
975 "bytes": bytes_value(&encode_rows_segment(&segment)),
976 "renderedJson": render_rows_segment(&segment).to_string(),
977 })),
978 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
979 }
980 }
981 "realtimeKnown" => {
982 let text = params
983 .get("text")
984 .and_then(Value::as_str)
985 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
986 let known = matches!(
987 parse_control(text),
988 Ok(ControlMessage::Hello { .. })
989 | Ok(ControlMessage::Wake { .. })
990 | Ok(ControlMessage::Heartbeat { .. })
991 | Ok(ControlMessage::Presence { .. })
992 );
993 Ok(json!({ "value": known }))
994 }
995
996 other => Err(client_err(format!("unknown method {other:?}"))),
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use serde_json::{json, Value};
1003 use syncular_client::{
1004 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
1005 };
1006
1007 use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
1008
1009 #[derive(Default)]
1010 struct NoNetwork {
1011 realtime_connects: usize,
1012 realtime_closes: usize,
1013 }
1014
1015 impl Transport for NoNetwork {
1016 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1017 Err(TransportError::new("sync.transport_failed", "offline"))
1018 }
1019
1020 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1021 Err(TransportError::new("sync.transport_failed", "offline"))
1022 }
1023
1024 fn download_segment(
1025 &mut self,
1026 _request: &SegmentRequest,
1027 ) -> Result<Vec<u8>, TransportError> {
1028 Err(TransportError::new("sync.transport_failed", "offline"))
1029 }
1030
1031 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1032 self.realtime_connects += 1;
1033 Ok(())
1034 }
1035
1036 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1037 Ok(())
1038 }
1039
1040 fn realtime_close(&mut self) -> Result<(), TransportError> {
1041 self.realtime_closes += 1;
1042 Ok(())
1043 }
1044 }
1045
1046 fn schema() -> Value {
1047 json!({
1048 "version": 1,
1049 "tables": [{
1050 "name": "todos",
1051 "columns": [
1052 { "name": "id", "type": "string", "nullable": false },
1053 { "name": "list_id", "type": "string", "nullable": false }
1054 ],
1055 "primaryKey": "id",
1056 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1057 }]
1058 })
1059 }
1060
1061 #[test]
1062 fn native_command_realtime_connection_is_idempotent() {
1063 let mut transport = NoNetwork::default();
1064 let mut client: Option<SyncClient> = None;
1065 let mut effects = CreateEffects::default();
1066 dispatch(
1067 &mut transport,
1068 &mut client,
1069 &mut effects,
1070 "create",
1071 &json!({ "schema": schema() }),
1072 )
1073 .expect("create client");
1074
1075 for _ in 0..2 {
1076 dispatch(
1077 &mut transport,
1078 &mut client,
1079 &mut effects,
1080 "connectRealtime",
1081 &json!({}),
1082 )
1083 .expect("idempotent connect command");
1084 }
1085 assert_eq!(transport.realtime_connects, 1);
1086
1087 dispatch(
1088 &mut transport,
1089 &mut client,
1090 &mut effects,
1091 "disconnectRealtime",
1092 &json!({}),
1093 )
1094 .expect("disconnect command");
1095 dispatch(
1096 &mut transport,
1097 &mut client,
1098 &mut effects,
1099 "disconnectRealtime",
1100 &json!({}),
1101 )
1102 .expect("idempotent disconnect command");
1103 assert_eq!(transport.realtime_closes, 1);
1104 dispatch(
1105 &mut transport,
1106 &mut client,
1107 &mut effects,
1108 "connectRealtime",
1109 &json!({}),
1110 )
1111 .expect("deliberate reconnect command");
1112 assert_eq!(transport.realtime_connects, 2);
1113 }
1114
1115 #[test]
1116 fn parses_portable_encryption_keyring_and_key_id_columns() {
1117 let key_hex = "2a".repeat(32);
1118 let config = parse_encryption(&json!({
1119 "keys": { "practice-key-v1": { "$bytes": key_hex } },
1120 "keyIdColumns": { "patients": "encryption_key_id" }
1121 }))
1122 .expect("portable keyring parses");
1123 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1124 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1125 }
1126
1127 #[test]
1128 fn rejects_non_string_key_id_columns() {
1129 let error = parse_encryption(&json!({
1130 "keys": {},
1131 "keyIdColumns": { "patients": 7 }
1132 }))
1133 .expect_err("invalid selector must fail");
1134 assert!(error.contains("must be a string"), "{error}");
1135 }
1136
1137 #[test]
1138 fn native_command_hosts_reject_subscription_identity_rebinds() {
1139 let mut transport = NoNetwork::default();
1140 let mut client: Option<SyncClient> = None;
1141 let mut effects = CreateEffects::default();
1142 dispatch(
1143 &mut transport,
1144 &mut client,
1145 &mut effects,
1146 "create",
1147 &json!({ "schema": schema() }),
1148 )
1149 .expect("create client");
1150
1151 let original = json!({
1152 "id": "stable-subscription",
1153 "table": "todos",
1154 "scopes": { "list_id": ["list-2", "list-1"] },
1155 "params": "{\"view\":\"v1\"}"
1156 });
1157 dispatch(
1158 &mut transport,
1159 &mut client,
1160 &mut effects,
1161 "subscribe",
1162 &original,
1163 )
1164 .expect("register subscription");
1165 dispatch(
1166 &mut transport,
1167 &mut client,
1168 &mut effects,
1169 "subscribe",
1170 &json!({
1171 "id": "stable-subscription",
1172 "table": "todos",
1173 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1174 "params": "{\"view\":\"v1\"}"
1175 }),
1176 )
1177 .expect("canonical re-declaration is idempotent");
1178
1179 let error = dispatch(
1180 &mut transport,
1181 &mut client,
1182 &mut effects,
1183 "subscribe",
1184 &json!({
1185 "id": "stable-subscription",
1186 "table": "todos",
1187 "scopes": { "list_id": ["list-2"] },
1188 "params": "{\"view\":\"v1\"}"
1189 }),
1190 )
1191 .expect_err("changed native query identity must fail");
1192 assert_eq!(error.0, "client.subscription_intent_mismatch");
1193
1194 let state = dispatch(
1195 &mut transport,
1196 &mut client,
1197 &mut effects,
1198 "subscriptionState",
1199 &json!({ "id": "stable-subscription" }),
1200 )
1201 .expect("subscription state");
1202 assert_eq!(state["state"]["cursor"], -1);
1203 assert_eq!(state["state"]["table"], "todos");
1204 }
1205
1206 #[test]
1207 fn security_preflight_is_fail_closed_until_exact_activation() {
1208 let mut transport = NoNetwork::default();
1209 let mut client: Option<SyncClient> = None;
1210 let mut effects = CreateEffects::default();
1211 dispatch(
1212 &mut transport,
1213 &mut client,
1214 &mut effects,
1215 "create",
1216 &json!({ "schema": schema(), "securityPreflight": true }),
1217 )
1218 .expect("preflight create");
1219
1220 let lifecycle = dispatch(
1221 &mut transport,
1222 &mut client,
1223 &mut effects,
1224 "securityLifecycle",
1225 &json!({}),
1226 )
1227 .expect("lifecycle");
1228 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1229
1230 let query_error = dispatch(
1231 &mut transport,
1232 &mut client,
1233 &mut effects,
1234 "query",
1235 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1236 )
1237 .expect_err("protected query must fail");
1238 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1239 let diagnostics_error = dispatch(
1240 &mut transport,
1241 &mut client,
1242 &mut effects,
1243 "diagnosticsSnapshot",
1244 &json!({}),
1245 )
1246 .expect_err("diagnostics table/subscription evidence remains protected");
1247 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1248
1249 dispatch(
1250 &mut transport,
1251 &mut client,
1252 &mut effects,
1253 "purgeLocalData",
1254 &json!({
1255 "input": {
1256 "purgeId": "directive-1",
1257 "targets": [{
1258 "table": "todos",
1259 "selectors": { "list_id": ["list-1"] }
1260 }]
1261 }
1262 }),
1263 )
1264 .expect("authorized local purge remains available");
1265
1266 let repair_error = dispatch(
1267 &mut transport,
1268 &mut client,
1269 &mut effects,
1270 "rebootstrapLocalData",
1271 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1272 )
1273 .expect_err("projection repair must remain protected during preflight");
1274 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1275
1276 dispatch(
1277 &mut transport,
1278 &mut client,
1279 &mut effects,
1280 "activateSecurity",
1281 &json!({}),
1282 )
1283 .expect("activation");
1284 let rows = dispatch(
1285 &mut transport,
1286 &mut client,
1287 &mut effects,
1288 "query",
1289 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1290 )
1291 .expect("active query");
1292 assert_eq!(rows, json!({ "rows": [] }));
1293
1294 dispatch(
1295 &mut transport,
1296 &mut client,
1297 &mut effects,
1298 "beginSecurityPreflight",
1299 &json!({}),
1300 )
1301 .expect("re-enter preflight");
1302 let blocked = dispatch(
1303 &mut transport,
1304 &mut client,
1305 &mut effects,
1306 "mutate",
1307 &json!({ "mutations": [] }),
1308 )
1309 .expect_err("mutation must be gated");
1310 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1311 }
1312
1313 #[test]
1314 fn preflight_refuses_a_replacement_create_without_the_flag() {
1315 let mut transport = NoNetwork::default();
1316 let mut client: Option<SyncClient> = None;
1317 let mut effects = CreateEffects::default();
1318 dispatch(
1319 &mut transport,
1320 &mut client,
1321 &mut effects,
1322 "create",
1323 &json!({ "schema": schema(), "securityPreflight": true }),
1324 )
1325 .expect("preflight create");
1326
1327 let escape = dispatch(
1329 &mut transport,
1330 &mut client,
1331 &mut effects,
1332 "create",
1333 &json!({ "schema": schema() }),
1334 )
1335 .expect_err("plain create must be refused during preflight");
1336 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1337 let query_error = dispatch(
1339 &mut transport,
1340 &mut client,
1341 &mut effects,
1342 "query",
1343 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1344 )
1345 .expect_err("protected query stays gated");
1346 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1347
1348 dispatch(
1350 &mut transport,
1351 &mut client,
1352 &mut effects,
1353 "create",
1354 &json!({ "schema": schema(), "securityPreflight": true }),
1355 )
1356 .expect("preflighted replacement create");
1357 let still_gated = dispatch(
1358 &mut transport,
1359 &mut client,
1360 &mut effects,
1361 "query",
1362 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1363 )
1364 .expect_err("replacement stays gated");
1365 assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1366
1367 dispatch(
1369 &mut transport,
1370 &mut client,
1371 &mut effects,
1372 "activateSecurity",
1373 &json!({}),
1374 )
1375 .expect("activation");
1376 dispatch(
1377 &mut transport,
1378 &mut client,
1379 &mut effects,
1380 "create",
1381 &json!({ "schema": schema() }),
1382 )
1383 .expect("plain create after activation");
1384 let rows = dispatch(
1385 &mut transport,
1386 &mut client,
1387 &mut effects,
1388 "query",
1389 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1390 )
1391 .expect("active query");
1392 assert_eq!(rows, json!({ "rows": [] }));
1393 }
1394
1395 fn temp_db_path(tag: &str) -> String {
1398 std::env::temp_dir()
1399 .join(format!(
1400 "syncular-command-{tag}-{}-{:?}.db",
1401 std::process::id(),
1402 std::time::SystemTime::now()
1403 .duration_since(std::time::UNIX_EPOCH)
1404 .expect("clock")
1405 .as_nanos()
1406 ))
1407 .to_string_lossy()
1408 .into_owned()
1409 }
1410
1411 #[test]
1412 fn an_activated_replica_reopens_plainly_after_shutdown() {
1413 let path = temp_db_path("activated-reopen");
1419 let mut transport = NoNetwork::default();
1420 let mut client: Option<SyncClient> = None;
1421 let mut effects = CreateEffects::default();
1422
1423 dispatch(
1424 &mut transport,
1425 &mut client,
1426 &mut effects,
1427 "create",
1428 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1429 )
1430 .expect("preflight create");
1431 dispatch(
1432 &mut transport,
1433 &mut client,
1434 &mut effects,
1435 "activateSecurity",
1436 &json!({}),
1437 )
1438 .expect("activation clears the gate");
1439 dispatch(
1440 &mut transport,
1441 &mut client,
1442 &mut effects,
1443 "shutdown",
1444 &json!({}),
1445 )
1446 .expect("shutdown after activation");
1447
1448 dispatch(
1450 &mut transport,
1451 &mut client,
1452 &mut effects,
1453 "create",
1454 &json!({ "schema": schema(), "dbPath": path }),
1455 )
1456 .expect("an activated replica must reopen plainly after shutdown");
1457 let state = dispatch(
1458 &mut transport,
1459 &mut client,
1460 &mut effects,
1461 "securityLifecycle",
1462 &json!({}),
1463 )
1464 .expect("lifecycle");
1465 assert_eq!(state, json!({ "state": "active" }));
1466
1467 let _ = std::fs::remove_file(&path);
1468 }
1469
1470 #[test]
1471 fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1472 let path = temp_db_path("quarantined-reopen");
1477 let mut transport = NoNetwork::default();
1478 let mut client: Option<SyncClient> = None;
1479 let mut effects = CreateEffects::default();
1480
1481 dispatch(
1482 &mut transport,
1483 &mut client,
1484 &mut effects,
1485 "create",
1486 &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1487 )
1488 .expect("preflight create");
1489 dispatch(
1490 &mut transport,
1491 &mut client,
1492 &mut effects,
1493 "shutdown",
1494 &json!({}),
1495 )
1496 .expect("shutdown during preflight");
1497
1498 let mut rebuilt = CreateEffects::default();
1501 let escape = dispatch(
1502 &mut transport,
1503 &mut client,
1504 &mut rebuilt,
1505 "create",
1506 &json!({ "schema": schema(), "dbPath": path }),
1507 )
1508 .expect_err("a quarantined replica must refuse a plain re-create");
1509 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1510
1511 let _ = std::fs::remove_file(&path);
1512 }
1513
1514 #[test]
1515 fn preflight_gate_survives_shutdown_before_replacement_creates() {
1516 let mut transport = NoNetwork::default();
1517 let mut client: Option<SyncClient> = None;
1518 let mut effects = CreateEffects::default();
1519 dispatch(
1520 &mut transport,
1521 &mut client,
1522 &mut effects,
1523 "create",
1524 &json!({ "schema": schema(), "securityPreflight": true }),
1525 )
1526 .expect("preflight create");
1527 dispatch(
1528 &mut transport,
1529 &mut client,
1530 &mut effects,
1531 "shutdown",
1532 &json!({}),
1533 )
1534 .expect("shutdown during preflight");
1535 assert!(client.is_none());
1536
1537 let escape = dispatch(
1539 &mut transport,
1540 &mut client,
1541 &mut effects,
1542 "create",
1543 &json!({ "schema": schema() }),
1544 )
1545 .expect_err("shutdown + plain create must stay refused");
1546 assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1547
1548 dispatch(
1549 &mut transport,
1550 &mut client,
1551 &mut effects,
1552 "create",
1553 &json!({ "schema": schema(), "securityPreflight": true }),
1554 )
1555 .expect("preflighted re-create");
1556 dispatch(
1557 &mut transport,
1558 &mut client,
1559 &mut effects,
1560 "activateSecurity",
1561 &json!({}),
1562 )
1563 .expect("activation");
1564
1565 dispatch(
1567 &mut transport,
1568 &mut client,
1569 &mut effects,
1570 "shutdown",
1571 &json!({}),
1572 )
1573 .expect("shutdown after activation");
1574 dispatch(
1575 &mut transport,
1576 &mut client,
1577 &mut effects,
1578 "create",
1579 &json!({ "schema": schema() }),
1580 )
1581 .expect("plain create after an activated shutdown");
1582 }
1583
1584 #[test]
1585 fn activate_security_validates_optional_headers_atomically() {
1586 let mut transport = NoNetwork::default();
1587 let mut client: Option<SyncClient> = None;
1588 let mut effects = CreateEffects::default();
1589 dispatch(
1590 &mut transport,
1591 &mut client,
1592 &mut effects,
1593 "create",
1594 &json!({ "schema": schema(), "securityPreflight": true }),
1595 )
1596 .expect("preflight create");
1597
1598 let invalid = dispatch(
1600 &mut transport,
1601 &mut client,
1602 &mut effects,
1603 "activateSecurity",
1604 &json!({ "headers": { "authorization": 7 } }),
1605 )
1606 .expect_err("non-string header must fail");
1607 assert_eq!(invalid.0, "sync.invalid_request");
1608 let lifecycle = dispatch(
1609 &mut transport,
1610 &mut client,
1611 &mut effects,
1612 "securityLifecycle",
1613 &json!({}),
1614 )
1615 .expect("lifecycle");
1616 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1617
1618 dispatch(
1620 &mut transport,
1621 &mut client,
1622 &mut effects,
1623 "activateSecurity",
1624 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1625 )
1626 .expect("activation with fresh headers");
1627 let lifecycle = dispatch(
1628 &mut transport,
1629 &mut client,
1630 &mut effects,
1631 "securityLifecycle",
1632 &json!({}),
1633 )
1634 .expect("lifecycle");
1635 assert_eq!(lifecycle, json!({ "state": "active" }));
1636 }
1637
1638 #[test]
1639 fn parse_headers_reads_the_full_replacement_set() {
1640 let parsed = parse_headers(&json!({
1641 "authorization": "Bearer fresh",
1642 "x-tenant": "t1"
1643 }))
1644 .expect("valid headers parse");
1645 assert_eq!(
1646 parsed,
1647 vec![
1648 ("authorization".to_owned(), "Bearer fresh".to_owned()),
1649 ("x-tenant".to_owned(), "t1".to_owned())
1650 ]
1651 );
1652 assert!(parse_headers(&json!(["authorization"])).is_err());
1653 assert!(parse_headers(&json!({ "authorization": null })).is_err());
1654 }
1655
1656 #[test]
1657 fn set_headers_validates_replacement_and_respects_preflight() {
1658 let mut transport = NoNetwork::default();
1659 let mut client: Option<SyncClient> = None;
1660 let mut effects = CreateEffects::default();
1661 dispatch(
1662 &mut transport,
1663 &mut client,
1664 &mut effects,
1665 "create",
1666 &json!({ "schema": schema() }),
1667 )
1668 .expect("create");
1669 dispatch(
1670 &mut transport,
1671 &mut client,
1672 &mut effects,
1673 "setHeaders",
1674 &json!({ "headers": { "authorization": "Bearer fresh" } }),
1675 )
1676 .expect("valid replacement");
1677 let invalid = dispatch(
1678 &mut transport,
1679 &mut client,
1680 &mut effects,
1681 "setHeaders",
1682 &json!({ "headers": { "authorization": 7 } }),
1683 )
1684 .expect_err("invalid replacement");
1685 assert_eq!(invalid.0, "sync.invalid_request");
1686
1687 dispatch(
1688 &mut transport,
1689 &mut client,
1690 &mut effects,
1691 "beginSecurityPreflight",
1692 &json!({}),
1693 )
1694 .expect("preflight");
1695 let gated = dispatch(
1696 &mut transport,
1697 &mut client,
1698 &mut effects,
1699 "setHeaders",
1700 &json!({ "headers": {} }),
1701 )
1702 .expect_err("direct rotation must respect preflight");
1703 assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1704 }
1705}