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 ClientLimits, CommandEffects, CommitOutcomeQuery, LocalDataPurgeInput, Mutation,
24 ResolveCommitOutcomeInput, SyncClient, Transport, WindowBase, WindowCoverage,
25};
26
27pub fn bytes_to_hex(bytes: &[u8]) -> String {
30 syncular_client::values::bytes_to_hex(bytes)
31}
32
33pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
34 syncular_client::values::hex_to_bytes(hex)
35}
36
37pub fn bytes_value(bytes: &[u8]) -> Value {
38 json!({ "$bytes": bytes_to_hex(bytes) })
39}
40
41pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
42 let hex = value
43 .and_then(|v| v.get("$bytes"))
44 .and_then(Value::as_str)
45 .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
46 hex_to_bytes(hex)
47}
48
49pub type CommandError = (String, String);
51
52#[derive(Debug, Default, Clone)]
56pub struct CreateEffects {
57 pub signed_urls: bool,
60}
61
62fn client_err(message: String) -> CommandError {
63 let code = message
64 .split_once(':')
65 .map(|(candidate, _)| candidate)
66 .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
67 .unwrap_or("client.failed");
68 (code.to_owned(), message)
69}
70
71fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
72 client
73 .as_mut()
74 .ok_or_else(|| client_err("no client instance created".to_owned()))
75}
76
77pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
78 let mut limits = ClientLimits::default();
79 let Some(object) = value.and_then(Value::as_object) else {
80 return limits;
81 };
82 limits.limit_commits = object
83 .get("limitCommits")
84 .and_then(Value::as_i64)
85 .map(|v| v as i32);
86 limits.limit_snapshot_rows = object
87 .get("limitSnapshotRows")
88 .and_then(Value::as_i64)
89 .map(|v| v as i32);
90 limits.max_snapshot_pages = object
91 .get("maxSnapshotPages")
92 .and_then(Value::as_i64)
93 .map(|v| v as i32);
94 limits.accept = object
95 .get("accept")
96 .and_then(Value::as_u64)
97 .map(|v| v as u8);
98 limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
99 limits.outcome_retention_max_entries = object
100 .get("outcomeRetentionMaxEntries")
101 .and_then(Value::as_u64)
102 .map(|value| value as usize);
103 limits
104}
105
106pub fn parse_encryption(
110 value: &Value,
111) -> Result<syncular_client::values::EncryptionConfig, String> {
112 let mut config = syncular_client::values::EncryptionConfig::default();
113 let Some(keys) = value.get("keys").and_then(Value::as_object) else {
114 return Ok(config);
115 };
116 for (key_id, key_val) in keys {
117 let bytes =
118 value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
119 if bytes.len() != 32 {
120 return Err(format!(
121 "encryption key {key_id:?} must be 32 bytes, got {}",
122 bytes.len()
123 ));
124 }
125 config.keys.insert(key_id.clone(), bytes);
126 }
127 if let Some(columns) = value.get("keyIdColumns") {
128 let columns = columns
129 .as_object()
130 .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
131 for (table, column) in columns {
132 let column = column.as_str().ok_or_else(|| {
133 format!("encryption keyIdColumns entry for {table:?} must be a string")
134 })?;
135 if column.is_empty() {
136 return Err(format!(
137 "encryption keyIdColumns entry for {table:?} must not be empty"
138 ));
139 }
140 config
141 .key_id_columns
142 .insert(table.clone(), column.to_owned());
143 }
144 }
145 Ok(config)
146}
147
148pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
149 let list = value
150 .and_then(Value::as_array)
151 .ok_or_else(|| "mutations must be a list".to_owned())?;
152 let mut out = Vec::with_capacity(list.len());
153 for entry in list {
154 let op = entry
155 .get("op")
156 .and_then(Value::as_str)
157 .ok_or_else(|| "mutation missing op".to_owned())?;
158 let table = entry
159 .get("table")
160 .and_then(Value::as_str)
161 .ok_or_else(|| "mutation missing table".to_owned())?
162 .to_owned();
163 let base_version = entry.get("baseVersion").and_then(Value::as_i64);
164 match op {
165 "upsert" => {
166 let mut values = entry
167 .get("values")
168 .and_then(Value::as_object)
169 .cloned()
170 .ok_or_else(|| "upsert missing values".to_owned())?;
171 decode_bigint_members(&mut values)?;
172 out.push(Mutation::Upsert {
173 table,
174 values,
175 base_version,
176 });
177 }
178 "delete" => {
179 let row_id = entry
180 .get("rowId")
181 .and_then(Value::as_str)
182 .ok_or_else(|| "delete missing rowId".to_owned())?
183 .to_owned();
184 out.push(Mutation::Delete {
185 table,
186 row_id,
187 base_version,
188 });
189 }
190 other => return Err(format!("unknown mutation op {other:?}")),
191 }
192 }
193 Ok(out)
194}
195
196fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
197 for value in values.values_mut() {
198 let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
199 continue;
200 };
201 let integer = decimal
202 .parse::<i64>()
203 .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
204 *value = Value::from(integer);
205 }
206 Ok(())
207}
208
209fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
210 match value {
211 Some(v) => syncular_client::values::json_to_scope_map(v),
212 None => Ok(Vec::new()),
213 }
214}
215
216fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
219 let object = value
220 .and_then(Value::as_object)
221 .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
222 let table = object
223 .get("table")
224 .and_then(Value::as_str)
225 .ok_or_else(|| "window base missing table".to_owned())?
226 .to_owned();
227 let variable = object
228 .get("variable")
229 .and_then(Value::as_str)
230 .ok_or_else(|| "window base missing variable".to_owned())?
231 .to_owned();
232 let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
233 let params = object
234 .get("params")
235 .and_then(Value::as_str)
236 .map(str::to_owned);
237 Ok(WindowBase {
238 table,
239 variable,
240 fixed_scopes,
241 params,
242 })
243}
244
245#[cfg(feature = "crdt-yjs")]
249fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
250 let table = params
251 .get("table")
252 .and_then(Value::as_str)
253 .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
254 .to_owned();
255 let row_id = params
256 .get("rowId")
257 .and_then(Value::as_str)
258 .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
259 .to_owned();
260 let column = params
261 .get("column")
262 .and_then(Value::as_str)
263 .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
264 .to_owned();
265 let name = params
266 .get("name")
267 .and_then(Value::as_str)
268 .unwrap_or("text")
269 .to_owned();
270 Ok((table, row_id, column, name))
271}
272
273pub fn dispatch<T: Transport>(
283 transport: &mut T,
284 client: &mut Option<SyncClient>,
285 effects: &mut CreateEffects,
286 method: &str,
287 params: &Value,
288) -> Result<Value, CommandError> {
289 if method != "create" {
290 let allowed_during_preflight = matches!(
291 method,
292 "securityLifecycle"
293 | "beginSecurityPreflight"
294 | "activateSecurity"
295 | "purgeLocalData"
296 | "localRevision"
297 | "statusSnapshot"
298 | "shutdown"
299 );
300 if client
301 .as_ref()
302 .is_some_and(|running| running.security_preflight())
303 && !allowed_during_preflight
304 {
305 return Err((
306 syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
307 "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
308 ));
309 }
310 }
311 match method {
312 "create" => {
313 let client_id = params
314 .get("clientId")
315 .and_then(Value::as_str)
316 .map(str::to_owned);
317 let schema = params
318 .get("schema")
319 .ok_or_else(|| client_err("create missing schema".to_owned()))?;
320 let limits = parse_limits(params.get("limits"));
321 let mut instance = match params.get("dbPath").and_then(Value::as_str) {
325 Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
326 .map_err(client_err)?,
327 None => {
328 SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
329 }
330 };
331 if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
333 instance.set_now_ms(now_ms);
334 }
335 effects.signed_urls = params
338 .get("signedUrls")
339 .and_then(Value::as_bool)
340 .unwrap_or(false);
341 let security_preflight = params
345 .get("securityPreflight")
346 .and_then(Value::as_bool)
347 .unwrap_or(false);
348 if security_preflight && params.get("encryption").is_some() {
349 return Err(client_err(
350 "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
351 .to_owned(),
352 ));
353 }
354 if let Some(enc) = params.get("encryption") {
355 let config = parse_encryption(enc).map_err(client_err)?;
356 instance.set_encryption(config);
357 }
358 if security_preflight {
359 instance.begin_security_preflight();
360 }
361 *client = Some(instance);
362 Ok(json!({}))
363 }
364 "securityLifecycle" => Ok(json!({
365 "state": need_client(client)?.security_lifecycle()
366 })),
367 "beginSecurityPreflight" => {
368 let running = need_client(client)?;
369 running.disconnect_realtime(transport);
370 running.begin_security_preflight();
371 Ok(json!({}))
372 }
373 "activateSecurity" => {
374 let encryption = match params.get("encryption") {
375 Some(value) => parse_encryption(value).map_err(client_err)?,
376 None => syncular_client::values::EncryptionConfig::default(),
377 };
378 need_client(client)?
379 .activate_security(encryption)
380 .map_err(client_err)?;
381 Ok(json!({}))
382 }
383 "shutdown" => {
384 if let Some(running) = client.as_mut() {
385 running.disconnect_realtime(transport);
386 running.begin_security_preflight();
387 }
388 *client = None;
389 Ok(json!({}))
390 }
391 "subscribe" => {
392 let id = params
393 .get("id")
394 .and_then(Value::as_str)
395 .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
396 .to_owned();
397 let table = params
398 .get("table")
399 .and_then(Value::as_str)
400 .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
401 .to_owned();
402 let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
403 let sub_params = params
404 .get("params")
405 .and_then(Value::as_str)
406 .map(str::to_owned);
407 need_client(client)?
408 .subscribe(id, table, scopes, sub_params)
409 .map_err(client_err)?;
410 Ok(json!({}))
411 }
412 "unsubscribe" => {
413 let id = params
414 .get("id")
415 .and_then(Value::as_str)
416 .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
417 need_client(client)?.unsubscribe(id);
418 Ok(json!({}))
419 }
420 "setWindow" => {
421 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
422 let units: Vec<String> = params
423 .get("units")
424 .and_then(Value::as_array)
425 .map(|arr| {
426 arr.iter()
427 .filter_map(|v| v.as_str().map(str::to_owned))
428 .collect()
429 })
430 .unwrap_or_default();
431 let command_effects = need_client(client)?
432 .set_window(&base, &units)
433 .map_err(client_err)?;
434 Ok(json!({ "effects": command_effects }))
435 }
436 "windowState" => {
437 let base = window_base_from_params(params.get("base")).map_err(client_err)?;
438 let state = need_client(client)?.window_state(&base);
439 Ok(json!({ "units": state.units, "pending": state.pending }))
440 }
441 "mutate" => {
442 let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
443 let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
444 Ok(json!({
445 "clientCommitId": id,
446 "effects": CommandEffects::interactive()
447 }))
448 }
449 "patch" => {
450 let table = params
451 .get("table")
452 .and_then(Value::as_str)
453 .ok_or_else(|| client_err("patch missing table".to_owned()))?;
454 let row_id = params
455 .get("rowId")
456 .and_then(Value::as_str)
457 .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
458 let mut partial = params
459 .get("partial")
460 .and_then(Value::as_object)
461 .cloned()
462 .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
463 decode_bigint_members(&mut partial).map_err(client_err)?;
464 let base_version = params.get("baseVersion").and_then(Value::as_i64);
465 let id = need_client(client)?
466 .patch(table, row_id, partial, base_version)
467 .map_err(client_err)?;
468 Ok(json!({
469 "clientCommitId": id,
470 "effects": CommandEffects::interactive()
471 }))
472 }
473 "purgeLocalData" => {
474 let input: LocalDataPurgeInput =
475 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
476 client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
477 })?)
478 .map_err(|error| {
479 client_err(format!(
480 "sync.invalid_request: invalid purgeLocalData input: {error}"
481 ))
482 })?;
483 let result = need_client(client)?
484 .purge_local_data(&input)
485 .map_err(client_err)?;
486 serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
487 }
488 "sync" => {
489 let outcome = need_client(client)?.sync(transport);
490 Ok(outcome.to_json())
491 }
492 "syncUntilIdle" => {
493 let max_rounds = params
494 .get("maxRounds")
495 .and_then(Value::as_u64)
496 .map(|v| v as u32);
497 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
498 Ok(outcome.to_json())
499 }
500 "readRows" => {
501 let table = params
502 .get("table")
503 .and_then(Value::as_str)
504 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
505 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
506 Ok(json!({ "rows": rows }))
507 }
508 "query" => {
509 let sql = params
513 .get("sql")
514 .and_then(Value::as_str)
515 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
516 let bind = match params.get("params") {
517 Some(Value::Array(list)) => list.clone(),
518 None | Some(Value::Null) => Vec::new(),
519 Some(_) => return Err(client_err("query params must be a list".to_owned())),
520 };
521 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
522 Ok(json!({ "rows": rows }))
523 }
524 "querySnapshot" => {
525 let sql = params
526 .get("sql")
527 .and_then(Value::as_str)
528 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
529 let bind = match params.get("params") {
530 Some(Value::Array(list)) => list.clone(),
531 None | Some(Value::Null) => Vec::new(),
532 Some(_) => {
533 return Err(client_err("querySnapshot params must be a list".to_owned()))
534 }
535 };
536 let mut coverage = Vec::new();
537 for entry in params
538 .get("coverage")
539 .and_then(Value::as_array)
540 .into_iter()
541 .flatten()
542 {
543 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
544 let units = entry
545 .get("units")
546 .and_then(Value::as_array)
547 .map(|values| {
548 values
549 .iter()
550 .filter_map(|value| value.as_str().map(str::to_owned))
551 .collect()
552 })
553 .unwrap_or_default();
554 coverage.push(WindowCoverage { base, units });
555 }
556 let snapshot = need_client(client)?
557 .query_snapshot(sql, &bind, &coverage)
558 .map_err(client_err)?;
559 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
560 }
561 "localRevision" => Ok(json!({
562 "revision": need_client(client)?.local_revision().to_string()
563 })),
564 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
565 .expect("status serializes")),
566 "drainChangeBatches" => Ok(json!({
571 "batches": need_client(client)?.drain_change_batches()
572 })),
573 "drainSyncIntents" => Ok(json!({
574 "intents": need_client(client)?.drain_sync_intents()
575 })),
576 #[cfg(feature = "crdt-yjs")]
582 "crdtText" => {
583 let (table, row_id, column, name) = crdt_target(params)?;
584 let text = need_client(client)?
585 .crdt_text(&table, &row_id, &column, &name)
586 .map_err(client_err)?;
587 Ok(json!({ "text": text }))
588 }
589 #[cfg(feature = "crdt-yjs")]
590 "crdtInsertText" => {
591 let (table, row_id, column, name) = crdt_target(params)?;
592 let index = params
593 .get("index")
594 .and_then(Value::as_u64)
595 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
596 as u32;
597 let value = params
598 .get("value")
599 .and_then(Value::as_str)
600 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
601 let id = need_client(client)?
602 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
603 .map_err(client_err)?;
604 Ok(json!({ "clientCommitId": id }))
605 }
606 #[cfg(feature = "crdt-yjs")]
607 "crdtDeleteText" => {
608 let (table, row_id, column, name) = crdt_target(params)?;
609 let index = params
610 .get("index")
611 .and_then(Value::as_u64)
612 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
613 as u32;
614 let len = params
615 .get("len")
616 .and_then(Value::as_u64)
617 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
618 as u32;
619 let id = need_client(client)?
620 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
621 .map_err(client_err)?;
622 Ok(json!({ "clientCommitId": id }))
623 }
624 #[cfg(feature = "crdt-yjs")]
625 "crdtApplyUpdate" => {
626 let (table, row_id, column, _name) = crdt_target(params)?;
627 let update = value_bytes(params.get("update")).map_err(client_err)?;
628 let id = need_client(client)?
629 .crdt_apply_update(&table, &row_id, &column, &update)
630 .map_err(client_err)?;
631 Ok(json!({ "clientCommitId": id }))
632 }
633 #[cfg(not(feature = "crdt-yjs"))]
634 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
635 "client.crdt_unavailable".to_owned(),
636 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
637 )),
638
639 "uploadBlob" => {
640 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
641 let media_type = params
642 .get("mediaType")
643 .and_then(Value::as_str)
644 .map(str::to_owned);
645 let name = params
646 .get("name")
647 .and_then(Value::as_str)
648 .map(str::to_owned);
649 let reference = need_client(client)?
650 .upload_blob(&bytes, media_type, name)
651 .map_err(client_err)?;
652 Ok(json!({ "ref": reference }))
653 }
654 "fetchBlob" => {
655 let blob = params
656 .get("blob")
657 .and_then(Value::as_str)
658 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
659 .to_owned();
660 let value = need_client(client)?.fetch_blob(transport, &blob)?;
663 Ok(json!({ "blob": value }))
664 }
665 "conflicts" => {
666 let conflicts = need_client(client)?.conflicts().to_vec();
667 Ok(json!({ "conflicts": conflicts }))
668 }
669 "rejections" => {
670 let rejections = need_client(client)?.rejections().to_vec();
671 Ok(json!({ "rejections": rejections }))
672 }
673 "commitOutcome" => {
674 let client_commit_id = params
675 .get("clientCommitId")
676 .and_then(Value::as_str)
677 .ok_or_else(|| {
678 client_err(
679 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
680 )
681 })?;
682 let outcome = need_client(client)?
683 .commit_outcome(client_commit_id)
684 .map_err(client_err)?;
685 Ok(json!({ "outcome": outcome }))
686 }
687 "commitOutcomes" => {
688 let query = serde_json::from_value::<CommitOutcomeQuery>(
689 params.get("query").cloned().unwrap_or_else(|| json!({})),
690 )
691 .map_err(|error| {
692 client_err(format!(
693 "sync.invalid_request: invalid commit outcome query: {error}"
694 ))
695 })?;
696 let outcomes = need_client(client)?
697 .commit_outcomes(query)
698 .map_err(client_err)?;
699 Ok(json!({ "outcomes": outcomes }))
700 }
701 "resolveCommitOutcome" => {
702 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
703 params.get("input").cloned().ok_or_else(|| {
704 client_err(
705 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
706 )
707 })?,
708 )
709 .map_err(|error| {
710 client_err(format!(
711 "sync.invalid_request: invalid outcome resolution: {error}"
712 ))
713 })?;
714 let outcome = need_client(client)?
715 .resolve_commit_outcome(input)
716 .map_err(client_err)?;
717 Ok(json!({ "outcome": outcome }))
718 }
719 "pendingCommitIds" => {
720 let ids = need_client(client)?.pending_commit_ids();
721 Ok(json!({ "ids": ids }))
722 }
723 "subscriptionState" => {
724 let id = params
725 .get("id")
726 .and_then(Value::as_str)
727 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
728 let state = need_client(client)?.subscription_state(id);
729 Ok(json!({ "state": state }))
730 }
731 "schemaFloor" => {
732 let floor = need_client(client)?.schema_floor().cloned();
733 Ok(json!({ "floor": floor }))
734 }
735 "leaseState" => {
736 let lease = need_client(client)?.lease_state().cloned();
737 Ok(json!({ "lease": lease }))
738 }
739 "upgrading" => {
740 let value = need_client(client)?.upgrading();
742 Ok(json!({ "value": value }))
743 }
744 "recreateWithSchema" => {
745 let schema = params
749 .get("schema")
750 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
751 need_client(client)?
752 .recreate_with_schema(schema)
753 .map_err(client_err)?;
754 Ok(json!({}))
755 }
756 "connectRealtime" => {
757 need_client(client)?
758 .connect_realtime(transport)
759 .map_err(client_err)?;
760 Ok(json!({}))
761 }
762 "disconnectRealtime" => {
763 need_client(client)?.disconnect_realtime(transport);
764 Ok(json!({}))
765 }
766 "syncNeeded" => {
767 let value = need_client(client)?.sync_needed();
768 Ok(json!({ "value": value }))
769 }
770 "setPresence" => {
771 let scope_key = params
772 .get("scopeKey")
773 .and_then(Value::as_str)
774 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
775 .to_owned();
776 let doc = params.get("doc");
778 let doc_ref = match doc {
779 None | Some(Value::Null) => None,
780 Some(v) => Some(v),
781 };
782 need_client(client)?
783 .set_presence(transport, &scope_key, doc_ref)
784 .map_err(client_err)?;
785 Ok(json!({}))
786 }
787 "presence" => {
788 let scope_key = params
789 .get("scopeKey")
790 .and_then(Value::as_str)
791 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
792 let peers = need_client(client)?.presence(scope_key);
793 Ok(json!({ "peers": peers }))
794 }
795
796 "messageRoundtrip" => {
798 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
799 match decode_message(&bytes) {
800 Ok(message) => Ok(json!({
801 "ok": true,
802 "bytes": bytes_value(&encode_message(&message)),
803 "renderedJson": render_message(&message).to_string(),
804 })),
805 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
806 }
807 }
808 "segmentRoundtrip" => {
809 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
810 match decode_rows_segment(&bytes) {
811 Ok(segment) => Ok(json!({
812 "ok": true,
813 "bytes": bytes_value(&encode_rows_segment(&segment)),
814 "renderedJson": render_rows_segment(&segment).to_string(),
815 })),
816 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
817 }
818 }
819 "realtimeKnown" => {
820 let text = params
821 .get("text")
822 .and_then(Value::as_str)
823 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
824 let known = matches!(
825 parse_control(text),
826 Ok(ControlMessage::Hello { .. })
827 | Ok(ControlMessage::Wake { .. })
828 | Ok(ControlMessage::Heartbeat { .. })
829 | Ok(ControlMessage::Presence { .. })
830 );
831 Ok(json!({ "value": known }))
832 }
833
834 other => Err(client_err(format!("unknown method {other:?}"))),
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use serde_json::{json, Value};
841 use syncular_client::{
842 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
843 };
844
845 use super::{dispatch, parse_encryption, CreateEffects};
846
847 struct NoNetwork;
848
849 impl Transport for NoNetwork {
850 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
851 Err(TransportError::new("sync.transport_failed", "offline"))
852 }
853
854 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
855 Err(TransportError::new("sync.transport_failed", "offline"))
856 }
857
858 fn download_segment(
859 &mut self,
860 _request: &SegmentRequest,
861 ) -> Result<Vec<u8>, TransportError> {
862 Err(TransportError::new("sync.transport_failed", "offline"))
863 }
864
865 fn realtime_connect(&mut self) -> Result<(), TransportError> {
866 Ok(())
867 }
868
869 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
870 Ok(())
871 }
872
873 fn realtime_close(&mut self) -> Result<(), TransportError> {
874 Ok(())
875 }
876 }
877
878 fn schema() -> Value {
879 json!({
880 "version": 1,
881 "tables": [{
882 "name": "todos",
883 "columns": [
884 { "name": "id", "type": "string", "nullable": false },
885 { "name": "list_id", "type": "string", "nullable": false }
886 ],
887 "primaryKey": "id",
888 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
889 }]
890 })
891 }
892
893 #[test]
894 fn parses_portable_encryption_keyring_and_key_id_columns() {
895 let key_hex = "2a".repeat(32);
896 let config = parse_encryption(&json!({
897 "keys": { "practice-key-v1": { "$bytes": key_hex } },
898 "keyIdColumns": { "patients": "encryption_key_id" }
899 }))
900 .expect("portable keyring parses");
901 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
902 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
903 }
904
905 #[test]
906 fn rejects_non_string_key_id_columns() {
907 let error = parse_encryption(&json!({
908 "keys": {},
909 "keyIdColumns": { "patients": 7 }
910 }))
911 .expect_err("invalid selector must fail");
912 assert!(error.contains("must be a string"), "{error}");
913 }
914
915 #[test]
916 fn security_preflight_is_fail_closed_until_exact_activation() {
917 let mut transport = NoNetwork;
918 let mut client: Option<SyncClient> = None;
919 let mut effects = CreateEffects::default();
920 dispatch(
921 &mut transport,
922 &mut client,
923 &mut effects,
924 "create",
925 &json!({ "schema": schema(), "securityPreflight": true }),
926 )
927 .expect("preflight create");
928
929 let lifecycle = dispatch(
930 &mut transport,
931 &mut client,
932 &mut effects,
933 "securityLifecycle",
934 &json!({}),
935 )
936 .expect("lifecycle");
937 assert_eq!(lifecycle, json!({ "state": "preflight" }));
938
939 let query_error = dispatch(
940 &mut transport,
941 &mut client,
942 &mut effects,
943 "query",
944 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
945 )
946 .expect_err("protected query must fail");
947 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
948
949 dispatch(
950 &mut transport,
951 &mut client,
952 &mut effects,
953 "purgeLocalData",
954 &json!({
955 "input": {
956 "purgeId": "directive-1",
957 "targets": [{
958 "table": "todos",
959 "selectors": { "list_id": ["list-1"] }
960 }]
961 }
962 }),
963 )
964 .expect("authorized local purge remains available");
965
966 dispatch(
967 &mut transport,
968 &mut client,
969 &mut effects,
970 "activateSecurity",
971 &json!({}),
972 )
973 .expect("activation");
974 let rows = dispatch(
975 &mut transport,
976 &mut client,
977 &mut effects,
978 "query",
979 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
980 )
981 .expect("active query");
982 assert_eq!(rows, json!({ "rows": [] }));
983
984 dispatch(
985 &mut transport,
986 &mut client,
987 &mut effects,
988 "beginSecurityPreflight",
989 &json!({}),
990 )
991 .expect("re-enter preflight");
992 let blocked = dispatch(
993 &mut transport,
994 &mut client,
995 &mut effects,
996 "mutate",
997 &json!({ "mutations": [] }),
998 )
999 .expect_err("mutation must be gated");
1000 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1001 }
1002}