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 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 "rebootstrapLocalData" => {
490 let input: LocalDataRebootstrapInput =
491 serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
492 client_err(
493 "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
494 )
495 })?)
496 .map_err(|error| {
497 client_err(format!(
498 "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
499 ))
500 })?;
501 let result = need_client(client)?
502 .rebootstrap_local_data(&input)
503 .map_err(client_err)?;
504 Ok(json!({
505 "alreadyApplied": result.already_applied,
506 "retainedCommits": result.retained_commits,
507 "resetSubscriptions": result.reset_subscriptions,
508 "effects": if result.already_applied {
509 CommandEffects::none()
510 } else {
511 CommandEffects::interactive()
512 }
513 }))
514 }
515 "sync" => {
516 let outcome = need_client(client)?.sync(transport);
517 Ok(outcome.to_json())
518 }
519 "syncUntilIdle" => {
520 let max_rounds = params
521 .get("maxRounds")
522 .and_then(Value::as_u64)
523 .map(|v| v as u32);
524 let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
525 Ok(outcome.to_json())
526 }
527 "readRows" => {
528 let table = params
529 .get("table")
530 .and_then(Value::as_str)
531 .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
532 let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
533 Ok(json!({ "rows": rows }))
534 }
535 "query" => {
536 let sql = params
540 .get("sql")
541 .and_then(Value::as_str)
542 .ok_or_else(|| client_err("query missing sql".to_owned()))?;
543 let bind = match params.get("params") {
544 Some(Value::Array(list)) => list.clone(),
545 None | Some(Value::Null) => Vec::new(),
546 Some(_) => return Err(client_err("query params must be a list".to_owned())),
547 };
548 let rows = need_client(client)?.query(sql, &bind).map_err(client_err)?;
549 Ok(json!({ "rows": rows }))
550 }
551 "querySnapshot" => {
552 let sql = params
553 .get("sql")
554 .and_then(Value::as_str)
555 .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
556 let bind = match params.get("params") {
557 Some(Value::Array(list)) => list.clone(),
558 None | Some(Value::Null) => Vec::new(),
559 Some(_) => {
560 return Err(client_err("querySnapshot params must be a list".to_owned()))
561 }
562 };
563 let mut coverage = Vec::new();
564 for entry in params
565 .get("coverage")
566 .and_then(Value::as_array)
567 .into_iter()
568 .flatten()
569 {
570 let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
571 let units = entry
572 .get("units")
573 .and_then(Value::as_array)
574 .map(|values| {
575 values
576 .iter()
577 .filter_map(|value| value.as_str().map(str::to_owned))
578 .collect()
579 })
580 .unwrap_or_default();
581 coverage.push(WindowCoverage { base, units });
582 }
583 let snapshot = need_client(client)?
584 .query_snapshot(sql, &bind, &coverage)
585 .map_err(client_err)?;
586 Ok(serde_json::to_value(snapshot).expect("snapshot serializes"))
587 }
588 "localRevision" => Ok(json!({
589 "revision": need_client(client)?.local_revision().to_string()
590 })),
591 "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
592 .expect("status serializes")),
593 "diagnosticsSnapshot" => {
594 let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
595 .map_err(|error| {
596 client_err(format!(
597 "sync.invalid_request: invalid diagnostics request: {error}"
598 ))
599 })?;
600 let snapshot = need_client(client)?
601 .diagnostics_snapshot(&request)
602 .map_err(client_err)?;
603 Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
604 }
605 "drainChangeBatches" => Ok(json!({
610 "batches": need_client(client)?.drain_change_batches()
611 })),
612 "drainSyncIntents" => Ok(json!({
613 "intents": need_client(client)?.drain_sync_intents()
614 })),
615 #[cfg(feature = "crdt-yjs")]
621 "crdtText" => {
622 let (table, row_id, column, name) = crdt_target(params)?;
623 let text = need_client(client)?
624 .crdt_text(&table, &row_id, &column, &name)
625 .map_err(client_err)?;
626 Ok(json!({ "text": text }))
627 }
628 #[cfg(feature = "crdt-yjs")]
629 "crdtInsertText" => {
630 let (table, row_id, column, name) = crdt_target(params)?;
631 let index = params
632 .get("index")
633 .and_then(Value::as_u64)
634 .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
635 as u32;
636 let value = params
637 .get("value")
638 .and_then(Value::as_str)
639 .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
640 let id = need_client(client)?
641 .crdt_insert_text(&table, &row_id, &column, &name, index, value)
642 .map_err(client_err)?;
643 Ok(json!({ "clientCommitId": id }))
644 }
645 #[cfg(feature = "crdt-yjs")]
646 "crdtDeleteText" => {
647 let (table, row_id, column, name) = crdt_target(params)?;
648 let index = params
649 .get("index")
650 .and_then(Value::as_u64)
651 .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
652 as u32;
653 let len = params
654 .get("len")
655 .and_then(Value::as_u64)
656 .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
657 as u32;
658 let id = need_client(client)?
659 .crdt_delete_text(&table, &row_id, &column, &name, index, len)
660 .map_err(client_err)?;
661 Ok(json!({ "clientCommitId": id }))
662 }
663 #[cfg(feature = "crdt-yjs")]
664 "crdtApplyUpdate" => {
665 let (table, row_id, column, _name) = crdt_target(params)?;
666 let update = value_bytes(params.get("update")).map_err(client_err)?;
667 let id = need_client(client)?
668 .crdt_apply_update(&table, &row_id, &column, &update)
669 .map_err(client_err)?;
670 Ok(json!({ "clientCommitId": id }))
671 }
672 #[cfg(not(feature = "crdt-yjs"))]
673 "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
674 "client.crdt_unavailable".to_owned(),
675 "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
676 )),
677
678 "uploadBlob" => {
679 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
680 let media_type = params
681 .get("mediaType")
682 .and_then(Value::as_str)
683 .map(str::to_owned);
684 let name = params
685 .get("name")
686 .and_then(Value::as_str)
687 .map(str::to_owned);
688 let reference = need_client(client)?
689 .upload_blob(&bytes, media_type, name)
690 .map_err(client_err)?;
691 Ok(json!({ "ref": reference }))
692 }
693 "fetchBlob" => {
694 let blob = params
695 .get("blob")
696 .and_then(Value::as_str)
697 .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
698 .to_owned();
699 let value = need_client(client)?.fetch_blob(transport, &blob)?;
702 Ok(json!({ "blob": value }))
703 }
704 "conflicts" => {
705 let conflicts = need_client(client)?.conflicts().to_vec();
706 Ok(json!({ "conflicts": conflicts }))
707 }
708 "rejections" => {
709 let rejections = need_client(client)?.rejections().to_vec();
710 Ok(json!({ "rejections": rejections }))
711 }
712 "commitOutcome" => {
713 let client_commit_id = params
714 .get("clientCommitId")
715 .and_then(Value::as_str)
716 .ok_or_else(|| {
717 client_err(
718 "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
719 )
720 })?;
721 let outcome = need_client(client)?
722 .commit_outcome(client_commit_id)
723 .map_err(client_err)?;
724 Ok(json!({ "outcome": outcome }))
725 }
726 "commitOutcomes" => {
727 let query = serde_json::from_value::<CommitOutcomeQuery>(
728 params.get("query").cloned().unwrap_or_else(|| json!({})),
729 )
730 .map_err(|error| {
731 client_err(format!(
732 "sync.invalid_request: invalid commit outcome query: {error}"
733 ))
734 })?;
735 let outcomes = need_client(client)?
736 .commit_outcomes(query)
737 .map_err(client_err)?;
738 Ok(json!({ "outcomes": outcomes }))
739 }
740 "resolveCommitOutcome" => {
741 let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
742 params.get("input").cloned().ok_or_else(|| {
743 client_err(
744 "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
745 )
746 })?,
747 )
748 .map_err(|error| {
749 client_err(format!(
750 "sync.invalid_request: invalid outcome resolution: {error}"
751 ))
752 })?;
753 let outcome = need_client(client)?
754 .resolve_commit_outcome(input)
755 .map_err(client_err)?;
756 Ok(json!({ "outcome": outcome }))
757 }
758 "pendingCommitIds" => {
759 let ids = need_client(client)?.pending_commit_ids();
760 Ok(json!({ "ids": ids }))
761 }
762 "subscriptionState" => {
763 let id = params
764 .get("id")
765 .and_then(Value::as_str)
766 .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
767 let state = need_client(client)?.subscription_state(id);
768 Ok(json!({ "state": state }))
769 }
770 "schemaFloor" => {
771 let floor = need_client(client)?.schema_floor().cloned();
772 Ok(json!({ "floor": floor }))
773 }
774 "leaseState" => {
775 let lease = need_client(client)?.lease_state().cloned();
776 Ok(json!({ "lease": lease }))
777 }
778 "upgrading" => {
779 let value = need_client(client)?.upgrading();
781 Ok(json!({ "value": value }))
782 }
783 "recreateWithSchema" => {
784 let schema = params
788 .get("schema")
789 .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
790 need_client(client)?
791 .recreate_with_schema(schema)
792 .map_err(client_err)?;
793 Ok(json!({}))
794 }
795 "connectRealtime" => {
796 need_client(client)?
797 .connect_realtime(transport)
798 .map_err(client_err)?;
799 Ok(json!({}))
800 }
801 "disconnectRealtime" => {
802 need_client(client)?.disconnect_realtime(transport);
803 Ok(json!({}))
804 }
805 "syncNeeded" => {
806 let value = need_client(client)?.sync_needed();
807 Ok(json!({ "value": value }))
808 }
809 "setPresence" => {
810 let scope_key = params
811 .get("scopeKey")
812 .and_then(Value::as_str)
813 .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
814 .to_owned();
815 let doc = params.get("doc");
817 let doc_ref = match doc {
818 None | Some(Value::Null) => None,
819 Some(v) => Some(v),
820 };
821 need_client(client)?
822 .set_presence(transport, &scope_key, doc_ref)
823 .map_err(client_err)?;
824 Ok(json!({}))
825 }
826 "presence" => {
827 let scope_key = params
828 .get("scopeKey")
829 .and_then(Value::as_str)
830 .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
831 let peers = need_client(client)?.presence(scope_key);
832 Ok(json!({ "peers": peers }))
833 }
834
835 "messageRoundtrip" => {
837 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
838 match decode_message(&bytes) {
839 Ok(message) => Ok(json!({
840 "ok": true,
841 "bytes": bytes_value(&encode_message(&message)),
842 "renderedJson": render_message(&message).to_string(),
843 })),
844 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
845 }
846 }
847 "segmentRoundtrip" => {
848 let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
849 match decode_rows_segment(&bytes) {
850 Ok(segment) => Ok(json!({
851 "ok": true,
852 "bytes": bytes_value(&encode_rows_segment(&segment)),
853 "renderedJson": render_rows_segment(&segment).to_string(),
854 })),
855 Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
856 }
857 }
858 "realtimeKnown" => {
859 let text = params
860 .get("text")
861 .and_then(Value::as_str)
862 .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
863 let known = matches!(
864 parse_control(text),
865 Ok(ControlMessage::Hello { .. })
866 | Ok(ControlMessage::Wake { .. })
867 | Ok(ControlMessage::Heartbeat { .. })
868 | Ok(ControlMessage::Presence { .. })
869 );
870 Ok(json!({ "value": known }))
871 }
872
873 other => Err(client_err(format!("unknown method {other:?}"))),
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use serde_json::{json, Value};
880 use syncular_client::{
881 SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
882 };
883
884 use super::{dispatch, parse_encryption, CreateEffects};
885
886 struct NoNetwork;
887
888 impl Transport for NoNetwork {
889 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
890 Err(TransportError::new("sync.transport_failed", "offline"))
891 }
892
893 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
894 Err(TransportError::new("sync.transport_failed", "offline"))
895 }
896
897 fn download_segment(
898 &mut self,
899 _request: &SegmentRequest,
900 ) -> Result<Vec<u8>, TransportError> {
901 Err(TransportError::new("sync.transport_failed", "offline"))
902 }
903
904 fn realtime_connect(&mut self) -> Result<(), TransportError> {
905 Ok(())
906 }
907
908 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
909 Ok(())
910 }
911
912 fn realtime_close(&mut self) -> Result<(), TransportError> {
913 Ok(())
914 }
915 }
916
917 fn schema() -> Value {
918 json!({
919 "version": 1,
920 "tables": [{
921 "name": "todos",
922 "columns": [
923 { "name": "id", "type": "string", "nullable": false },
924 { "name": "list_id", "type": "string", "nullable": false }
925 ],
926 "primaryKey": "id",
927 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
928 }]
929 })
930 }
931
932 #[test]
933 fn parses_portable_encryption_keyring_and_key_id_columns() {
934 let key_hex = "2a".repeat(32);
935 let config = parse_encryption(&json!({
936 "keys": { "practice-key-v1": { "$bytes": key_hex } },
937 "keyIdColumns": { "patients": "encryption_key_id" }
938 }))
939 .expect("portable keyring parses");
940 assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
941 assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
942 }
943
944 #[test]
945 fn rejects_non_string_key_id_columns() {
946 let error = parse_encryption(&json!({
947 "keys": {},
948 "keyIdColumns": { "patients": 7 }
949 }))
950 .expect_err("invalid selector must fail");
951 assert!(error.contains("must be a string"), "{error}");
952 }
953
954 #[test]
955 fn native_command_hosts_reject_subscription_identity_rebinds() {
956 let mut transport = NoNetwork;
957 let mut client: Option<SyncClient> = None;
958 let mut effects = CreateEffects::default();
959 dispatch(
960 &mut transport,
961 &mut client,
962 &mut effects,
963 "create",
964 &json!({ "schema": schema() }),
965 )
966 .expect("create client");
967
968 let original = json!({
969 "id": "stable-subscription",
970 "table": "todos",
971 "scopes": { "list_id": ["list-2", "list-1"] },
972 "params": "{\"view\":\"v1\"}"
973 });
974 dispatch(
975 &mut transport,
976 &mut client,
977 &mut effects,
978 "subscribe",
979 &original,
980 )
981 .expect("register subscription");
982 dispatch(
983 &mut transport,
984 &mut client,
985 &mut effects,
986 "subscribe",
987 &json!({
988 "id": "stable-subscription",
989 "table": "todos",
990 "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
991 "params": "{\"view\":\"v1\"}"
992 }),
993 )
994 .expect("canonical re-declaration is idempotent");
995
996 let error = dispatch(
997 &mut transport,
998 &mut client,
999 &mut effects,
1000 "subscribe",
1001 &json!({
1002 "id": "stable-subscription",
1003 "table": "todos",
1004 "scopes": { "list_id": ["list-2"] },
1005 "params": "{\"view\":\"v1\"}"
1006 }),
1007 )
1008 .expect_err("changed native query identity must fail");
1009 assert_eq!(error.0, "client.subscription_intent_mismatch");
1010
1011 let state = dispatch(
1012 &mut transport,
1013 &mut client,
1014 &mut effects,
1015 "subscriptionState",
1016 &json!({ "id": "stable-subscription" }),
1017 )
1018 .expect("subscription state");
1019 assert_eq!(state["state"]["cursor"], -1);
1020 assert_eq!(state["state"]["table"], "todos");
1021 }
1022
1023 #[test]
1024 fn security_preflight_is_fail_closed_until_exact_activation() {
1025 let mut transport = NoNetwork;
1026 let mut client: Option<SyncClient> = None;
1027 let mut effects = CreateEffects::default();
1028 dispatch(
1029 &mut transport,
1030 &mut client,
1031 &mut effects,
1032 "create",
1033 &json!({ "schema": schema(), "securityPreflight": true }),
1034 )
1035 .expect("preflight create");
1036
1037 let lifecycle = dispatch(
1038 &mut transport,
1039 &mut client,
1040 &mut effects,
1041 "securityLifecycle",
1042 &json!({}),
1043 )
1044 .expect("lifecycle");
1045 assert_eq!(lifecycle, json!({ "state": "preflight" }));
1046
1047 let query_error = dispatch(
1048 &mut transport,
1049 &mut client,
1050 &mut effects,
1051 "query",
1052 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1053 )
1054 .expect_err("protected query must fail");
1055 assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1056 let diagnostics_error = dispatch(
1057 &mut transport,
1058 &mut client,
1059 &mut effects,
1060 "diagnosticsSnapshot",
1061 &json!({}),
1062 )
1063 .expect_err("diagnostics table/subscription evidence remains protected");
1064 assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1065
1066 dispatch(
1067 &mut transport,
1068 &mut client,
1069 &mut effects,
1070 "purgeLocalData",
1071 &json!({
1072 "input": {
1073 "purgeId": "directive-1",
1074 "targets": [{
1075 "table": "todos",
1076 "selectors": { "list_id": ["list-1"] }
1077 }]
1078 }
1079 }),
1080 )
1081 .expect("authorized local purge remains available");
1082
1083 let repair_error = dispatch(
1084 &mut transport,
1085 &mut client,
1086 &mut effects,
1087 "rebootstrapLocalData",
1088 &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1089 )
1090 .expect_err("projection repair must remain protected during preflight");
1091 assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1092
1093 dispatch(
1094 &mut transport,
1095 &mut client,
1096 &mut effects,
1097 "activateSecurity",
1098 &json!({}),
1099 )
1100 .expect("activation");
1101 let rows = dispatch(
1102 &mut transport,
1103 &mut client,
1104 &mut effects,
1105 "query",
1106 &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1107 )
1108 .expect("active query");
1109 assert_eq!(rows, json!({ "rows": [] }));
1110
1111 dispatch(
1112 &mut transport,
1113 &mut client,
1114 &mut effects,
1115 "beginSecurityPreflight",
1116 &json!({}),
1117 )
1118 .expect("re-enter preflight");
1119 let blocked = dispatch(
1120 &mut transport,
1121 &mut client,
1122 &mut effects,
1123 "mutate",
1124 &json!({ "mutations": [] }),
1125 )
1126 .expect_err("mutation must be gated");
1127 assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1128 }
1129}