1use reqwest::{Client as HttpClient, Method, StatusCode};
7use secrecy::{ExposeSecret as _, SecretString};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use serde_json::Value;
10use std::{fmt, time::Duration};
11use thiserror::Error;
12use url::Url;
13use uuid::Uuid;
14
15#[derive(Clone, Debug, Deserialize)]
17pub struct RegistryRelease {
18 pub version: String,
19}
20
21pub async fn latest_release() -> Result<RegistryRelease, Error> {
25 latest_release_for("silicon-commit-client").await
26}
27
28pub async fn latest_cli_release() -> Result<RegistryRelease, Error> {
32 latest_release_for("silicon-commit-cli").await
33}
34
35async fn latest_release_for(crate_name: &str) -> Result<RegistryRelease, Error> {
36 let http = HttpClient::builder()
37 .redirect(reqwest::redirect::Policy::none())
38 .timeout(Duration::from_secs(5))
39 .build()
40 .map_err(transport)?;
41 let response = http
42 .get(format!("https://crates.io/api/v1/crates/{crate_name}"))
43 .header(
44 "user-agent",
45 format!("{crate_name}/{}", env!("CARGO_PKG_VERSION")),
46 )
47 .send()
48 .await
49 .map_err(transport)?;
50 if !response.status().is_success() {
51 return Err(Error::Api {
52 status: response.status(),
53 code: "registry_unavailable".into(),
54 request_id: None,
55 });
56 }
57 let body = response.bytes().await.map_err(transport)?;
58 let value: Value = serde_json::from_slice(&body).map_err(Error::Decode)?;
59 let version = value
60 .pointer("/crate/newest")
61 .and_then(Value::as_str)
62 .ok_or(Error::Invalid("registry response missing newest version"))?;
63 Ok(RegistryRelease {
64 version: version.to_owned(),
65 })
66}
67
68#[derive(Debug, Error)]
70pub enum Error {
71 #[error(
72 "invalid API URL: use an HTTP(S) origin or /api/v1/ URL without credentials, query, or fragment"
73 )]
74 InvalidUrl,
75 #[error("invalid request: {0}")]
76 Invalid(&'static str),
77 #[error("request transport failed")]
78 Transport(#[source] reqwest::Error),
79 #[error("HTTP {status}: {code} (request ID: {request_id:?})")]
80 Api {
81 status: StatusCode,
82 code: String,
83 request_id: Option<String>,
84 },
85 #[error("response exceeds the 8 MiB limit")]
86 ResponseTooLarge,
87 #[error("response did not match the expected JSON contract")]
88 Decode(#[source] serde_json::Error),
89}
90impl Error {
91 pub fn is_unauthenticated(&self) -> bool {
93 matches!(
94 self,
95 Self::Api {
96 status: StatusCode::UNAUTHORIZED,
97 ..
98 }
99 )
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct Mutation {
106 key: String,
107 version: Option<i64>,
108}
109impl Default for Mutation {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114impl Mutation {
115 pub fn new() -> Self {
116 Self {
117 key: Uuid::new_v4().to_string(),
118 version: None,
119 }
120 }
121 pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
122 let key = key.into();
123 if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
124 return Err(Error::Invalid(
125 "idempotency key must be 8–255 visible ASCII characters",
126 ));
127 }
128 Ok(Self { key, version: None })
129 }
130 pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
131 if version < 0 {
132 return Err(Error::Invalid("version cannot be negative"));
133 }
134 self.version = Some(version);
135 Ok(self)
136 }
137 pub fn key(&self) -> &str {
138 &self.key
139 }
140}
141
142#[derive(Clone, Debug)]
144pub struct Client {
145 http: HttpClient,
146 base: Url,
147 bearer: Option<SecretString>,
148 telemetry: bool,
149 source: &'static str,
150 test_key: Option<SecretString>,
151 org_id: Option<String>,
152 mutation: Option<Mutation>,
153}
154
155#[derive(Clone, Deserialize, Serialize)]
157pub struct SessionTokens {
158 pub access_token: String,
159 pub refresh_token: String,
160 pub token_type: Value,
161 pub expires_in: i64,
162 pub scope: String,
163 pub actor: Value,
164 pub org_id: Option<String>,
165}
166pub fn validate_session_context(actor: &Value, org_id: Option<&str>) -> Result<(), Error> {
168 let (prefix, maximum) = match actor["type"].as_str() {
169 Some("carbon") => ("c:", 30),
170 Some("silicon") => ("si:", 50),
171 _ => return Err(Error::Invalid("session requires a Carbon or Silicon actor")),
172 };
173 let valid_id = actor["public_id"]
174 .as_str()
175 .and_then(|id| id.strip_prefix(prefix))
176 .is_some_and(|id| {
177 (3..=maximum).contains(&id.len())
178 && id.bytes().all(|byte| {
179 byte.is_ascii_lowercase()
180 || byte.is_ascii_digit()
181 || matches!(byte, b'_' | b'-')
182 })
183 });
184 let valid_org = org_id.is_some_and(|org| {
185 (3..=50).contains(&org.len())
186 && org.bytes().all(|byte| {
187 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
188 })
189 });
190 if !valid_id || !valid_org {
191 return Err(Error::Invalid(
192 "session requires a canonical actor and one organization; sign in again",
193 ));
194 }
195 Ok(())
196}
197impl SessionTokens {
198 pub fn validate(&self) -> Result<(), Error> {
200 validate_session_context(&self.actor, self.org_id.as_deref())?;
201 if self.access_token.is_empty()
202 || self.refresh_token.is_empty()
203 || self.expires_in <= 0
204 || self.token_type != "Bearer"
205 || self
206 .scope
207 .split_whitespace()
208 .any(|scope| scope.starts_with("obo:"))
209 {
210 return Err(Error::Invalid("invalid ordinary IAM 5 token response"));
211 }
212 Ok(())
213 }
214}
215impl fmt::Debug for SessionTokens {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.debug_struct("SessionTokens")
218 .field("tokens", &"<redacted>")
219 .field("org_id", &self.org_id)
220 .finish()
221 }
222}
223
224impl Client {
225 pub fn new(base_url: &str) -> Result<Self, Error> {
227 let mut base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
228 if !matches!(base.scheme(), "http" | "https")
229 || base.host_str().is_none()
230 || !base.username().is_empty()
231 || base.password().is_some()
232 || base.query().is_some()
233 || base.fragment().is_some()
234 || !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/")
235 {
236 return Err(Error::InvalidUrl);
237 }
238 base.set_path("/api/v1/");
239 let http = HttpClient::builder()
240 .redirect(reqwest::redirect::Policy::none())
241 .connect_timeout(Duration::from_secs(5))
242 .timeout(Duration::from_secs(30))
243 .user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
244 .build()
245 .map_err(transport)?;
246 Ok(Self {
247 http,
248 base,
249 bearer: None,
250 telemetry: true,
251 source: "rust-client",
252 test_key: None,
253 org_id: None,
254 mutation: None,
255 })
256 }
257 pub fn with_bearer(mut self, token: impl Into<String>) -> Self {
258 self.bearer = Some(SecretString::from(token.into()));
259 self
260 }
261 pub fn with_test_key(mut self, key: impl Into<String>) -> Result<Self, Error> {
262 let key = key.into();
263 if !valid_testing_selector(&key) {
264 return Err(Error::Invalid(
265 "test selector must be an IAM test app_secret or a legacy 32-character Commit key",
266 ));
267 }
268 self.test_key = Some(SecretString::from(key));
269 Ok(self)
270 }
271 pub fn with_test_app_secret(self, secret: impl Into<String>) -> Result<Self, Error> {
273 self.with_test_key(secret)
274 }
275 pub async fn contracts(&self) -> Result<Value, Error> {
277 self.get(&["contracts"], &[]).await
278 }
279 pub fn with_telemetry(mut self, enabled: bool) -> Self {
281 self.telemetry = enabled;
282 self
283 }
284 pub fn with_source(mut self, source: &'static str) -> Result<Self, Error> {
286 if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
287 return Err(Error::Invalid("unknown client source"));
288 }
289 self.source = source;
290 Ok(self)
291 }
292 pub async fn email_settings(&self) -> Result<Value, Error> {
294 self.get(&["email-settings"], &[]).await
295 }
296 pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
298 self.write(Method::PUT, &["email-settings"], body).await
299 }
300 pub async fn report(&self, body: &Value) -> Result<Value, Error> {
302 self.write(Method::POST, &["reports"], body).await
303 }
304 pub async fn testing_context(&self) -> Result<Value, Error> {
305 self.get(&["testing-context"], &[]).await
306 }
307 pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
309 self.write(
310 Method::POST,
311 &["projects", project, "tasks", task, "claim"],
312 &serde_json::json!({}),
313 )
314 .await
315 }
316 pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
318 self.execute(
319 Method::DELETE,
320 &["projects", project, "tasks", task],
321 None,
322 &[],
323 false,
324 )
325 .await
326 }
327 pub async fn project_versions(
329 &self,
330 project: &str,
331 query: &[(&str, &str)],
332 ) -> Result<Value, Error> {
333 self.get(&["projects", project, "versions"], query).await
334 }
335 pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
337 self.get(
338 &["projects", project, "versions", &version.to_string()],
339 &[],
340 )
341 .await
342 }
343 pub fn with_org_id(mut self, org_id: impl Into<String>) -> Self {
344 self.org_id = Some(org_id.into());
345 self
346 }
347 pub fn with_mutation(mut self, mutation: Mutation) -> Self {
349 self.mutation = Some(mutation);
350 self
351 }
352 pub fn base_url(&self) -> &Url {
353 &self.base
354 }
355 pub fn new_idempotency_key() -> String {
356 Mutation::new().key
357 }
358 pub async fn health(&self) -> Result<Value, Error> {
359 self.execute(Method::GET, &["healthz"], None, &[], true)
360 .await
361 }
362 pub async fn ready(&self) -> Result<Value, Error> {
363 self.execute(Method::GET, &["readyz"], None, &[], true)
364 .await
365 }
366 pub async fn version(&self) -> Result<Value, Error> {
367 let mut public = self.clone();
369 public.bearer = None;
370 public.org_id = None;
371 public.test_key = None;
372 public.get(&["version"], &[]).await
373 }
374 pub async fn iam(&self) -> Result<Value, Error> {
376 let mut public = self.clone();
377 public.bearer = None;
378 public.org_id = None;
379 public.test_key = None;
380 public.get(&["iam"], &[]).await
381 }
382 pub async fn login_status(&self) -> Result<Value, Error> {
386 if self
387 .bearer
388 .as_ref()
389 .is_none_or(|token| token.expose_secret().is_empty())
390 {
391 return Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}));
392 }
393 match self.get(&["auth", "status"], &[]).await {
394 Err(error) if error.is_unauthenticated() => {
395 Ok(serde_json::json!({"authenticated": false, "actor": null, "org_id": null}))
396 }
397 result => result,
398 }
399 }
400 pub async fn login_with_slt(&self, slt: &str) -> Result<SessionTokens, Error> {
401 let mut body = serde_json::json!({"slt":slt});
403 if let Some(org) = &self.org_id {
404 body["org_id"] = org.clone().into();
405 }
406 let mut broker = self.clone();
407 broker.bearer = None;
408 let tokens: SessionTokens = decode(
409 broker
410 .write(Method::POST, &["auth", "login"], &body)
411 .await?,
412 )?;
413 tokens.validate()?;
414 if self
415 .org_id
416 .as_ref()
417 .is_some_and(|org| Some(org) != tokens.org_id.as_ref())
418 {
419 return Err(Error::Invalid("login returned a different organization"));
420 }
421 Ok(tokens)
422 }
423 pub async fn refresh_session(&self, refresh_token: &str) -> Result<SessionTokens, Error> {
425 let mut broker = self.clone();
426 broker.bearer = None;
427 broker.org_id = None;
428 let tokens: SessionTokens = decode(
429 broker
430 .write(
431 Method::POST,
432 &["auth", "refresh"],
433 &serde_json::json!({"refresh_token":refresh_token}),
434 )
435 .await?,
436 )?;
437 tokens.validate()?;
438 if self
439 .org_id
440 .as_ref()
441 .is_some_and(|org| Some(org) != tokens.org_id.as_ref())
442 {
443 return Err(Error::Invalid("refresh returned a different organization"));
444 }
445 Ok(tokens)
446 }
447 pub async fn logout(&self, token: &str) -> Result<(), Error> {
448 self.write(
449 Method::POST,
450 &["auth", "logout"],
451 &serde_json::json!({"token":token}),
452 )
453 .await?;
454 Ok(())
455 }
456 pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
458 self.get(&["todos"], query).await
459 }
460 pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
462 self.get(&["todos", todo_id], &[]).await
463 }
464 pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
466 self.write(Method::POST, &["todos"], body).await
467 }
468 pub async fn update_todo<T: Serialize + ?Sized>(
470 &self,
471 todo_id: &str,
472 body: &T,
473 ) -> Result<Value, Error> {
474 self.write(Method::PATCH, &["todos", todo_id], body).await
475 }
476 pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
478 self.execute(Method::DELETE, &["todos", todo_id], None, &[], false)
479 .await
480 }
481 pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
483 self.get(&["todos", todo_id, "notes"], query).await
484 }
485 pub async fn add_note<T: Serialize + ?Sized>(
487 &self,
488 todo_id: &str,
489 body: &T,
490 ) -> Result<Value, Error> {
491 self.write(Method::POST, &["todos", todo_id, "notes"], body)
492 .await
493 }
494 pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
496 self.get(&["todos", todo_id, "notification-subscription"], &[])
497 .await
498 }
499 pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
501 &self,
502 todo_id: &str,
503 body: &T,
504 ) -> Result<Value, Error> {
505 self.write(
506 Method::PUT,
507 &["todos", todo_id, "notification-subscription"],
508 body,
509 )
510 .await
511 }
512 pub async fn notification_settings(&self) -> Result<Value, Error> {
514 self.get(&["notification-settings"], &[]).await
515 }
516 pub async fn update_notification_settings<T: Serialize + ?Sized>(
518 &self,
519 body: &T,
520 ) -> Result<Value, Error> {
521 self.write(Method::PUT, &["notification-settings"], body)
522 .await
523 }
524 pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
526 self.get(&["projects"], query).await
527 }
528 pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
530 self.get(&["projects", project_id], &[]).await
531 }
532 pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
534 self.write(Method::POST, &["projects"], body).await
535 }
536 pub async fn update_project<T: Serialize + ?Sized>(
538 &self,
539 project_id: &str,
540 body: &T,
541 ) -> Result<Value, Error> {
542 self.write(Method::PATCH, &["projects", project_id], body)
543 .await
544 }
545 pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
547 self.get(&["projects", project_id, "diary"], &[]).await
548 }
549 pub async fn replace_project_diary<T: Serialize + ?Sized>(
551 &self,
552 project_id: &str,
553 body: &T,
554 ) -> Result<Value, Error> {
555 self.write(Method::PUT, &["projects", project_id, "diary"], body)
556 .await
557 }
558 pub async fn project_tasks(
560 &self,
561 project_id: &str,
562 query: &[(&str, &str)],
563 ) -> Result<Value, Error> {
564 self.get(&["projects", project_id, "tasks"], query).await
565 }
566 pub async fn project_entries(
568 &self,
569 project_id: &str,
570 query: &[(&str, &str)],
571 ) -> Result<Value, Error> {
572 self.get(&["projects", project_id, "entries"], query).await
573 }
574 pub async fn create_project_task<T: Serialize + ?Sized>(
576 &self,
577 project_id: &str,
578 body: &T,
579 ) -> Result<Value, Error> {
580 self.write(Method::POST, &["projects", project_id, "tasks"], body)
581 .await
582 }
583 pub async fn update_project_task<T: Serialize + ?Sized>(
585 &self,
586 project_id: &str,
587 task_id: &str,
588 body: &T,
589 ) -> Result<Value, Error> {
590 self.write(
591 Method::PATCH,
592 &["projects", project_id, "tasks", task_id],
593 body,
594 )
595 .await
596 }
597 pub async fn create_project_blocker<T: Serialize + ?Sized>(
599 &self,
600 project_id: &str,
601 body: &T,
602 ) -> Result<Value, Error> {
603 self.write(Method::POST, &["projects", project_id, "blockers"], body)
604 .await
605 }
606 pub async fn create_project_update<T: Serialize + ?Sized>(
608 &self,
609 project_id: &str,
610 body: &T,
611 ) -> Result<Value, Error> {
612 self.write(Method::POST, &["projects", project_id, "updates"], body)
613 .await
614 }
615 pub async fn complete_project<T: Serialize + ?Sized>(
617 &self,
618 project_id: &str,
619 body: &T,
620 ) -> Result<Value, Error> {
621 self.write(Method::POST, &["projects", project_id, "completion"], body)
622 .await
623 }
624 pub async fn list_test_environments(&self) -> Result<Value, Error> {
626 self.get(&["test-environments"], &[]).await
627 }
628 pub async fn create_test_environment<T: Serialize + ?Sized>(
631 &self,
632 body: &T,
633 ) -> Result<Value, Error> {
634 self.write(Method::POST, &["test-environments"], body).await
635 }
636 pub async fn retrieve_test_environment_key(&self, id: &str) -> Result<Value, Error> {
638 self.get(&["test-environments", id, "key"], &[]).await
639 }
640 pub async fn rotate_test_environment(&self, id: &str) -> Result<Value, Error> {
642 self.execute(
643 Method::POST,
644 &["test-environments", id, "rotate"],
645 None,
646 &[],
647 false,
648 )
649 .await
650 }
651 pub async fn delete_test_environment(&self, id: &str) -> Result<Value, Error> {
653 self.execute(Method::DELETE, &["test-environments", id], None, &[], false)
654 .await
655 }
656 pub async fn restore_test_environment(&self, id: &str) -> Result<Value, Error> {
657 self.execute(
658 Method::POST,
659 &["test-environments", id, "restore"],
660 None,
661 &[],
662 false,
663 )
664 .await
665 }
666 pub async fn clean_test_environment(&self, id: &str) -> Result<Value, Error> {
667 self.execute(
668 Method::POST,
669 &["test-environments", id, "clean"],
670 None,
671 &[],
672 false,
673 )
674 .await
675 }
676 async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
677 self.execute(Method::GET, path, None, query, false).await
678 }
679 async fn write<T: Serialize + ?Sized>(
680 &self,
681 method: Method,
682 path: &[&str],
683 body: &T,
684 ) -> Result<Value, Error> {
685 let body = serde_json::to_vec(body).map_err(Error::Decode)?;
686 self.execute(method, path, Some(body), &[], false).await
687 }
688 fn url(&self, path: &[&str], root: bool) -> Result<Url, Error> {
689 if path
690 .iter()
691 .any(|p| p.is_empty() || matches!(*p, "." | ".."))
692 {
693 return Err(Error::Invalid(
694 "empty and dot resource identifiers are forbidden",
695 ));
696 }
697 let mut url = self.base.clone();
698 {
699 let mut segments = url.path_segments_mut().map_err(|_| Error::InvalidUrl)?;
700 if root {
701 segments.clear();
702 } else {
703 segments.pop_if_empty();
704 }
705 segments.extend(path);
706 }
707 Ok(url)
708 }
709 async fn execute(
710 &self,
711 method: Method,
712 path: &[&str],
713 body: Option<Vec<u8>>,
714 query: &[(&str, &str)],
715 root: bool,
716 ) -> Result<Value, Error> {
717 let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
718 let mut req = self
719 .http
720 .request(method, self.url(path, root)?)
721 .query(query)
722 .header("x-commit-supported-versions", "1")
723 .header("x-commit-client", self.source)
724 .header(
725 "x-commit-telemetry",
726 if self.telemetry { "on" } else { "off" },
727 );
728 if !root {
729 if let Some(token) = &self.bearer {
730 req = req.bearer_auth(token.expose_secret());
731 }
732 if let Some(org) = &self.org_id {
733 req = req.header("x-org-id", org);
734 }
735 if let Some(key) = &self.test_key {
736 let key = key.expose_secret();
737 if !valid_testing_selector(key) {
738 return Err(Error::Invalid("invalid testing application secret"));
739 }
740 req = req.header("x-testing-environment-key", key);
741 }
742 }
743 if is_mutation {
744 let mutation = self.mutation.clone().unwrap_or_default();
745 req = req.header("idempotency-key", mutation.key);
746 if let Some(version) = mutation.version {
747 req = req.header("if-match", format!("\"{version}\""));
748 }
749 }
750 if let Some(body) = body {
751 req = req.header("content-type", "application/json").body(body);
752 }
753 let mut response = req.send().await.map_err(transport)?;
754 if response
755 .headers()
756 .get("x-commit-api-version")
757 .is_some_and(|v| v != "1")
758 {
759 return Err(Error::Invalid(
760 "server selected an unsupported API contract",
761 ));
762 }
763 let status = response.status();
764 let request_id = response
765 .headers()
766 .get("x-request-id")
767 .and_then(|v| v.to_str().ok())
768 .map(str::to_owned);
769 const LIMIT: usize = 8 * 1024 * 1024;
770 if response.content_length().is_some_and(|n| n > LIMIT as u64) {
771 return Err(Error::ResponseTooLarge);
772 }
773 let mut bytes = Vec::new();
774 while let Some(chunk) = response.chunk().await.map_err(transport)? {
775 if bytes.len() + chunk.len() > LIMIT {
776 return Err(Error::ResponseTooLarge);
777 }
778 bytes.extend_from_slice(&chunk);
779 }
780 if !status.is_success() {
781 let parsed: Option<Value> = serde_json::from_slice(&bytes).ok();
782 let code = parsed
783 .as_ref()
784 .and_then(|v| v.pointer("/error/code"))
785 .and_then(Value::as_str)
786 .filter(|s| {
787 s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
788 })
789 .unwrap_or("http_error")
790 .to_owned();
791 return Err(Error::Api {
792 status,
793 code,
794 request_id,
795 });
796 }
797 if bytes.is_empty() {
798 return Ok(Value::Null);
799 }
800 serde_json::from_slice(&bytes).map_err(Error::Decode)
801 }
802}
803fn transport(error: reqwest::Error) -> Error {
804 Error::Transport(error.without_url())
805}
806fn decode<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
807 serde_json::from_value(value).map_err(Error::Decode)
808}
809
810fn valid_testing_selector(key: &str) -> bool {
811 (key.len() == 32 && key.bytes().all(|b| b.is_ascii_alphanumeric()))
812 || (key.len() == 47
813 && key.starts_with("ask_")
814 && key[4..]
815 .bytes()
816 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
817}