feat(client): v2 REPL over SDK with categorized help and tab-completion

925-line REPL replacing the 3317-line monolith — delegates all crypto,
MLS, and RPC to quicproquo-sdk. 20 commands across 6 categories
(messaging, groups, account, keys, utility, debug), rustyline tab
completion, background event listener, auto-server-launch.

Also adds SDK accessor methods (server_addr_string, config_state_path),
WS bridge register handler, and README table formatting cleanup.
This commit is contained in:
2026-03-04 13:02:54 +01:00
parent 99f9abe9ed
commit cab03bd3f7
7 changed files with 1810 additions and 325 deletions

View File

@@ -165,6 +165,7 @@ async fn dispatch(state: &WsBridgeState, req: RpcRequest) -> RpcResponse {
"send" => handle_send(state, req.id, &req.params),
"receive" => handle_receive(state, req.id, &req.params),
"deleteAccount" => handle_delete_account(state, req.id, &req.params),
"register" => handle_register(state, req.id, &req.params),
_ => RpcResponse::error(req.id, format!("unknown method: {}", req.method)),
}
}
@@ -175,6 +176,89 @@ fn handle_health(id: serde_json::Value) -> RpcResponse {
RpcResponse::success(id, serde_json::json!("ok"))
}
fn handle_register(
state: &WsBridgeState,
id: serde_json::Value,
params: &serde_json::Value,
) -> RpcResponse {
// Only allow in insecure-auth mode (development/demo).
if !state.allow_insecure_auth {
return RpcResponse::error(id, "register is only available in --allow-insecure-auth mode");
}
// Rate limit.
let auth_ctx = match extract_auth(state, params) {
Ok(ctx) => ctx,
Err(e) => return RpcResponse::error(id, e),
};
if let Err(e) = ws_check_rate_limit(state, &auth_ctx) {
return RpcResponse::error(id, e);
}
// Validate username.
let username = match params.get("username").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u,
_ => return RpcResponse::error(id, "missing or empty 'username' param"),
};
if username.len() > 32 {
return RpcResponse::error(id, "username must be at most 32 characters");
}
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return RpcResponse::error(id, "username must be alphanumeric or underscore only");
}
// Validate identity key.
let ik_b64 = match params.get("identityKey").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
_ => return RpcResponse::error(id, "missing or empty 'identityKey' param"),
};
let identity_key = match B64.decode(ik_b64) {
Ok(k) => k,
Err(e) => return RpcResponse::error(id, format!("bad base64 identityKey: {e}")),
};
if identity_key.len() != 32 {
return RpcResponse::error(id, "identityKey must be 32 bytes");
}
// Check if username is already taken by a different key.
match state.store.get_user_identity_key(username) {
Ok(Some(existing)) if existing == identity_key => {
// Idempotent: same key, return success.
return RpcResponse::success(
id,
serde_json::json!({
"username": username,
"identityKey": B64.encode(&identity_key),
}),
);
}
Ok(Some(_)) => {
return RpcResponse::error(id, "username already taken");
}
Ok(None) => {} // Available, proceed.
Err(e) => return RpcResponse::error(id, format!("storage error: {e}")),
}
// Store the mapping.
if let Err(e) = state.store.store_user_identity_key(username, identity_key.clone()) {
return RpcResponse::error(id, format!("storage error: {e}"));
}
tracing::info!(
username = %username,
key_prefix = %hex::encode(&identity_key[..4]),
"audit: ws_bridge register"
);
RpcResponse::success(
id,
serde_json::json!({
"username": username,
"identityKey": B64.encode(&identity_key),
}),
)
}
async fn handle_resolve_user(
state: &WsBridgeState,
id: serde_json::Value,