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