+543
-627
Cargo.lock
+543
-627
Cargo.lock
···
3
3
version = 4
4
4
5
5
[[package]]
6
-
name = "addr2line"
7
-
version = "0.24.2"
8
-
source = "registry+https://github.com/rust-lang/crates.io-index"
9
-
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
10
-
dependencies = [
11
-
"gimli",
12
-
]
13
-
14
-
[[package]]
15
-
name = "adler2"
16
-
version = "2.0.0"
17
-
source = "registry+https://github.com/rust-lang/crates.io-index"
18
-
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
19
-
20
-
[[package]]
21
6
name = "aho-corasick"
22
-
version = "1.1.3"
7
+
version = "1.1.4"
23
8
source = "registry+https://github.com/rust-lang/crates.io-index"
24
-
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
9
+
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
25
10
dependencies = [
26
11
"memchr",
27
12
]
···
34
19
35
20
[[package]]
36
21
name = "anstream"
37
-
version = "0.6.19"
22
+
version = "0.6.21"
38
23
source = "registry+https://github.com/rust-lang/crates.io-index"
39
-
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
24
+
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
40
25
dependencies = [
41
26
"anstyle",
42
27
"anstyle-parse",
···
49
34
50
35
[[package]]
51
36
name = "anstyle"
52
-
version = "1.0.11"
37
+
version = "1.0.13"
53
38
source = "registry+https://github.com/rust-lang/crates.io-index"
54
-
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
39
+
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
55
40
56
41
[[package]]
57
42
name = "anstyle-parse"
···
64
49
65
50
[[package]]
66
51
name = "anstyle-query"
67
-
version = "1.1.3"
52
+
version = "1.1.4"
68
53
source = "registry+https://github.com/rust-lang/crates.io-index"
69
-
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
54
+
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
70
55
dependencies = [
71
-
"windows-sys 0.59.0",
56
+
"windows-sys 0.60.2",
72
57
]
73
58
74
59
[[package]]
75
60
name = "anstyle-wincon"
76
-
version = "3.0.9"
61
+
version = "3.0.10"
77
62
source = "registry+https://github.com/rust-lang/crates.io-index"
78
-
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
63
+
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
79
64
dependencies = [
80
65
"anstyle",
81
66
"once_cell_polyfill",
82
-
"windows-sys 0.59.0",
67
+
"windows-sys 0.60.2",
83
68
]
84
69
85
70
[[package]]
86
71
name = "anyhow"
87
-
version = "1.0.98"
72
+
version = "1.0.100"
88
73
source = "registry+https://github.com/rust-lang/crates.io-index"
89
-
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
74
+
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
90
75
91
76
[[package]]
92
77
name = "async-trait"
93
-
version = "0.1.88"
78
+
version = "0.1.89"
94
79
source = "registry+https://github.com/rust-lang/crates.io-index"
95
-
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
80
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
96
81
dependencies = [
97
82
"proc-macro2",
98
83
"quote",
99
-
"syn",
84
+
"syn 2.0.109",
100
85
]
101
86
102
87
[[package]]
···
127
112
"serde_ipld_dagcbor",
128
113
"serde_json",
129
114
"sha2",
130
-
"thiserror 2.0.12",
115
+
"thiserror 2.0.17",
131
116
"tokio",
132
117
]
133
118
···
149
134
"secrecy",
150
135
"serde",
151
136
"serde_json",
152
-
"thiserror 2.0.12",
137
+
"thiserror 2.0.17",
153
138
"tokio",
154
139
"tracing",
155
140
"urlencoding",
156
141
]
157
142
158
143
[[package]]
144
+
name = "atproto-extras"
145
+
version = "0.13.0"
146
+
dependencies = [
147
+
"anyhow",
148
+
"async-trait",
149
+
"atproto-identity",
150
+
"atproto-record",
151
+
"clap",
152
+
"regex",
153
+
"reqwest",
154
+
"serde_json",
155
+
"tokio",
156
+
]
157
+
158
+
[[package]]
159
159
name = "atproto-identity"
160
160
version = "0.13.0"
161
161
dependencies = [
···
175
175
"serde",
176
176
"serde_ipld_dagcbor",
177
177
"serde_json",
178
-
"thiserror 2.0.12",
178
+
"thiserror 2.0.17",
179
179
"tokio",
180
180
"tracing",
181
181
"url",
···
195
195
"http",
196
196
"serde",
197
197
"serde_json",
198
-
"thiserror 2.0.12",
198
+
"thiserror 2.0.17",
199
199
"tokio",
200
200
"tokio-util",
201
201
"tokio-websockets",
···
218
218
"reqwest",
219
219
"serde",
220
220
"serde_json",
221
-
"thiserror 2.0.12",
221
+
"thiserror 2.0.17",
222
222
"tokio",
223
223
"tracing",
224
224
"zeroize",
···
249
249
"serde_ipld_dagcbor",
250
250
"serde_json",
251
251
"sha2",
252
-
"thiserror 2.0.12",
252
+
"thiserror 2.0.17",
253
253
"tokio",
254
254
"tracing",
255
255
"ulid",
···
267
267
"reqwest",
268
268
"serde",
269
269
"serde_json",
270
-
"thiserror 2.0.12",
270
+
"thiserror 2.0.17",
271
271
"zeroize",
272
272
]
273
273
···
294
294
"secrecy",
295
295
"serde",
296
296
"serde_json",
297
-
"thiserror 2.0.12",
297
+
"thiserror 2.0.17",
298
298
"tokio",
299
299
"tracing",
300
300
"zeroize",
···
317
317
"serde_ipld_dagcbor",
318
318
"serde_json",
319
319
"sha2",
320
-
"thiserror 2.0.12",
320
+
"thiserror 2.0.17",
321
+
"tokio",
322
+
]
323
+
324
+
[[package]]
325
+
name = "atproto-tap"
326
+
version = "0.13.0"
327
+
dependencies = [
328
+
"atproto-client",
329
+
"atproto-identity",
330
+
"base64",
331
+
"clap",
332
+
"compact_str",
333
+
"futures",
334
+
"http",
335
+
"itoa",
336
+
"reqwest",
337
+
"serde",
338
+
"serde_json",
339
+
"thiserror 2.0.17",
321
340
"tokio",
341
+
"tokio-stream",
342
+
"tokio-websockets",
343
+
"tracing",
344
+
"tracing-subscriber",
322
345
]
323
346
324
347
[[package]]
···
342
365
"reqwest-middleware",
343
366
"serde",
344
367
"serde_json",
345
-
"thiserror 2.0.12",
368
+
"thiserror 2.0.17",
346
369
"tokio",
347
370
"tracing",
348
371
]
···
369
392
"reqwest-middleware",
370
393
"serde",
371
394
"serde_json",
372
-
"thiserror 2.0.12",
395
+
"thiserror 2.0.17",
373
396
"tokio",
374
397
"tracing",
375
398
]
376
399
377
400
[[package]]
378
401
name = "autocfg"
379
-
version = "1.4.0"
402
+
version = "1.5.0"
380
403
source = "registry+https://github.com/rust-lang/crates.io-index"
381
-
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
404
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
382
405
383
406
[[package]]
384
407
name = "axum"
385
-
version = "0.8.4"
408
+
version = "0.8.6"
386
409
source = "registry+https://github.com/rust-lang/crates.io-index"
387
-
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
410
+
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
388
411
dependencies = [
389
412
"axum-core",
390
413
"axum-macros",
···
402
425
"mime",
403
426
"percent-encoding",
404
427
"pin-project-lite",
405
-
"rustversion",
406
-
"serde",
428
+
"serde_core",
407
429
"serde_json",
408
430
"serde_path_to_error",
409
431
"serde_urlencoded",
···
417
439
418
440
[[package]]
419
441
name = "axum-core"
420
-
version = "0.5.2"
442
+
version = "0.5.5"
421
443
source = "registry+https://github.com/rust-lang/crates.io-index"
422
-
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
444
+
checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22"
423
445
dependencies = [
424
446
"bytes",
425
447
"futures-core",
···
428
450
"http-body-util",
429
451
"mime",
430
452
"pin-project-lite",
431
-
"rustversion",
432
453
"sync_wrapper",
433
454
"tower-layer",
434
455
"tower-service",
···
443
464
dependencies = [
444
465
"proc-macro2",
445
466
"quote",
446
-
"syn",
447
-
]
448
-
449
-
[[package]]
450
-
name = "backtrace"
451
-
version = "0.3.75"
452
-
source = "registry+https://github.com/rust-lang/crates.io-index"
453
-
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
454
-
dependencies = [
455
-
"addr2line",
456
-
"cfg-if",
457
-
"libc",
458
-
"miniz_oxide",
459
-
"object",
460
-
"rustc-demangle",
461
-
"windows-targets 0.52.6",
467
+
"syn 2.0.109",
462
468
]
463
469
464
470
[[package]]
···
474
480
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
475
481
476
482
[[package]]
483
+
name = "base256emoji"
484
+
version = "1.0.2"
485
+
source = "registry+https://github.com/rust-lang/crates.io-index"
486
+
checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c"
487
+
dependencies = [
488
+
"const-str",
489
+
"match-lookup",
490
+
]
491
+
492
+
[[package]]
477
493
name = "base64"
478
494
version = "0.22.1"
479
495
source = "registry+https://github.com/rust-lang/crates.io-index"
···
481
497
482
498
[[package]]
483
499
name = "base64ct"
484
-
version = "1.7.3"
500
+
version = "1.8.0"
485
501
source = "registry+https://github.com/rust-lang/crates.io-index"
486
-
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
502
+
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
487
503
488
504
[[package]]
489
505
name = "bitflags"
490
-
version = "2.9.1"
506
+
version = "2.10.0"
491
507
source = "registry+https://github.com/rust-lang/crates.io-index"
492
-
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
508
+
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
493
509
494
510
[[package]]
495
511
name = "block-buffer"
···
502
518
503
519
[[package]]
504
520
name = "bumpalo"
505
-
version = "3.17.0"
521
+
version = "3.19.0"
506
522
source = "registry+https://github.com/rust-lang/crates.io-index"
507
-
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
523
+
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
508
524
509
525
[[package]]
510
526
name = "bytes"
···
513
529
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
514
530
515
531
[[package]]
532
+
name = "castaway"
533
+
version = "0.2.4"
534
+
source = "registry+https://github.com/rust-lang/crates.io-index"
535
+
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
536
+
dependencies = [
537
+
"rustversion",
538
+
]
539
+
540
+
[[package]]
516
541
name = "cbor4ii"
517
542
version = "0.2.14"
518
543
source = "registry+https://github.com/rust-lang/crates.io-index"
···
523
548
524
549
[[package]]
525
550
name = "cc"
526
-
version = "1.2.24"
551
+
version = "1.2.44"
527
552
source = "registry+https://github.com/rust-lang/crates.io-index"
528
-
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
553
+
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
529
554
dependencies = [
555
+
"find-msvc-tools",
530
556
"jobserver",
531
557
"libc",
532
558
"shlex",
···
534
560
535
561
[[package]]
536
562
name = "cfg-if"
537
-
version = "1.0.0"
563
+
version = "1.0.4"
538
564
source = "registry+https://github.com/rust-lang/crates.io-index"
539
-
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
565
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
540
566
541
567
[[package]]
542
568
name = "cfg_aliases"
···
546
572
547
573
[[package]]
548
574
name = "chrono"
549
-
version = "0.4.41"
575
+
version = "0.4.42"
550
576
source = "registry+https://github.com/rust-lang/crates.io-index"
551
-
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
577
+
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
552
578
dependencies = [
553
579
"num-traits",
554
580
"serde",
···
570
596
571
597
[[package]]
572
598
name = "clap"
573
-
version = "4.5.40"
599
+
version = "4.5.51"
574
600
source = "registry+https://github.com/rust-lang/crates.io-index"
575
-
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
601
+
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
576
602
dependencies = [
577
603
"clap_builder",
578
604
"clap_derive",
···
580
606
581
607
[[package]]
582
608
name = "clap_builder"
583
-
version = "4.5.40"
609
+
version = "4.5.51"
584
610
source = "registry+https://github.com/rust-lang/crates.io-index"
585
-
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
611
+
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
586
612
dependencies = [
587
613
"anstream",
588
614
"anstyle",
···
592
618
593
619
[[package]]
594
620
name = "clap_derive"
595
-
version = "4.5.40"
621
+
version = "4.5.49"
596
622
source = "registry+https://github.com/rust-lang/crates.io-index"
597
-
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
623
+
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
598
624
dependencies = [
599
625
"heck",
600
626
"proc-macro2",
601
627
"quote",
602
-
"syn",
628
+
"syn 2.0.109",
603
629
]
604
630
605
631
[[package]]
606
632
name = "clap_lex"
607
-
version = "0.7.5"
633
+
version = "0.7.6"
608
634
source = "registry+https://github.com/rust-lang/crates.io-index"
609
-
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
635
+
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
610
636
611
637
[[package]]
612
638
name = "colorchoice"
···
615
641
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
616
642
617
643
[[package]]
644
+
name = "compact_str"
645
+
version = "0.8.1"
646
+
source = "registry+https://github.com/rust-lang/crates.io-index"
647
+
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
648
+
dependencies = [
649
+
"castaway",
650
+
"cfg-if",
651
+
"itoa",
652
+
"rustversion",
653
+
"ryu",
654
+
"serde",
655
+
"static_assertions",
656
+
]
657
+
658
+
[[package]]
618
659
name = "const-oid"
619
660
version = "0.9.6"
620
661
source = "registry+https://github.com/rust-lang/crates.io-index"
621
662
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
622
663
623
664
[[package]]
665
+
name = "const-str"
666
+
version = "0.4.3"
667
+
source = "registry+https://github.com/rust-lang/crates.io-index"
668
+
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
669
+
670
+
[[package]]
624
671
name = "core-foundation"
625
672
version = "0.9.4"
626
673
source = "registry+https://github.com/rust-lang/crates.io-index"
···
739
786
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
740
787
dependencies = [
741
788
"data-encoding",
742
-
"syn",
789
+
"syn 2.0.109",
743
790
]
744
791
745
792
[[package]]
···
773
820
dependencies = [
774
821
"proc-macro2",
775
822
"quote",
776
-
"syn",
823
+
"syn 2.0.109",
777
824
]
778
825
779
826
[[package]]
···
833
880
"heck",
834
881
"proc-macro2",
835
882
"quote",
836
-
"syn",
883
+
"syn 2.0.109",
837
884
]
838
885
839
886
[[package]]
···
853
900
]
854
901
855
902
[[package]]
903
+
name = "find-msvc-tools"
904
+
version = "0.1.4"
905
+
source = "registry+https://github.com/rust-lang/crates.io-index"
906
+
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
907
+
908
+
[[package]]
856
909
name = "fnv"
857
910
version = "1.0.7"
858
911
source = "registry+https://github.com/rust-lang/crates.io-index"
···
866
919
867
920
[[package]]
868
921
name = "form_urlencoded"
869
-
version = "1.2.1"
922
+
version = "1.2.2"
870
923
source = "registry+https://github.com/rust-lang/crates.io-index"
871
-
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
924
+
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
872
925
dependencies = [
873
926
"percent-encoding",
874
927
]
···
929
982
dependencies = [
930
983
"proc-macro2",
931
984
"quote",
932
-
"syn",
985
+
"syn 2.0.109",
933
986
]
934
987
935
988
[[package]]
···
960
1013
"pin-project-lite",
961
1014
"pin-utils",
962
1015
"slab",
963
-
]
964
-
965
-
[[package]]
966
-
name = "generator"
967
-
version = "0.8.5"
968
-
source = "registry+https://github.com/rust-lang/crates.io-index"
969
-
checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827"
970
-
dependencies = [
971
-
"cc",
972
-
"cfg-if",
973
-
"libc",
974
-
"log",
975
-
"rustversion",
976
-
"windows",
977
1016
]
978
1017
979
1018
[[package]]
980
1019
name = "generic-array"
981
-
version = "0.14.7"
1020
+
version = "0.14.9"
982
1021
source = "registry+https://github.com/rust-lang/crates.io-index"
983
-
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1022
+
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
984
1023
dependencies = [
985
1024
"typenum",
986
1025
"version_check",
···
996
1035
"cfg-if",
997
1036
"js-sys",
998
1037
"libc",
999
-
"wasi 0.11.0+wasi-snapshot-preview1",
1038
+
"wasi",
1000
1039
"wasm-bindgen",
1001
1040
]
1002
1041
1003
1042
[[package]]
1004
1043
name = "getrandom"
1005
-
version = "0.3.3"
1044
+
version = "0.3.4"
1006
1045
source = "registry+https://github.com/rust-lang/crates.io-index"
1007
-
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
1046
+
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1008
1047
dependencies = [
1009
1048
"cfg-if",
1010
1049
"js-sys",
1011
1050
"libc",
1012
1051
"r-efi",
1013
-
"wasi 0.14.2+wasi-0.2.4",
1052
+
"wasip2",
1014
1053
"wasm-bindgen",
1015
1054
]
1016
-
1017
-
[[package]]
1018
-
name = "gimli"
1019
-
version = "0.31.1"
1020
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1021
-
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
1022
1055
1023
1056
[[package]]
1024
1057
name = "group"
···
1033
1066
1034
1067
[[package]]
1035
1068
name = "h2"
1036
-
version = "0.4.10"
1069
+
version = "0.4.12"
1037
1070
source = "registry+https://github.com/rust-lang/crates.io-index"
1038
-
checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
1071
+
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
1039
1072
dependencies = [
1040
1073
"atomic-waker",
1041
1074
"bytes",
···
1052
1085
1053
1086
[[package]]
1054
1087
name = "hashbrown"
1055
-
version = "0.15.3"
1088
+
version = "0.15.5"
1056
1089
source = "registry+https://github.com/rust-lang/crates.io-index"
1057
-
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
1090
+
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
1058
1091
dependencies = [
1059
1092
"allocator-api2",
1060
1093
"equivalent",
···
1062
1095
]
1063
1096
1064
1097
[[package]]
1098
+
name = "hashbrown"
1099
+
version = "0.16.0"
1100
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1101
+
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
1102
+
1103
+
[[package]]
1065
1104
name = "heck"
1066
1105
version = "0.5.0"
1067
1106
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1083
1122
"idna",
1084
1123
"ipnet",
1085
1124
"once_cell",
1086
-
"rand 0.9.1",
1125
+
"rand 0.9.2",
1087
1126
"ring",
1088
-
"thiserror 2.0.12",
1127
+
"thiserror 2.0.17",
1089
1128
"tinyvec",
1090
1129
"tokio",
1091
1130
"tracing",
···
1105
1144
"moka",
1106
1145
"once_cell",
1107
1146
"parking_lot",
1108
-
"rand 0.9.1",
1147
+
"rand 0.9.2",
1109
1148
"resolv-conf",
1110
1149
"smallvec",
1111
-
"thiserror 2.0.12",
1150
+
"thiserror 2.0.17",
1112
1151
"tokio",
1113
1152
"tracing",
1114
1153
]
···
1179
1218
1180
1219
[[package]]
1181
1220
name = "hyper"
1182
-
version = "1.6.0"
1221
+
version = "1.7.0"
1183
1222
source = "registry+https://github.com/rust-lang/crates.io-index"
1184
-
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
1223
+
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
1185
1224
dependencies = [
1225
+
"atomic-waker",
1186
1226
"bytes",
1187
1227
"futures-channel",
1188
-
"futures-util",
1228
+
"futures-core",
1189
1229
"h2",
1190
1230
"http",
1191
1231
"http-body",
···
1193
1233
"httpdate",
1194
1234
"itoa",
1195
1235
"pin-project-lite",
1236
+
"pin-utils",
1196
1237
"smallvec",
1197
1238
"tokio",
1198
1239
"want",
···
1200
1241
1201
1242
[[package]]
1202
1243
name = "hyper-rustls"
1203
-
version = "0.27.6"
1244
+
version = "0.27.7"
1204
1245
source = "registry+https://github.com/rust-lang/crates.io-index"
1205
-
checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
1246
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
1206
1247
dependencies = [
1207
1248
"http",
1208
1249
"hyper",
···
1217
1258
1218
1259
[[package]]
1219
1260
name = "hyper-util"
1220
-
version = "0.1.13"
1261
+
version = "0.1.17"
1221
1262
source = "registry+https://github.com/rust-lang/crates.io-index"
1222
-
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
1263
+
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
1223
1264
dependencies = [
1224
1265
"base64",
1225
1266
"bytes",
···
1233
1274
"libc",
1234
1275
"percent-encoding",
1235
1276
"pin-project-lite",
1236
-
"socket2",
1277
+
"socket2 0.6.1",
1237
1278
"system-configuration",
1238
1279
"tokio",
1239
1280
"tower-service",
···
1243
1284
1244
1285
[[package]]
1245
1286
name = "icu_collections"
1246
-
version = "2.0.0"
1287
+
version = "2.1.1"
1247
1288
source = "registry+https://github.com/rust-lang/crates.io-index"
1248
-
checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
1289
+
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
1249
1290
dependencies = [
1250
1291
"displaydoc",
1251
1292
"potential_utf",
···
1256
1297
1257
1298
[[package]]
1258
1299
name = "icu_locale_core"
1259
-
version = "2.0.0"
1300
+
version = "2.1.1"
1260
1301
source = "registry+https://github.com/rust-lang/crates.io-index"
1261
-
checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
1302
+
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
1262
1303
dependencies = [
1263
1304
"displaydoc",
1264
1305
"litemap",
···
1269
1310
1270
1311
[[package]]
1271
1312
name = "icu_normalizer"
1272
-
version = "2.0.0"
1313
+
version = "2.1.1"
1273
1314
source = "registry+https://github.com/rust-lang/crates.io-index"
1274
-
checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
1315
+
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
1275
1316
dependencies = [
1276
-
"displaydoc",
1277
1317
"icu_collections",
1278
1318
"icu_normalizer_data",
1279
1319
"icu_properties",
···
1284
1324
1285
1325
[[package]]
1286
1326
name = "icu_normalizer_data"
1287
-
version = "2.0.0"
1327
+
version = "2.1.1"
1288
1328
source = "registry+https://github.com/rust-lang/crates.io-index"
1289
-
checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
1329
+
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
1290
1330
1291
1331
[[package]]
1292
1332
name = "icu_properties"
1293
-
version = "2.0.1"
1333
+
version = "2.1.1"
1294
1334
source = "registry+https://github.com/rust-lang/crates.io-index"
1295
-
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
1335
+
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
1296
1336
dependencies = [
1297
-
"displaydoc",
1298
1337
"icu_collections",
1299
1338
"icu_locale_core",
1300
1339
"icu_properties_data",
1301
1340
"icu_provider",
1302
-
"potential_utf",
1303
1341
"zerotrie",
1304
1342
"zerovec",
1305
1343
]
1306
1344
1307
1345
[[package]]
1308
1346
name = "icu_properties_data"
1309
-
version = "2.0.1"
1347
+
version = "2.1.1"
1310
1348
source = "registry+https://github.com/rust-lang/crates.io-index"
1311
-
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
1349
+
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
1312
1350
1313
1351
[[package]]
1314
1352
name = "icu_provider"
1315
-
version = "2.0.0"
1353
+
version = "2.1.1"
1316
1354
source = "registry+https://github.com/rust-lang/crates.io-index"
1317
-
checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
1355
+
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
1318
1356
dependencies = [
1319
1357
"displaydoc",
1320
1358
"icu_locale_core",
1321
-
"stable_deref_trait",
1322
-
"tinystr",
1323
1359
"writeable",
1324
1360
"yoke",
1325
1361
"zerofrom",
···
1329
1365
1330
1366
[[package]]
1331
1367
name = "idna"
1332
-
version = "1.0.3"
1368
+
version = "1.1.0"
1333
1369
source = "registry+https://github.com/rust-lang/crates.io-index"
1334
-
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
1370
+
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1335
1371
dependencies = [
1336
1372
"idna_adapter",
1337
1373
"smallvec",
···
1350
1386
1351
1387
[[package]]
1352
1388
name = "indexmap"
1353
-
version = "2.9.0"
1389
+
version = "2.12.0"
1354
1390
source = "registry+https://github.com/rust-lang/crates.io-index"
1355
-
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
1391
+
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
1356
1392
dependencies = [
1357
1393
"equivalent",
1358
-
"hashbrown",
1394
+
"hashbrown 0.16.0",
1359
1395
]
1360
1396
1361
1397
[[package]]
···
1364
1400
source = "registry+https://github.com/rust-lang/crates.io-index"
1365
1401
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
1366
1402
dependencies = [
1367
-
"socket2",
1403
+
"socket2 0.5.10",
1368
1404
"widestring",
1369
1405
"windows-sys 0.48.0",
1370
1406
"winreg",
···
1389
1425
1390
1426
[[package]]
1391
1427
name = "iri-string"
1392
-
version = "0.7.8"
1428
+
version = "0.7.9"
1393
1429
source = "registry+https://github.com/rust-lang/crates.io-index"
1394
-
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
1430
+
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
1395
1431
dependencies = [
1396
1432
"memchr",
1397
1433
"serde",
···
1399
1435
1400
1436
[[package]]
1401
1437
name = "is_terminal_polyfill"
1402
-
version = "1.70.1"
1438
+
version = "1.70.2"
1403
1439
source = "registry+https://github.com/rust-lang/crates.io-index"
1404
-
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
1440
+
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1405
1441
1406
1442
[[package]]
1407
1443
name = "itoa"
···
1411
1447
1412
1448
[[package]]
1413
1449
name = "jobserver"
1414
-
version = "0.1.33"
1450
+
version = "0.1.34"
1415
1451
source = "registry+https://github.com/rust-lang/crates.io-index"
1416
-
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
1452
+
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1417
1453
dependencies = [
1418
-
"getrandom 0.3.3",
1454
+
"getrandom 0.3.4",
1419
1455
"libc",
1420
1456
]
1421
1457
1422
1458
[[package]]
1423
1459
name = "js-sys"
1424
-
version = "0.3.77"
1460
+
version = "0.3.82"
1425
1461
source = "registry+https://github.com/rust-lang/crates.io-index"
1426
-
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
1462
+
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
1427
1463
dependencies = [
1428
1464
"once_cell",
1429
1465
"wasm-bindgen",
···
1451
1487
1452
1488
[[package]]
1453
1489
name = "libc"
1454
-
version = "0.2.172"
1490
+
version = "0.2.177"
1455
1491
source = "registry+https://github.com/rust-lang/crates.io-index"
1456
-
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
1492
+
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
1457
1493
1458
1494
[[package]]
1459
1495
name = "litemap"
1460
-
version = "0.8.0"
1496
+
version = "0.8.1"
1461
1497
source = "registry+https://github.com/rust-lang/crates.io-index"
1462
-
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
1498
+
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1463
1499
1464
1500
[[package]]
1465
1501
name = "lock_api"
1466
-
version = "0.4.13"
1502
+
version = "0.4.14"
1467
1503
source = "registry+https://github.com/rust-lang/crates.io-index"
1468
-
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
1504
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1469
1505
dependencies = [
1470
-
"autocfg",
1471
1506
"scopeguard",
1472
1507
]
1473
1508
1474
1509
[[package]]
1475
1510
name = "log"
1476
-
version = "0.4.27"
1511
+
version = "0.4.28"
1477
1512
source = "registry+https://github.com/rust-lang/crates.io-index"
1478
-
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
1479
-
1480
-
[[package]]
1481
-
name = "loom"
1482
-
version = "0.7.2"
1483
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1484
-
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1485
-
dependencies = [
1486
-
"cfg-if",
1487
-
"generator",
1488
-
"scoped-tls",
1489
-
"tracing",
1490
-
"tracing-subscriber",
1491
-
]
1513
+
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
1492
1514
1493
1515
[[package]]
1494
1516
name = "lru"
···
1496
1518
source = "registry+https://github.com/rust-lang/crates.io-index"
1497
1519
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1498
1520
dependencies = [
1499
-
"hashbrown",
1521
+
"hashbrown 0.15.5",
1500
1522
]
1501
1523
1502
1524
[[package]]
···
1504
1526
version = "0.1.2"
1505
1527
source = "registry+https://github.com/rust-lang/crates.io-index"
1506
1528
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1529
+
1530
+
[[package]]
1531
+
name = "match-lookup"
1532
+
version = "0.1.1"
1533
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1534
+
checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e"
1535
+
dependencies = [
1536
+
"proc-macro2",
1537
+
"quote",
1538
+
"syn 1.0.109",
1539
+
]
1507
1540
1508
1541
[[package]]
1509
1542
name = "matchers"
1510
-
version = "0.1.0"
1543
+
version = "0.2.0"
1511
1544
source = "registry+https://github.com/rust-lang/crates.io-index"
1512
-
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
1545
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1513
1546
dependencies = [
1514
-
"regex-automata 0.1.10",
1547
+
"regex-automata",
1515
1548
]
1516
1549
1517
1550
[[package]]
···
1522
1555
1523
1556
[[package]]
1524
1557
name = "memchr"
1525
-
version = "2.7.4"
1558
+
version = "2.7.6"
1526
1559
source = "registry+https://github.com/rust-lang/crates.io-index"
1527
-
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
1560
+
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
1528
1561
1529
1562
[[package]]
1530
1563
name = "mime"
···
1543
1576
]
1544
1577
1545
1578
[[package]]
1546
-
name = "miniz_oxide"
1547
-
version = "0.8.8"
1548
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1549
-
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
1550
-
dependencies = [
1551
-
"adler2",
1552
-
]
1553
-
1554
-
[[package]]
1555
1579
name = "mio"
1556
-
version = "1.0.4"
1580
+
version = "1.1.0"
1557
1581
source = "registry+https://github.com/rust-lang/crates.io-index"
1558
-
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
1582
+
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
1559
1583
dependencies = [
1560
1584
"libc",
1561
-
"wasi 0.11.0+wasi-snapshot-preview1",
1562
-
"windows-sys 0.59.0",
1585
+
"wasi",
1586
+
"windows-sys 0.61.2",
1563
1587
]
1564
1588
1565
1589
[[package]]
1566
1590
name = "moka"
1567
-
version = "0.12.10"
1591
+
version = "0.12.11"
1568
1592
source = "registry+https://github.com/rust-lang/crates.io-index"
1569
-
checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926"
1593
+
checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077"
1570
1594
dependencies = [
1571
1595
"crossbeam-channel",
1572
1596
"crossbeam-epoch",
1573
1597
"crossbeam-utils",
1574
-
"loom",
1598
+
"equivalent",
1575
1599
"parking_lot",
1576
1600
"portable-atomic",
1577
1601
"rustc_version",
1578
1602
"smallvec",
1579
1603
"tagptr",
1580
-
"thiserror 1.0.69",
1581
1604
"uuid",
1582
1605
]
1583
1606
1584
1607
[[package]]
1585
1608
name = "multibase"
1586
-
version = "0.9.1"
1609
+
version = "0.9.2"
1587
1610
source = "registry+https://github.com/rust-lang/crates.io-index"
1588
-
checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404"
1611
+
checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77"
1589
1612
dependencies = [
1590
1613
"base-x",
1614
+
"base256emoji",
1591
1615
"data-encoding",
1592
1616
"data-encoding-macro",
1593
1617
]
···
1605
1629
1606
1630
[[package]]
1607
1631
name = "nu-ansi-term"
1608
-
version = "0.46.0"
1632
+
version = "0.50.3"
1609
1633
source = "registry+https://github.com/rust-lang/crates.io-index"
1610
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
1634
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1611
1635
dependencies = [
1612
-
"overload",
1613
-
"winapi",
1636
+
"windows-sys 0.61.2",
1614
1637
]
1615
1638
1616
1639
[[package]]
···
1620
1643
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1621
1644
dependencies = [
1622
1645
"autocfg",
1623
-
]
1624
-
1625
-
[[package]]
1626
-
name = "object"
1627
-
version = "0.36.7"
1628
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1629
-
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
1630
-
dependencies = [
1631
-
"memchr",
1632
1646
]
1633
1647
1634
1648
[[package]]
···
1643
1657
1644
1658
[[package]]
1645
1659
name = "once_cell_polyfill"
1646
-
version = "1.70.1"
1660
+
version = "1.70.2"
1647
1661
source = "registry+https://github.com/rust-lang/crates.io-index"
1648
-
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
1662
+
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1649
1663
1650
1664
[[package]]
1651
1665
name = "openssl-probe"
1652
1666
version = "0.1.6"
1653
1667
source = "registry+https://github.com/rust-lang/crates.io-index"
1654
1668
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1655
-
1656
-
[[package]]
1657
-
name = "overload"
1658
-
version = "0.1.1"
1659
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1660
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
1661
1669
1662
1670
[[package]]
1663
1671
name = "p256"
···
1687
1695
1688
1696
[[package]]
1689
1697
name = "parking_lot"
1690
-
version = "0.12.4"
1698
+
version = "0.12.5"
1691
1699
source = "registry+https://github.com/rust-lang/crates.io-index"
1692
-
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
1700
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1693
1701
dependencies = [
1694
1702
"lock_api",
1695
1703
"parking_lot_core",
···
1697
1705
1698
1706
[[package]]
1699
1707
name = "parking_lot_core"
1700
-
version = "0.9.11"
1708
+
version = "0.9.12"
1701
1709
source = "registry+https://github.com/rust-lang/crates.io-index"
1702
-
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
1710
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1703
1711
dependencies = [
1704
1712
"cfg-if",
1705
1713
"libc",
1706
1714
"redox_syscall",
1707
1715
"smallvec",
1708
-
"windows-targets 0.52.6",
1716
+
"windows-link 0.2.1",
1709
1717
]
1710
1718
1711
1719
[[package]]
···
1719
1727
1720
1728
[[package]]
1721
1729
name = "percent-encoding"
1722
-
version = "2.3.1"
1730
+
version = "2.3.2"
1723
1731
source = "registry+https://github.com/rust-lang/crates.io-index"
1724
-
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
1732
+
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1725
1733
1726
1734
[[package]]
1727
1735
name = "pin-project-lite"
···
1753
1761
1754
1762
[[package]]
1755
1763
name = "portable-atomic"
1756
-
version = "1.11.0"
1764
+
version = "1.11.1"
1757
1765
source = "registry+https://github.com/rust-lang/crates.io-index"
1758
-
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
1766
+
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
1759
1767
1760
1768
[[package]]
1761
1769
name = "potential_utf"
1762
-
version = "0.1.2"
1770
+
version = "0.1.4"
1763
1771
source = "registry+https://github.com/rust-lang/crates.io-index"
1764
-
checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
1772
+
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
1765
1773
dependencies = [
1766
1774
"zerovec",
1767
1775
]
···
1787
1795
1788
1796
[[package]]
1789
1797
name = "proc-macro2"
1790
-
version = "1.0.95"
1798
+
version = "1.0.103"
1791
1799
source = "registry+https://github.com/rust-lang/crates.io-index"
1792
-
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
1800
+
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
1793
1801
dependencies = [
1794
1802
"unicode-ident",
1795
1803
]
1796
1804
1797
1805
[[package]]
1798
1806
name = "quinn"
1799
-
version = "0.11.8"
1807
+
version = "0.11.9"
1800
1808
source = "registry+https://github.com/rust-lang/crates.io-index"
1801
-
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
1809
+
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
1802
1810
dependencies = [
1803
1811
"bytes",
1804
1812
"cfg_aliases",
···
1807
1815
"quinn-udp",
1808
1816
"rustc-hash",
1809
1817
"rustls",
1810
-
"socket2",
1811
-
"thiserror 2.0.12",
1818
+
"socket2 0.6.1",
1819
+
"thiserror 2.0.17",
1812
1820
"tokio",
1813
1821
"tracing",
1814
1822
"web-time",
···
1816
1824
1817
1825
[[package]]
1818
1826
name = "quinn-proto"
1819
-
version = "0.11.12"
1827
+
version = "0.11.13"
1820
1828
source = "registry+https://github.com/rust-lang/crates.io-index"
1821
-
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
1829
+
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
1822
1830
dependencies = [
1823
1831
"bytes",
1824
-
"getrandom 0.3.3",
1832
+
"getrandom 0.3.4",
1825
1833
"lru-slab",
1826
-
"rand 0.9.1",
1834
+
"rand 0.9.2",
1827
1835
"ring",
1828
1836
"rustc-hash",
1829
1837
"rustls",
1830
1838
"rustls-pki-types",
1831
1839
"slab",
1832
-
"thiserror 2.0.12",
1840
+
"thiserror 2.0.17",
1833
1841
"tinyvec",
1834
1842
"tracing",
1835
1843
"web-time",
···
1837
1845
1838
1846
[[package]]
1839
1847
name = "quinn-udp"
1840
-
version = "0.5.12"
1848
+
version = "0.5.14"
1841
1849
source = "registry+https://github.com/rust-lang/crates.io-index"
1842
-
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
1850
+
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
1843
1851
dependencies = [
1844
1852
"cfg_aliases",
1845
1853
"libc",
1846
1854
"once_cell",
1847
-
"socket2",
1855
+
"socket2 0.6.1",
1848
1856
"tracing",
1849
-
"windows-sys 0.59.0",
1857
+
"windows-sys 0.60.2",
1850
1858
]
1851
1859
1852
1860
[[package]]
1853
1861
name = "quote"
1854
-
version = "1.0.40"
1862
+
version = "1.0.41"
1855
1863
source = "registry+https://github.com/rust-lang/crates.io-index"
1856
-
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
1864
+
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
1857
1865
dependencies = [
1858
1866
"proc-macro2",
1859
1867
]
1860
1868
1861
1869
[[package]]
1862
1870
name = "r-efi"
1863
-
version = "5.2.0"
1871
+
version = "5.3.0"
1864
1872
source = "registry+https://github.com/rust-lang/crates.io-index"
1865
-
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
1873
+
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1866
1874
1867
1875
[[package]]
1868
1876
name = "rand"
···
1877
1885
1878
1886
[[package]]
1879
1887
name = "rand"
1880
-
version = "0.9.1"
1888
+
version = "0.9.2"
1881
1889
source = "registry+https://github.com/rust-lang/crates.io-index"
1882
-
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
1890
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1883
1891
dependencies = [
1884
1892
"rand_chacha 0.9.0",
1885
1893
"rand_core 0.9.3",
···
1920
1928
source = "registry+https://github.com/rust-lang/crates.io-index"
1921
1929
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1922
1930
dependencies = [
1923
-
"getrandom 0.3.3",
1931
+
"getrandom 0.3.4",
1924
1932
]
1925
1933
1926
1934
[[package]]
1927
1935
name = "redox_syscall"
1928
-
version = "0.5.12"
1936
+
version = "0.5.18"
1929
1937
source = "registry+https://github.com/rust-lang/crates.io-index"
1930
-
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
1938
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1931
1939
dependencies = [
1932
1940
"bitflags",
1933
1941
]
1934
1942
1935
1943
[[package]]
1936
1944
name = "regex"
1937
-
version = "1.11.1"
1945
+
version = "1.12.2"
1938
1946
source = "registry+https://github.com/rust-lang/crates.io-index"
1939
-
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
1947
+
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
1940
1948
dependencies = [
1941
1949
"aho-corasick",
1942
1950
"memchr",
1943
-
"regex-automata 0.4.9",
1944
-
"regex-syntax 0.8.5",
1945
-
]
1946
-
1947
-
[[package]]
1948
-
name = "regex-automata"
1949
-
version = "0.1.10"
1950
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1951
-
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
1952
-
dependencies = [
1953
-
"regex-syntax 0.6.29",
1951
+
"regex-automata",
1952
+
"regex-syntax",
1954
1953
]
1955
1954
1956
1955
[[package]]
1957
1956
name = "regex-automata"
1958
-
version = "0.4.9"
1957
+
version = "0.4.13"
1959
1958
source = "registry+https://github.com/rust-lang/crates.io-index"
1960
-
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
1959
+
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
1961
1960
dependencies = [
1962
1961
"aho-corasick",
1963
1962
"memchr",
1964
-
"regex-syntax 0.8.5",
1963
+
"regex-syntax",
1965
1964
]
1966
1965
1967
1966
[[package]]
1968
1967
name = "regex-syntax"
1969
-
version = "0.6.29"
1970
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1971
-
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
1972
-
1973
-
[[package]]
1974
-
name = "regex-syntax"
1975
-
version = "0.8.5"
1968
+
version = "0.8.8"
1976
1969
source = "registry+https://github.com/rust-lang/crates.io-index"
1977
-
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
1970
+
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1978
1971
1979
1972
[[package]]
1980
1973
name = "reqwest"
1981
-
version = "0.12.18"
1974
+
version = "0.12.24"
1982
1975
source = "registry+https://github.com/rust-lang/crates.io-index"
1983
-
checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
1976
+
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
1984
1977
dependencies = [
1985
1978
"base64",
1986
1979
"bytes",
···
1994
1987
"hyper",
1995
1988
"hyper-rustls",
1996
1989
"hyper-util",
1997
-
"ipnet",
1998
1990
"js-sys",
1999
1991
"log",
2000
1992
"mime",
2001
1993
"mime_guess",
2002
-
"once_cell",
2003
1994
"percent-encoding",
2004
1995
"pin-project-lite",
2005
1996
"quinn",
···
2050
2041
2051
2042
[[package]]
2052
2043
name = "resolv-conf"
2053
-
version = "0.7.4"
2044
+
version = "0.7.5"
2054
2045
source = "registry+https://github.com/rust-lang/crates.io-index"
2055
-
checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3"
2046
+
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
2056
2047
2057
2048
[[package]]
2058
2049
name = "rfc6979"
···
2100
2091
]
2101
2092
2102
2093
[[package]]
2103
-
name = "rustc-demangle"
2104
-
version = "0.1.24"
2105
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2106
-
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
2107
-
2108
-
[[package]]
2109
2094
name = "rustc-hash"
2110
2095
version = "2.1.1"
2111
2096
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2122
2107
2123
2108
[[package]]
2124
2109
name = "rustls"
2125
-
version = "0.23.27"
2110
+
version = "0.23.35"
2126
2111
source = "registry+https://github.com/rust-lang/crates.io-index"
2127
-
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
2112
+
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
2128
2113
dependencies = [
2129
2114
"once_cell",
2130
2115
"ring",
···
2136
2121
2137
2122
[[package]]
2138
2123
name = "rustls-native-certs"
2139
-
version = "0.8.1"
2124
+
version = "0.8.2"
2140
2125
source = "registry+https://github.com/rust-lang/crates.io-index"
2141
-
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
2126
+
checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923"
2142
2127
dependencies = [
2143
2128
"openssl-probe",
2144
2129
"rustls-pki-types",
···
2148
2133
2149
2134
[[package]]
2150
2135
name = "rustls-pki-types"
2151
-
version = "1.12.0"
2136
+
version = "1.13.0"
2152
2137
source = "registry+https://github.com/rust-lang/crates.io-index"
2153
-
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
2138
+
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
2154
2139
dependencies = [
2155
2140
"web-time",
2156
2141
"zeroize",
···
2158
2143
2159
2144
[[package]]
2160
2145
name = "rustls-webpki"
2161
-
version = "0.103.3"
2146
+
version = "0.103.8"
2162
2147
source = "registry+https://github.com/rust-lang/crates.io-index"
2163
-
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
2148
+
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
2164
2149
dependencies = [
2165
2150
"ring",
2166
2151
"rustls-pki-types",
···
2169
2154
2170
2155
[[package]]
2171
2156
name = "rustversion"
2172
-
version = "1.0.21"
2157
+
version = "1.0.22"
2173
2158
source = "registry+https://github.com/rust-lang/crates.io-index"
2174
-
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
2159
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
2175
2160
2176
2161
[[package]]
2177
2162
name = "ryu"
···
2181
2166
2182
2167
[[package]]
2183
2168
name = "schannel"
2184
-
version = "0.1.27"
2169
+
version = "0.1.28"
2185
2170
source = "registry+https://github.com/rust-lang/crates.io-index"
2186
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
2171
+
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
2187
2172
dependencies = [
2188
-
"windows-sys 0.59.0",
2173
+
"windows-sys 0.61.2",
2189
2174
]
2190
-
2191
-
[[package]]
2192
-
name = "scoped-tls"
2193
-
version = "1.0.1"
2194
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2195
-
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
2196
2175
2197
2176
[[package]]
2198
2177
name = "scopeguard"
···
2227
2206
2228
2207
[[package]]
2229
2208
name = "security-framework"
2230
-
version = "3.2.0"
2209
+
version = "3.5.1"
2231
2210
source = "registry+https://github.com/rust-lang/crates.io-index"
2232
-
checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316"
2211
+
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
2233
2212
dependencies = [
2234
2213
"bitflags",
2235
2214
"core-foundation 0.10.1",
···
2240
2219
2241
2220
[[package]]
2242
2221
name = "security-framework-sys"
2243
-
version = "2.14.0"
2222
+
version = "2.15.0"
2244
2223
source = "registry+https://github.com/rust-lang/crates.io-index"
2245
-
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
2224
+
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
2246
2225
dependencies = [
2247
2226
"core-foundation-sys",
2248
2227
"libc",
···
2250
2229
2251
2230
[[package]]
2252
2231
name = "semver"
2253
-
version = "1.0.26"
2232
+
version = "1.0.27"
2254
2233
source = "registry+https://github.com/rust-lang/crates.io-index"
2255
-
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
2234
+
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
2256
2235
2257
2236
[[package]]
2258
2237
name = "serde"
2259
-
version = "1.0.219"
2238
+
version = "1.0.228"
2260
2239
source = "registry+https://github.com/rust-lang/crates.io-index"
2261
-
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
2240
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
2262
2241
dependencies = [
2242
+
"serde_core",
2263
2243
"serde_derive",
2264
2244
]
2265
2245
2266
2246
[[package]]
2267
2247
name = "serde_bytes"
2268
-
version = "0.11.17"
2248
+
version = "0.11.19"
2269
2249
source = "registry+https://github.com/rust-lang/crates.io-index"
2270
-
checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96"
2250
+
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
2271
2251
dependencies = [
2272
2252
"serde",
2253
+
"serde_core",
2254
+
]
2255
+
2256
+
[[package]]
2257
+
name = "serde_core"
2258
+
version = "1.0.228"
2259
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2260
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
2261
+
dependencies = [
2262
+
"serde_derive",
2273
2263
]
2274
2264
2275
2265
[[package]]
2276
2266
name = "serde_derive"
2277
-
version = "1.0.219"
2267
+
version = "1.0.228"
2278
2268
source = "registry+https://github.com/rust-lang/crates.io-index"
2279
-
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
2269
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
2280
2270
dependencies = [
2281
2271
"proc-macro2",
2282
2272
"quote",
2283
-
"syn",
2273
+
"syn 2.0.109",
2284
2274
]
2285
2275
2286
2276
[[package]]
2287
2277
name = "serde_ipld_dagcbor"
2288
-
version = "0.6.3"
2278
+
version = "0.6.4"
2289
2279
source = "registry+https://github.com/rust-lang/crates.io-index"
2290
-
checksum = "99600723cf53fb000a66175555098db7e75217c415bdd9a16a65d52a19dcc4fc"
2280
+
checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778"
2291
2281
dependencies = [
2292
2282
"cbor4ii",
2293
2283
"ipld-core",
···
2297
2287
2298
2288
[[package]]
2299
2289
name = "serde_json"
2300
-
version = "1.0.140"
2290
+
version = "1.0.145"
2301
2291
source = "registry+https://github.com/rust-lang/crates.io-index"
2302
-
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
2292
+
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
2303
2293
dependencies = [
2304
2294
"indexmap",
2305
2295
"itoa",
2306
2296
"memchr",
2307
2297
"ryu",
2308
2298
"serde",
2299
+
"serde_core",
2309
2300
]
2310
2301
2311
2302
[[package]]
2312
2303
name = "serde_path_to_error"
2313
-
version = "0.1.17"
2304
+
version = "0.1.20"
2314
2305
source = "registry+https://github.com/rust-lang/crates.io-index"
2315
-
checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a"
2306
+
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
2316
2307
dependencies = [
2317
2308
"itoa",
2318
2309
"serde",
2310
+
"serde_core",
2319
2311
]
2320
2312
2321
2313
[[package]]
···
2368
2360
2369
2361
[[package]]
2370
2362
name = "signal-hook-registry"
2371
-
version = "1.4.5"
2363
+
version = "1.4.6"
2372
2364
source = "registry+https://github.com/rust-lang/crates.io-index"
2373
-
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
2365
+
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
2374
2366
dependencies = [
2375
2367
"libc",
2376
2368
]
···
2393
2385
2394
2386
[[package]]
2395
2387
name = "slab"
2396
-
version = "0.4.9"
2388
+
version = "0.4.11"
2397
2389
source = "registry+https://github.com/rust-lang/crates.io-index"
2398
-
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
2399
-
dependencies = [
2400
-
"autocfg",
2401
-
]
2390
+
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
2402
2391
2403
2392
[[package]]
2404
2393
name = "smallvec"
2405
-
version = "1.15.0"
2394
+
version = "1.15.1"
2406
2395
source = "registry+https://github.com/rust-lang/crates.io-index"
2407
-
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
2396
+
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
2408
2397
2409
2398
[[package]]
2410
2399
name = "socket2"
···
2417
2406
]
2418
2407
2419
2408
[[package]]
2409
+
name = "socket2"
2410
+
version = "0.6.1"
2411
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2412
+
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
2413
+
dependencies = [
2414
+
"libc",
2415
+
"windows-sys 0.60.2",
2416
+
]
2417
+
2418
+
[[package]]
2420
2419
name = "spki"
2421
2420
version = "0.7.3"
2422
2421
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2428
2427
2429
2428
[[package]]
2430
2429
name = "stable_deref_trait"
2431
-
version = "1.2.0"
2430
+
version = "1.2.1"
2432
2431
source = "registry+https://github.com/rust-lang/crates.io-index"
2433
-
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
2432
+
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2433
+
2434
+
[[package]]
2435
+
name = "static_assertions"
2436
+
version = "1.1.0"
2437
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2438
+
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2434
2439
2435
2440
[[package]]
2436
2441
name = "strsim"
···
2446
2451
2447
2452
[[package]]
2448
2453
name = "syn"
2449
-
version = "2.0.101"
2454
+
version = "1.0.109"
2455
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2456
+
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
2457
+
dependencies = [
2458
+
"proc-macro2",
2459
+
"quote",
2460
+
"unicode-ident",
2461
+
]
2462
+
2463
+
[[package]]
2464
+
name = "syn"
2465
+
version = "2.0.109"
2450
2466
source = "registry+https://github.com/rust-lang/crates.io-index"
2451
-
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
2467
+
checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f"
2452
2468
dependencies = [
2453
2469
"proc-macro2",
2454
2470
"quote",
···
2472
2488
dependencies = [
2473
2489
"proc-macro2",
2474
2490
"quote",
2475
-
"syn",
2491
+
"syn 2.0.109",
2476
2492
]
2477
2493
2478
2494
[[package]]
···
2513
2529
2514
2530
[[package]]
2515
2531
name = "thiserror"
2516
-
version = "2.0.12"
2532
+
version = "2.0.17"
2517
2533
source = "registry+https://github.com/rust-lang/crates.io-index"
2518
-
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
2534
+
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
2519
2535
dependencies = [
2520
-
"thiserror-impl 2.0.12",
2536
+
"thiserror-impl 2.0.17",
2521
2537
]
2522
2538
2523
2539
[[package]]
···
2528
2544
dependencies = [
2529
2545
"proc-macro2",
2530
2546
"quote",
2531
-
"syn",
2547
+
"syn 2.0.109",
2532
2548
]
2533
2549
2534
2550
[[package]]
2535
2551
name = "thiserror-impl"
2536
-
version = "2.0.12"
2552
+
version = "2.0.17"
2537
2553
source = "registry+https://github.com/rust-lang/crates.io-index"
2538
-
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
2554
+
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
2539
2555
dependencies = [
2540
2556
"proc-macro2",
2541
2557
"quote",
2542
-
"syn",
2558
+
"syn 2.0.109",
2543
2559
]
2544
2560
2545
2561
[[package]]
2546
2562
name = "thread_local"
2547
-
version = "1.1.8"
2563
+
version = "1.1.9"
2548
2564
source = "registry+https://github.com/rust-lang/crates.io-index"
2549
-
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
2565
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
2550
2566
dependencies = [
2551
2567
"cfg-if",
2552
-
"once_cell",
2553
2568
]
2554
2569
2555
2570
[[package]]
2556
2571
name = "tinystr"
2557
-
version = "0.8.1"
2572
+
version = "0.8.2"
2558
2573
source = "registry+https://github.com/rust-lang/crates.io-index"
2559
-
checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
2574
+
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
2560
2575
dependencies = [
2561
2576
"displaydoc",
2562
2577
"zerovec",
···
2564
2579
2565
2580
[[package]]
2566
2581
name = "tinyvec"
2567
-
version = "1.9.0"
2582
+
version = "1.10.0"
2568
2583
source = "registry+https://github.com/rust-lang/crates.io-index"
2569
-
checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71"
2584
+
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
2570
2585
dependencies = [
2571
2586
"tinyvec_macros",
2572
2587
]
···
2579
2594
2580
2595
[[package]]
2581
2596
name = "tokio"
2582
-
version = "1.45.1"
2597
+
version = "1.48.0"
2583
2598
source = "registry+https://github.com/rust-lang/crates.io-index"
2584
-
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
2599
+
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
2585
2600
dependencies = [
2586
-
"backtrace",
2587
2601
"bytes",
2588
2602
"libc",
2589
2603
"mio",
2590
2604
"parking_lot",
2591
2605
"pin-project-lite",
2592
2606
"signal-hook-registry",
2593
-
"socket2",
2607
+
"socket2 0.6.1",
2594
2608
"tokio-macros",
2595
-
"windows-sys 0.52.0",
2609
+
"windows-sys 0.61.2",
2596
2610
]
2597
2611
2598
2612
[[package]]
2599
2613
name = "tokio-macros"
2600
-
version = "2.5.0"
2614
+
version = "2.6.0"
2601
2615
source = "registry+https://github.com/rust-lang/crates.io-index"
2602
-
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
2616
+
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
2603
2617
dependencies = [
2604
2618
"proc-macro2",
2605
2619
"quote",
2606
-
"syn",
2620
+
"syn 2.0.109",
2607
2621
]
2608
2622
2609
2623
[[package]]
2610
2624
name = "tokio-rustls"
2611
-
version = "0.26.2"
2625
+
version = "0.26.4"
2612
2626
source = "registry+https://github.com/rust-lang/crates.io-index"
2613
-
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
2627
+
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
2614
2628
dependencies = [
2615
2629
"rustls",
2616
2630
"tokio",
2617
2631
]
2618
2632
2619
2633
[[package]]
2634
+
name = "tokio-stream"
2635
+
version = "0.1.17"
2636
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2637
+
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
2638
+
dependencies = [
2639
+
"futures-core",
2640
+
"pin-project-lite",
2641
+
"tokio",
2642
+
]
2643
+
2644
+
[[package]]
2620
2645
name = "tokio-util"
2621
-
version = "0.7.15"
2646
+
version = "0.7.17"
2622
2647
source = "registry+https://github.com/rust-lang/crates.io-index"
2623
-
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
2648
+
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
2624
2649
dependencies = [
2625
2650
"bytes",
2626
2651
"futures-core",
···
2641
2666
"futures-sink",
2642
2667
"http",
2643
2668
"httparse",
2644
-
"rand 0.9.1",
2669
+
"rand 0.9.2",
2645
2670
"ring",
2646
2671
"rustls-native-certs",
2647
2672
"rustls-pki-types",
···
2669
2694
2670
2695
[[package]]
2671
2696
name = "tower-http"
2672
-
version = "0.6.4"
2697
+
version = "0.6.6"
2673
2698
source = "registry+https://github.com/rust-lang/crates.io-index"
2674
-
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
2699
+
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
2675
2700
dependencies = [
2676
2701
"bitflags",
2677
2702
"bytes",
···
2711
2736
2712
2737
[[package]]
2713
2738
name = "tracing-attributes"
2714
-
version = "0.1.28"
2739
+
version = "0.1.30"
2715
2740
source = "registry+https://github.com/rust-lang/crates.io-index"
2716
-
checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
2741
+
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
2717
2742
dependencies = [
2718
2743
"proc-macro2",
2719
2744
"quote",
2720
-
"syn",
2745
+
"syn 2.0.109",
2721
2746
]
2722
2747
2723
2748
[[package]]
2724
2749
name = "tracing-core"
2725
-
version = "0.1.33"
2750
+
version = "0.1.34"
2726
2751
source = "registry+https://github.com/rust-lang/crates.io-index"
2727
-
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
2752
+
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
2728
2753
dependencies = [
2729
2754
"once_cell",
2730
2755
"valuable",
···
2743
2768
2744
2769
[[package]]
2745
2770
name = "tracing-subscriber"
2746
-
version = "0.3.19"
2771
+
version = "0.3.20"
2747
2772
source = "registry+https://github.com/rust-lang/crates.io-index"
2748
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
2773
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
2749
2774
dependencies = [
2750
2775
"matchers",
2751
2776
"nu-ansi-term",
2752
2777
"once_cell",
2753
-
"regex",
2778
+
"regex-automata",
2754
2779
"sharded-slab",
2755
2780
"smallvec",
2756
2781
"thread_local",
···
2767
2792
2768
2793
[[package]]
2769
2794
name = "typenum"
2770
-
version = "1.18.0"
2795
+
version = "1.19.0"
2771
2796
source = "registry+https://github.com/rust-lang/crates.io-index"
2772
-
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
2797
+
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
2773
2798
2774
2799
[[package]]
2775
2800
name = "ulid"
···
2777
2802
source = "registry+https://github.com/rust-lang/crates.io-index"
2778
2803
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
2779
2804
dependencies = [
2780
-
"rand 0.9.1",
2805
+
"rand 0.9.2",
2781
2806
"web-time",
2782
2807
]
2783
2808
···
2789
2814
2790
2815
[[package]]
2791
2816
name = "unicode-ident"
2792
-
version = "1.0.18"
2817
+
version = "1.0.22"
2793
2818
source = "registry+https://github.com/rust-lang/crates.io-index"
2794
-
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
2819
+
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
2795
2820
2796
2821
[[package]]
2797
2822
name = "unsigned-varint"
···
2807
2832
2808
2833
[[package]]
2809
2834
name = "url"
2810
-
version = "2.5.4"
2835
+
version = "2.5.7"
2811
2836
source = "registry+https://github.com/rust-lang/crates.io-index"
2812
-
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
2837
+
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
2813
2838
dependencies = [
2814
2839
"form_urlencoded",
2815
2840
"idna",
2816
2841
"percent-encoding",
2842
+
"serde",
2817
2843
]
2818
2844
2819
2845
[[package]]
···
2836
2862
2837
2863
[[package]]
2838
2864
name = "uuid"
2839
-
version = "1.17.0"
2865
+
version = "1.18.1"
2840
2866
source = "registry+https://github.com/rust-lang/crates.io-index"
2841
-
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
2867
+
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
2842
2868
dependencies = [
2843
-
"getrandom 0.3.3",
2869
+
"getrandom 0.3.4",
2844
2870
"js-sys",
2845
2871
"wasm-bindgen",
2846
2872
]
···
2868
2894
2869
2895
[[package]]
2870
2896
name = "wasi"
2871
-
version = "0.11.0+wasi-snapshot-preview1"
2897
+
version = "0.11.1+wasi-snapshot-preview1"
2872
2898
source = "registry+https://github.com/rust-lang/crates.io-index"
2873
-
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
2899
+
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2874
2900
2875
2901
[[package]]
2876
-
name = "wasi"
2877
-
version = "0.14.2+wasi-0.2.4"
2902
+
name = "wasip2"
2903
+
version = "1.0.1+wasi-0.2.4"
2878
2904
source = "registry+https://github.com/rust-lang/crates.io-index"
2879
-
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
2905
+
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
2880
2906
dependencies = [
2881
-
"wit-bindgen-rt",
2907
+
"wit-bindgen",
2882
2908
]
2883
2909
2884
2910
[[package]]
2885
2911
name = "wasm-bindgen"
2886
-
version = "0.2.100"
2912
+
version = "0.2.105"
2887
2913
source = "registry+https://github.com/rust-lang/crates.io-index"
2888
-
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
2914
+
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
2889
2915
dependencies = [
2890
2916
"cfg-if",
2891
2917
"once_cell",
2892
2918
"rustversion",
2893
2919
"wasm-bindgen-macro",
2894
-
]
2895
-
2896
-
[[package]]
2897
-
name = "wasm-bindgen-backend"
2898
-
version = "0.2.100"
2899
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2900
-
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
2901
-
dependencies = [
2902
-
"bumpalo",
2903
-
"log",
2904
-
"proc-macro2",
2905
-
"quote",
2906
-
"syn",
2907
2920
"wasm-bindgen-shared",
2908
2921
]
2909
2922
2910
2923
[[package]]
2911
2924
name = "wasm-bindgen-futures"
2912
-
version = "0.4.50"
2925
+
version = "0.4.55"
2913
2926
source = "registry+https://github.com/rust-lang/crates.io-index"
2914
-
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
2927
+
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
2915
2928
dependencies = [
2916
2929
"cfg-if",
2917
2930
"js-sys",
···
2922
2935
2923
2936
[[package]]
2924
2937
name = "wasm-bindgen-macro"
2925
-
version = "0.2.100"
2938
+
version = "0.2.105"
2926
2939
source = "registry+https://github.com/rust-lang/crates.io-index"
2927
-
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
2940
+
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
2928
2941
dependencies = [
2929
2942
"quote",
2930
2943
"wasm-bindgen-macro-support",
···
2932
2945
2933
2946
[[package]]
2934
2947
name = "wasm-bindgen-macro-support"
2935
-
version = "0.2.100"
2948
+
version = "0.2.105"
2936
2949
source = "registry+https://github.com/rust-lang/crates.io-index"
2937
-
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
2950
+
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
2938
2951
dependencies = [
2952
+
"bumpalo",
2939
2953
"proc-macro2",
2940
2954
"quote",
2941
-
"syn",
2942
-
"wasm-bindgen-backend",
2955
+
"syn 2.0.109",
2943
2956
"wasm-bindgen-shared",
2944
2957
]
2945
2958
2946
2959
[[package]]
2947
2960
name = "wasm-bindgen-shared"
2948
-
version = "0.2.100"
2961
+
version = "0.2.105"
2949
2962
source = "registry+https://github.com/rust-lang/crates.io-index"
2950
-
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
2963
+
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
2951
2964
dependencies = [
2952
2965
"unicode-ident",
2953
2966
]
2954
2967
2955
2968
[[package]]
2956
2969
name = "web-sys"
2957
-
version = "0.3.77"
2970
+
version = "0.3.82"
2958
2971
source = "registry+https://github.com/rust-lang/crates.io-index"
2959
-
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
2972
+
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
2960
2973
dependencies = [
2961
2974
"js-sys",
2962
2975
"wasm-bindgen",
···
2974
2987
2975
2988
[[package]]
2976
2989
name = "webpki-roots"
2977
-
version = "1.0.0"
2990
+
version = "1.0.4"
2978
2991
source = "registry+https://github.com/rust-lang/crates.io-index"
2979
-
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
2992
+
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
2980
2993
dependencies = [
2981
2994
"rustls-pki-types",
2982
2995
]
2983
2996
2984
2997
[[package]]
2985
2998
name = "widestring"
2986
-
version = "1.2.0"
2987
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2988
-
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
2989
-
2990
-
[[package]]
2991
-
name = "winapi"
2992
-
version = "0.3.9"
2993
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2994
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2995
-
dependencies = [
2996
-
"winapi-i686-pc-windows-gnu",
2997
-
"winapi-x86_64-pc-windows-gnu",
2998
-
]
2999
-
3000
-
[[package]]
3001
-
name = "winapi-i686-pc-windows-gnu"
3002
-
version = "0.4.0"
3003
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3004
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
3005
-
3006
-
[[package]]
3007
-
name = "winapi-x86_64-pc-windows-gnu"
3008
-
version = "0.4.0"
3009
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3010
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
3011
-
3012
-
[[package]]
3013
-
name = "windows"
3014
-
version = "0.61.1"
3015
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3016
-
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
3017
-
dependencies = [
3018
-
"windows-collections",
3019
-
"windows-core",
3020
-
"windows-future",
3021
-
"windows-link",
3022
-
"windows-numerics",
3023
-
]
3024
-
3025
-
[[package]]
3026
-
name = "windows-collections"
3027
-
version = "0.2.0"
3028
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3029
-
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
3030
-
dependencies = [
3031
-
"windows-core",
3032
-
]
3033
-
3034
-
[[package]]
3035
-
name = "windows-core"
3036
-
version = "0.61.2"
2999
+
version = "1.2.1"
3037
3000
source = "registry+https://github.com/rust-lang/crates.io-index"
3038
-
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
3039
-
dependencies = [
3040
-
"windows-implement",
3041
-
"windows-interface",
3042
-
"windows-link",
3043
-
"windows-result",
3044
-
"windows-strings 0.4.2",
3045
-
]
3001
+
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
3046
3002
3047
3003
[[package]]
3048
-
name = "windows-future"
3049
-
version = "0.2.1"
3004
+
name = "windows-link"
3005
+
version = "0.1.3"
3050
3006
source = "registry+https://github.com/rust-lang/crates.io-index"
3051
-
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
3052
-
dependencies = [
3053
-
"windows-core",
3054
-
"windows-link",
3055
-
"windows-threading",
3056
-
]
3057
-
3058
-
[[package]]
3059
-
name = "windows-implement"
3060
-
version = "0.60.0"
3061
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3062
-
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
3063
-
dependencies = [
3064
-
"proc-macro2",
3065
-
"quote",
3066
-
"syn",
3067
-
]
3068
-
3069
-
[[package]]
3070
-
name = "windows-interface"
3071
-
version = "0.59.1"
3072
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3073
-
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
3074
-
dependencies = [
3075
-
"proc-macro2",
3076
-
"quote",
3077
-
"syn",
3078
-
]
3007
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
3079
3008
3080
3009
[[package]]
3081
3010
name = "windows-link"
3082
-
version = "0.1.1"
3083
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3084
-
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
3085
-
3086
-
[[package]]
3087
-
name = "windows-numerics"
3088
-
version = "0.2.0"
3011
+
version = "0.2.1"
3089
3012
source = "registry+https://github.com/rust-lang/crates.io-index"
3090
-
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
3091
-
dependencies = [
3092
-
"windows-core",
3093
-
"windows-link",
3094
-
]
3013
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
3095
3014
3096
3015
[[package]]
3097
3016
name = "windows-registry"
3098
-
version = "0.4.0"
3017
+
version = "0.5.3"
3099
3018
source = "registry+https://github.com/rust-lang/crates.io-index"
3100
-
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
3019
+
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
3101
3020
dependencies = [
3021
+
"windows-link 0.1.3",
3102
3022
"windows-result",
3103
-
"windows-strings 0.3.1",
3104
-
"windows-targets 0.53.0",
3023
+
"windows-strings",
3105
3024
]
3106
3025
3107
3026
[[package]]
···
3110
3029
source = "registry+https://github.com/rust-lang/crates.io-index"
3111
3030
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3112
3031
dependencies = [
3113
-
"windows-link",
3114
-
]
3115
-
3116
-
[[package]]
3117
-
name = "windows-strings"
3118
-
version = "0.3.1"
3119
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3120
-
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
3121
-
dependencies = [
3122
-
"windows-link",
3032
+
"windows-link 0.1.3",
3123
3033
]
3124
3034
3125
3035
[[package]]
···
3128
3038
source = "registry+https://github.com/rust-lang/crates.io-index"
3129
3039
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3130
3040
dependencies = [
3131
-
"windows-link",
3041
+
"windows-link 0.1.3",
3132
3042
]
3133
3043
3134
3044
[[package]]
···
3159
3069
]
3160
3070
3161
3071
[[package]]
3072
+
name = "windows-sys"
3073
+
version = "0.60.2"
3074
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3075
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
3076
+
dependencies = [
3077
+
"windows-targets 0.53.5",
3078
+
]
3079
+
3080
+
[[package]]
3081
+
name = "windows-sys"
3082
+
version = "0.61.2"
3083
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3084
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
3085
+
dependencies = [
3086
+
"windows-link 0.2.1",
3087
+
]
3088
+
3089
+
[[package]]
3162
3090
name = "windows-targets"
3163
3091
version = "0.48.5"
3164
3092
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3191
3119
3192
3120
[[package]]
3193
3121
name = "windows-targets"
3194
-
version = "0.53.0"
3122
+
version = "0.53.5"
3195
3123
source = "registry+https://github.com/rust-lang/crates.io-index"
3196
-
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
3124
+
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
3197
3125
dependencies = [
3198
-
"windows_aarch64_gnullvm 0.53.0",
3199
-
"windows_aarch64_msvc 0.53.0",
3200
-
"windows_i686_gnu 0.53.0",
3201
-
"windows_i686_gnullvm 0.53.0",
3202
-
"windows_i686_msvc 0.53.0",
3203
-
"windows_x86_64_gnu 0.53.0",
3204
-
"windows_x86_64_gnullvm 0.53.0",
3205
-
"windows_x86_64_msvc 0.53.0",
3206
-
]
3207
-
3208
-
[[package]]
3209
-
name = "windows-threading"
3210
-
version = "0.1.0"
3211
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3212
-
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
3213
-
dependencies = [
3214
-
"windows-link",
3126
+
"windows-link 0.2.1",
3127
+
"windows_aarch64_gnullvm 0.53.1",
3128
+
"windows_aarch64_msvc 0.53.1",
3129
+
"windows_i686_gnu 0.53.1",
3130
+
"windows_i686_gnullvm 0.53.1",
3131
+
"windows_i686_msvc 0.53.1",
3132
+
"windows_x86_64_gnu 0.53.1",
3133
+
"windows_x86_64_gnullvm 0.53.1",
3134
+
"windows_x86_64_msvc 0.53.1",
3215
3135
]
3216
3136
3217
3137
[[package]]
···
3228
3148
3229
3149
[[package]]
3230
3150
name = "windows_aarch64_gnullvm"
3231
-
version = "0.53.0"
3151
+
version = "0.53.1"
3232
3152
source = "registry+https://github.com/rust-lang/crates.io-index"
3233
-
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
3153
+
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
3234
3154
3235
3155
[[package]]
3236
3156
name = "windows_aarch64_msvc"
···
3246
3166
3247
3167
[[package]]
3248
3168
name = "windows_aarch64_msvc"
3249
-
version = "0.53.0"
3169
+
version = "0.53.1"
3250
3170
source = "registry+https://github.com/rust-lang/crates.io-index"
3251
-
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
3171
+
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
3252
3172
3253
3173
[[package]]
3254
3174
name = "windows_i686_gnu"
···
3264
3184
3265
3185
[[package]]
3266
3186
name = "windows_i686_gnu"
3267
-
version = "0.53.0"
3187
+
version = "0.53.1"
3268
3188
source = "registry+https://github.com/rust-lang/crates.io-index"
3269
-
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
3189
+
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
3270
3190
3271
3191
[[package]]
3272
3192
name = "windows_i686_gnullvm"
···
3276
3196
3277
3197
[[package]]
3278
3198
name = "windows_i686_gnullvm"
3279
-
version = "0.53.0"
3199
+
version = "0.53.1"
3280
3200
source = "registry+https://github.com/rust-lang/crates.io-index"
3281
-
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
3201
+
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
3282
3202
3283
3203
[[package]]
3284
3204
name = "windows_i686_msvc"
···
3294
3214
3295
3215
[[package]]
3296
3216
name = "windows_i686_msvc"
3297
-
version = "0.53.0"
3217
+
version = "0.53.1"
3298
3218
source = "registry+https://github.com/rust-lang/crates.io-index"
3299
-
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
3219
+
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
3300
3220
3301
3221
[[package]]
3302
3222
name = "windows_x86_64_gnu"
···
3312
3232
3313
3233
[[package]]
3314
3234
name = "windows_x86_64_gnu"
3315
-
version = "0.53.0"
3235
+
version = "0.53.1"
3316
3236
source = "registry+https://github.com/rust-lang/crates.io-index"
3317
-
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
3237
+
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
3318
3238
3319
3239
[[package]]
3320
3240
name = "windows_x86_64_gnullvm"
···
3330
3250
3331
3251
[[package]]
3332
3252
name = "windows_x86_64_gnullvm"
3333
-
version = "0.53.0"
3253
+
version = "0.53.1"
3334
3254
source = "registry+https://github.com/rust-lang/crates.io-index"
3335
-
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
3255
+
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
3336
3256
3337
3257
[[package]]
3338
3258
name = "windows_x86_64_msvc"
···
3348
3268
3349
3269
[[package]]
3350
3270
name = "windows_x86_64_msvc"
3351
-
version = "0.53.0"
3271
+
version = "0.53.1"
3352
3272
source = "registry+https://github.com/rust-lang/crates.io-index"
3353
-
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
3273
+
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
3354
3274
3355
3275
[[package]]
3356
3276
name = "winreg"
···
3363
3283
]
3364
3284
3365
3285
[[package]]
3366
-
name = "wit-bindgen-rt"
3367
-
version = "0.39.0"
3286
+
name = "wit-bindgen"
3287
+
version = "0.46.0"
3368
3288
source = "registry+https://github.com/rust-lang/crates.io-index"
3369
-
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
3370
-
dependencies = [
3371
-
"bitflags",
3372
-
]
3289
+
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
3373
3290
3374
3291
[[package]]
3375
3292
name = "writeable"
3376
-
version = "0.6.1"
3293
+
version = "0.6.2"
3377
3294
source = "registry+https://github.com/rust-lang/crates.io-index"
3378
-
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
3295
+
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
3379
3296
3380
3297
[[package]]
3381
3298
name = "yoke"
3382
-
version = "0.8.0"
3299
+
version = "0.8.1"
3383
3300
source = "registry+https://github.com/rust-lang/crates.io-index"
3384
-
checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
3301
+
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
3385
3302
dependencies = [
3386
-
"serde",
3387
3303
"stable_deref_trait",
3388
3304
"yoke-derive",
3389
3305
"zerofrom",
···
3391
3307
3392
3308
[[package]]
3393
3309
name = "yoke-derive"
3394
-
version = "0.8.0"
3310
+
version = "0.8.1"
3395
3311
source = "registry+https://github.com/rust-lang/crates.io-index"
3396
-
checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
3312
+
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
3397
3313
dependencies = [
3398
3314
"proc-macro2",
3399
3315
"quote",
3400
-
"syn",
3316
+
"syn 2.0.109",
3401
3317
"synstructure",
3402
3318
]
3403
3319
3404
3320
[[package]]
3405
3321
name = "zerocopy"
3406
-
version = "0.8.25"
3322
+
version = "0.8.27"
3407
3323
source = "registry+https://github.com/rust-lang/crates.io-index"
3408
-
checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
3324
+
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
3409
3325
dependencies = [
3410
3326
"zerocopy-derive",
3411
3327
]
3412
3328
3413
3329
[[package]]
3414
3330
name = "zerocopy-derive"
3415
-
version = "0.8.25"
3331
+
version = "0.8.27"
3416
3332
source = "registry+https://github.com/rust-lang/crates.io-index"
3417
-
checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
3333
+
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
3418
3334
dependencies = [
3419
3335
"proc-macro2",
3420
3336
"quote",
3421
-
"syn",
3337
+
"syn 2.0.109",
3422
3338
]
3423
3339
3424
3340
[[package]]
···
3438
3354
dependencies = [
3439
3355
"proc-macro2",
3440
3356
"quote",
3441
-
"syn",
3357
+
"syn 2.0.109",
3442
3358
"synstructure",
3443
3359
]
3444
3360
3445
3361
[[package]]
3446
3362
name = "zeroize"
3447
-
version = "1.8.1"
3363
+
version = "1.8.2"
3448
3364
source = "registry+https://github.com/rust-lang/crates.io-index"
3449
-
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
3365
+
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
3450
3366
dependencies = [
3451
3367
"zeroize_derive",
3452
3368
]
···
3459
3375
dependencies = [
3460
3376
"proc-macro2",
3461
3377
"quote",
3462
-
"syn",
3378
+
"syn 2.0.109",
3463
3379
]
3464
3380
3465
3381
[[package]]
3466
3382
name = "zerotrie"
3467
-
version = "0.2.2"
3383
+
version = "0.2.3"
3468
3384
source = "registry+https://github.com/rust-lang/crates.io-index"
3469
-
checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
3385
+
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
3470
3386
dependencies = [
3471
3387
"displaydoc",
3472
3388
"yoke",
···
3475
3391
3476
3392
[[package]]
3477
3393
name = "zerovec"
3478
-
version = "0.11.2"
3394
+
version = "0.11.5"
3479
3395
source = "registry+https://github.com/rust-lang/crates.io-index"
3480
-
checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
3396
+
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
3481
3397
dependencies = [
3482
3398
"yoke",
3483
3399
"zerofrom",
···
3486
3402
3487
3403
[[package]]
3488
3404
name = "zerovec-derive"
3489
-
version = "0.11.1"
3405
+
version = "0.11.2"
3490
3406
source = "registry+https://github.com/rust-lang/crates.io-index"
3491
-
checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
3407
+
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
3492
3408
dependencies = [
3493
3409
"proc-macro2",
3494
3410
"quote",
3495
-
"syn",
3411
+
"syn 2.0.109",
3496
3412
]
3497
3413
3498
3414
[[package]]
···
3515
3431
3516
3432
[[package]]
3517
3433
name = "zstd-sys"
3518
-
version = "2.0.15+zstd.1.5.7"
3434
+
version = "2.0.16+zstd.1.5.7"
3519
3435
source = "registry+https://github.com/rust-lang/crates.io-index"
3520
-
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
3436
+
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
3521
3437
dependencies = [
3522
3438
"cc",
3523
3439
"pkg-config",
+26
-20
Cargo.toml
+26
-20
Cargo.toml
···
1
1
[workspace]
2
2
members = [
3
3
"crates/atproto-client",
4
+
"crates/atproto-extras",
4
5
"crates/atproto-identity",
5
6
"crates/atproto-jetstream",
6
7
"crates/atproto-oauth-aip",
7
8
"crates/atproto-oauth-axum",
8
9
"crates/atproto-oauth",
9
10
"crates/atproto-record",
11
+
"crates/atproto-tap",
10
12
"crates/atproto-xrpcs-helloworld",
11
13
"crates/atproto-xrpcs",
12
14
"crates/atproto-lexicon",
···
24
26
categories = ["command-line-utilities", "web-programming"]
25
27
26
28
[workspace.dependencies]
29
+
atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" }
27
30
atproto-client = { version = "0.13.0", path = "crates/atproto-client" }
31
+
atproto-extras = { version = "0.13.0", path = "crates/atproto-extras" }
28
32
atproto-identity = { version = "0.13.0", path = "crates/atproto-identity" }
33
+
atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" }
29
34
atproto-oauth = { version = "0.13.0", path = "crates/atproto-oauth" }
30
-
atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" }
31
35
atproto-oauth-aip = { version = "0.13.0", path = "crates/atproto-oauth-aip" }
36
+
atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" }
32
37
atproto-record = { version = "0.13.0", path = "crates/atproto-record" }
38
+
atproto-tap = { version = "0.13.0", path = "crates/atproto-tap" }
33
39
atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" }
34
-
atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" }
35
-
atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" }
36
40
41
+
ammonia = "4.0"
37
42
anyhow = "1.0"
38
-
async-trait = "0.1.88"
39
-
base64 = "0.22.1"
40
-
chrono = {version = "0.4.41", default-features = false, features = ["std", "now"]}
43
+
async-trait = "0.1"
44
+
base64 = "0.22"
45
+
chrono = {version = "0.4", default-features = false, features = ["std", "now"]}
41
46
clap = { version = "4.5", features = ["derive", "env"] }
42
-
ecdsa = { version = "0.16.9", features = ["std"] }
43
-
elliptic-curve = { version = "0.13.8", features = ["jwk", "serde"] }
47
+
ecdsa = { version = "0.16", features = ["std"] }
48
+
elliptic-curve = { version = "0.13", features = ["jwk", "serde"] }
44
49
futures = "0.3"
45
50
hickory-resolver = { version = "0.25" }
46
-
http = "1.3.1"
47
-
k256 = "0.13.4"
51
+
http = "1.3"
52
+
k256 = "0.13"
48
53
lru = "0.12"
49
-
multibase = "0.9.1"
50
-
p256 = "0.13.2"
51
-
p384 = "0.13.0"
54
+
multibase = "0.9"
55
+
p256 = "0.13"
56
+
p384 = "0.13"
52
57
rand = "0.8"
58
+
regex = "1.11"
53
59
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] }
54
-
reqwest-chain = "1.0.0"
55
-
reqwest-middleware = { version = "0.4.2", features = ["json", "multipart"]}
60
+
reqwest-chain = "1.0"
61
+
reqwest-middleware = { version = "0.4", features = ["json", "multipart"]}
56
62
rpassword = "7.3"
57
63
secrecy = { version = "0.10", features = ["serde"] }
58
64
serde = { version = "1.0", features = ["derive"] }
59
-
serde_ipld_dagcbor = "0.6.3"
60
-
serde_json = "1.0"
61
-
sha2 = "0.10.9"
65
+
serde_ipld_dagcbor = "0.6"
66
+
serde_json = { version = "1.0", features = ["unbounded_depth"] }
67
+
sha2 = "0.10"
62
68
thiserror = "2.0"
63
69
tokio = { version = "1.41", features = ["macros", "rt", "rt-multi-thread"] }
64
70
tokio-websockets = { version = "0.11.4", features = ["client", "rustls-native-roots", "rand", "ring"] }
65
71
tokio-util = "0.7"
66
72
tracing = { version = "0.1", features = ["async-await"] }
67
-
ulid = "1.2.1"
73
+
ulid = "1.2"
68
74
zstd = "0.13"
69
75
url = "2.5"
70
76
urlencoding = "2.1"
71
77
72
-
zeroize = { version = "1.8.1", features = ["zeroize_derive"] }
78
+
zeroize = { version = "1.8", features = ["zeroize_derive"] }
73
79
74
80
[workspace.lints.rust]
75
81
unsafe_code = "forbid"
+4
-4
README.md
+4
-4
README.md
···
131
131
### XRPC Service
132
132
133
133
```rust
134
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
134
+
use atproto_xrpcs::authorization::Authorization;
135
135
use axum::{Json, Router, extract::Query, routing::get};
136
136
use serde::Deserialize;
137
137
use serde_json::json;
···
143
143
144
144
async fn handle_hello(
145
145
params: Query<HelloParams>,
146
-
authorization: Option<ResolvingAuthorization>,
146
+
authorization: Option<Authorization>,
147
147
) -> Json<serde_json::Value> {
148
148
let subject = params.subject.as_deref().unwrap_or("World");
149
-
149
+
150
150
let message = if let Some(auth) = authorization {
151
151
format!("Hello, authenticated {}! (caller: {})", subject, auth.subject())
152
152
} else {
153
153
format!("Hello, {}!", subject)
154
154
};
155
-
155
+
156
156
Json(json!({ "message": message }))
157
157
}
158
158
+179
crates/atproto-attestation/src/attestation.rs
+179
crates/atproto-attestation/src/attestation.rs
···
43
43
Ok(Value::Object(record_obj))
44
44
}
45
45
46
+
/// Creates a cryptographic signature for a record with attestation metadata.
47
+
///
48
+
/// This is a low-level function that generates just the signature bytes without
49
+
/// embedding them in a record structure. It's useful when you need to create
50
+
/// signatures independently or for custom attestation workflows.
51
+
///
52
+
/// The signature is created over a content CID that binds together:
53
+
/// - The record content
54
+
/// - The attestation metadata
55
+
/// - The repository DID (to prevent replay attacks)
56
+
///
57
+
/// # Arguments
58
+
///
59
+
/// * `record_input` - The record to sign (as AnyInput: String, Json, or TypedLexicon)
60
+
/// * `attestation_input` - The attestation metadata (as AnyInput)
61
+
/// * `repository` - The repository DID where this record will be stored
62
+
/// * `private_key_data` - The private key to use for signing
63
+
///
64
+
/// # Returns
65
+
///
66
+
/// A byte vector containing the normalized ECDSA signature that can be verified
67
+
/// against the same content CID.
68
+
///
69
+
/// # Errors
70
+
///
71
+
/// Returns an error if:
72
+
/// - CID generation fails
73
+
/// - Signature creation fails
74
+
/// - Signature normalization fails
75
+
///
76
+
/// # Example
77
+
///
78
+
/// ```rust
79
+
/// use atproto_attestation::{create_signature, input::AnyInput};
80
+
/// use atproto_identity::key::{KeyType, generate_key};
81
+
/// use serde_json::json;
82
+
///
83
+
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
84
+
/// let private_key = generate_key(KeyType::K256Private)?;
85
+
///
86
+
/// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"});
87
+
/// let metadata = json!({"$type": "com.example.signature"});
88
+
///
89
+
/// let signature_bytes = create_signature(
90
+
/// AnyInput::Serialize(record),
91
+
/// AnyInput::Serialize(metadata),
92
+
/// "did:plc:repo123",
93
+
/// &private_key
94
+
/// )?;
95
+
///
96
+
/// // signature_bytes can now be base64-encoded or used as needed
97
+
/// # Ok(())
98
+
/// # }
99
+
/// ```
100
+
pub fn create_signature<R, M>(
101
+
record_input: AnyInput<R>,
102
+
attestation_input: AnyInput<M>,
103
+
repository: &str,
104
+
private_key_data: &KeyData,
105
+
) -> Result<Vec<u8>, AttestationError>
106
+
where
107
+
R: Serialize + Clone,
108
+
M: Serialize + Clone,
109
+
{
110
+
// Step 1: Create a content CID from record + attestation + repository
111
+
let content_cid = create_attestation_cid(record_input, attestation_input, repository)?;
112
+
113
+
// Step 2: Sign the CID bytes
114
+
let raw_signature = sign(private_key_data, &content_cid.to_bytes())
115
+
.map_err(|error| AttestationError::SignatureCreationFailed { error })?;
116
+
117
+
// Step 3: Normalize the signature to ensure consistent format
118
+
normalize_signature(raw_signature, private_key_data.key_type())
119
+
}
120
+
46
121
/// Creates a remote attestation with both the attested record and proof record.
47
122
///
48
123
/// This is the recommended way to create remote attestations. It generates both:
···
602
677
assert!(sig.get("signature").is_some());
603
678
assert!(sig.get("key").is_some());
604
679
assert!(sig.get("repository").is_none()); // Should not be in final signature
680
+
681
+
Ok(())
682
+
}
683
+
684
+
#[test]
685
+
fn create_signature_returns_valid_bytes() -> Result<(), Box<dyn std::error::Error>> {
686
+
let private_key = generate_key(KeyType::K256Private)?;
687
+
let public_key = to_public(&private_key)?;
688
+
689
+
let record = json!({
690
+
"$type": "app.example.record",
691
+
"body": "Test signature creation"
692
+
});
693
+
694
+
let metadata = json!({
695
+
"$type": "com.example.signature",
696
+
"key": format!("{}", public_key)
697
+
});
698
+
699
+
let repository = "did:plc:test123";
700
+
701
+
// Create signature
702
+
let signature_bytes = create_signature(
703
+
AnyInput::Serialize(record.clone()),
704
+
AnyInput::Serialize(metadata.clone()),
705
+
repository,
706
+
&private_key,
707
+
)?;
708
+
709
+
// Verify signature is not empty
710
+
assert!(!signature_bytes.is_empty(), "Signature bytes should not be empty");
711
+
712
+
// Verify signature length is reasonable for ECDSA (typically 64-72 bytes for secp256k1)
713
+
assert!(
714
+
signature_bytes.len() >= 64 && signature_bytes.len() <= 73,
715
+
"Signature length should be between 64 and 73 bytes, got {}",
716
+
signature_bytes.len()
717
+
);
718
+
719
+
// Verify we can validate the signature
720
+
let content_cid = create_attestation_cid(
721
+
AnyInput::Serialize(record),
722
+
AnyInput::Serialize(metadata),
723
+
repository,
724
+
)?;
725
+
726
+
validate(&public_key, &signature_bytes, &content_cid.to_bytes())?;
727
+
728
+
Ok(())
729
+
}
730
+
731
+
#[test]
732
+
fn create_signature_different_inputs_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> {
733
+
let private_key = generate_key(KeyType::K256Private)?;
734
+
735
+
let record1 = json!({"$type": "app.example.record", "body": "First message"});
736
+
let record2 = json!({"$type": "app.example.record", "body": "Second message"});
737
+
let metadata = json!({"$type": "com.example.signature"});
738
+
let repository = "did:plc:test123";
739
+
740
+
let sig1 = create_signature(
741
+
AnyInput::Serialize(record1),
742
+
AnyInput::Serialize(metadata.clone()),
743
+
repository,
744
+
&private_key,
745
+
)?;
746
+
747
+
let sig2 = create_signature(
748
+
AnyInput::Serialize(record2),
749
+
AnyInput::Serialize(metadata),
750
+
repository,
751
+
&private_key,
752
+
)?;
753
+
754
+
assert_ne!(sig1, sig2, "Different records should produce different signatures");
755
+
756
+
Ok(())
757
+
}
758
+
759
+
#[test]
760
+
fn create_signature_different_repositories_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> {
761
+
let private_key = generate_key(KeyType::K256Private)?;
762
+
763
+
let record = json!({"$type": "app.example.record", "body": "Message"});
764
+
let metadata = json!({"$type": "com.example.signature"});
765
+
766
+
let sig1 = create_signature(
767
+
AnyInput::Serialize(record.clone()),
768
+
AnyInput::Serialize(metadata.clone()),
769
+
"did:plc:repo1",
770
+
&private_key,
771
+
)?;
772
+
773
+
let sig2 = create_signature(
774
+
AnyInput::Serialize(record),
775
+
AnyInput::Serialize(metadata),
776
+
"did:plc:repo2",
777
+
&private_key,
778
+
)?;
779
+
780
+
assert_ne!(
781
+
sig1, sig2,
782
+
"Different repository DIDs should produce different signatures"
783
+
);
605
784
606
785
Ok(())
607
786
}
+19
-6
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
+19
-6
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
···
47
47
48
48
use anyhow::{Context, Result, anyhow};
49
49
use atproto_attestation::AnyInput;
50
-
use atproto_identity::key::{KeyData, KeyResolver};
50
+
use atproto_identity::key::{KeyData, KeyResolver, identify_key};
51
51
use clap::Parser;
52
52
use serde_json::Value;
53
53
use std::{
···
115
115
attestation: Option<String>,
116
116
}
117
117
118
-
struct FakeKeyResolver {}
118
+
/// A key resolver that supports `did:key:` identifiers directly.
119
+
///
120
+
/// This resolver handles key references that are encoded as `did:key:` strings,
121
+
/// parsing them to extract the cryptographic key data. For other DID methods,
122
+
/// it returns an error since those would require fetching DID documents.
123
+
struct DidKeyResolver {}
119
124
120
125
#[async_trait::async_trait]
121
-
impl KeyResolver for FakeKeyResolver {
122
-
async fn resolve(&self, _subject: &str) -> Result<KeyData> {
123
-
todo!()
126
+
impl KeyResolver for DidKeyResolver {
127
+
async fn resolve(&self, subject: &str) -> Result<KeyData> {
128
+
if subject.starts_with("did:key:") {
129
+
identify_key(subject)
130
+
.map_err(|e| anyhow!("Failed to parse did:key '{}': {}", subject, e))
131
+
} else {
132
+
Err(anyhow!(
133
+
"Subject '{}' is not a did:key: identifier. Only did:key: subjects are supported by this resolver.",
134
+
subject
135
+
))
136
+
}
124
137
}
125
138
}
126
139
···
175
188
identity_resolver,
176
189
};
177
190
178
-
let key_resolver = FakeKeyResolver {};
191
+
let key_resolver = DidKeyResolver {};
179
192
180
193
atproto_attestation::verify_record(
181
194
AnyInput::Serialize(record.clone()),
+1
-1
crates/atproto-attestation/src/lib.rs
+1
-1
crates/atproto-attestation/src/lib.rs
+6
crates/atproto-client/Cargo.toml
+6
crates/atproto-client/Cargo.toml
+165
crates/atproto-client/src/bin/atproto-client-put-record.rs
+165
crates/atproto-client/src/bin/atproto-client-put-record.rs
···
1
+
//! AT Protocol client tool for writing records to a repository.
2
+
//!
3
+
//! This binary tool creates or updates records in an AT Protocol repository
4
+
//! using app password authentication. It resolves the subject to a DID,
5
+
//! creates a session, and writes the record using the putRecord XRPC method.
6
+
//!
7
+
//! # Usage
8
+
//!
9
+
//! ```text
10
+
//! ATPROTO_PASSWORD=<password> atproto-client-put-record <subject> <record_key> <record_json>
11
+
//! ```
12
+
//!
13
+
//! # Environment Variables
14
+
//!
15
+
//! - `ATPROTO_PASSWORD` - Required. App password for authentication.
16
+
//! - `CERTIFICATE_BUNDLES` - Custom CA certificate bundles.
17
+
//! - `USER_AGENT` - Custom user agent string.
18
+
//! - `DNS_NAMESERVERS` - Custom DNS nameservers.
19
+
//! - `PLC_HOSTNAME` - Override PLC hostname (default: plc.directory).
20
+
21
+
use anyhow::Result;
22
+
use atproto_client::{
23
+
client::{AppPasswordAuth, Auth},
24
+
com::atproto::{
25
+
repo::{put_record, PutRecordRequest, PutRecordResponse},
26
+
server::create_session,
27
+
},
28
+
errors::CliError,
29
+
};
30
+
use atproto_identity::{
31
+
config::{CertificateBundles, DnsNameservers, default_env, optional_env, version},
32
+
plc,
33
+
resolve::{HickoryDnsResolver, resolve_subject},
34
+
web,
35
+
};
36
+
use std::env;
37
+
38
+
fn print_usage() {
39
+
eprintln!("Usage: atproto-client-put-record <subject> <record_key> <record_json>");
40
+
eprintln!();
41
+
eprintln!("Arguments:");
42
+
eprintln!(" <subject> Handle or DID of the repository owner");
43
+
eprintln!(" <record_key> Record key (rkey) for the record");
44
+
eprintln!(" <record_json> JSON record data (must include $type field)");
45
+
eprintln!();
46
+
eprintln!("Environment Variables:");
47
+
eprintln!(" ATPROTO_PASSWORD Required. App password for authentication.");
48
+
eprintln!(" CERTIFICATE_BUNDLES Custom CA certificate bundles.");
49
+
eprintln!(" USER_AGENT Custom user agent string.");
50
+
eprintln!(" DNS_NAMESERVERS Custom DNS nameservers.");
51
+
eprintln!(" PLC_HOSTNAME Override PLC hostname (default: plc.directory).");
52
+
}
53
+
54
+
#[tokio::main]
55
+
async fn main() -> Result<()> {
56
+
let args: Vec<String> = env::args().collect();
57
+
58
+
if args.len() != 4 {
59
+
print_usage();
60
+
std::process::exit(1);
61
+
}
62
+
63
+
let subject = &args[1];
64
+
let record_key = &args[2];
65
+
let record_json = &args[3];
66
+
67
+
// Get password from environment variable
68
+
let password = env::var("ATPROTO_PASSWORD").map_err(|_| {
69
+
anyhow::anyhow!("ATPROTO_PASSWORD environment variable is required")
70
+
})?;
71
+
72
+
// Set up HTTP client configuration
73
+
let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?;
74
+
let default_user_agent = format!(
75
+
"atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)",
76
+
version()?
77
+
);
78
+
let user_agent = default_env("USER_AGENT", &default_user_agent);
79
+
let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?;
80
+
let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory");
81
+
82
+
let mut client_builder = reqwest::Client::builder();
83
+
for ca_certificate in certificate_bundles.as_ref() {
84
+
let cert = std::fs::read(ca_certificate)?;
85
+
let cert = reqwest::Certificate::from_pem(&cert)?;
86
+
client_builder = client_builder.add_root_certificate(cert);
87
+
}
88
+
89
+
client_builder = client_builder.user_agent(user_agent);
90
+
let http_client = client_builder.build()?;
91
+
92
+
let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref());
93
+
94
+
// Parse the record JSON
95
+
let record: serde_json::Value = serde_json::from_str(record_json).map_err(|err| {
96
+
tracing::error!(error = ?err, "Failed to parse record JSON");
97
+
anyhow::anyhow!("Failed to parse record JSON: {}", err)
98
+
})?;
99
+
100
+
// Extract collection from $type field
101
+
let collection = record
102
+
.get("$type")
103
+
.and_then(|v| v.as_str())
104
+
.ok_or_else(|| anyhow::anyhow!("Record must contain a $type field for the collection"))?
105
+
.to_string();
106
+
107
+
// Resolve subject to DID
108
+
let did = resolve_subject(&http_client, &dns_resolver, subject).await?;
109
+
110
+
// Get DID document to find PDS endpoint
111
+
let document = if did.starts_with("did:plc:") {
112
+
plc::query(&http_client, &plc_hostname, &did).await?
113
+
} else if did.starts_with("did:web:") {
114
+
web::query(&http_client, &did).await?
115
+
} else {
116
+
anyhow::bail!("Unsupported DID method: {}", did);
117
+
};
118
+
119
+
// Get PDS endpoint from the DID document
120
+
let pds_endpoints = document.pds_endpoints();
121
+
let pds_endpoint = pds_endpoints
122
+
.first()
123
+
.ok_or_else(|| CliError::NoPdsEndpointFound { did: did.clone() })?;
124
+
125
+
// Create session
126
+
let session = create_session(&http_client, pds_endpoint, &did, &password, None).await?;
127
+
128
+
// Set up app password authentication
129
+
let auth = Auth::AppPassword(AppPasswordAuth {
130
+
access_token: session.access_jwt.clone(),
131
+
});
132
+
133
+
// Create put record request
134
+
let put_request = PutRecordRequest {
135
+
repo: session.did.clone(),
136
+
collection,
137
+
record_key: record_key.clone(),
138
+
validate: true,
139
+
record,
140
+
swap_commit: None,
141
+
swap_record: None,
142
+
};
143
+
144
+
// Execute put record
145
+
let response = put_record(&http_client, &auth, pds_endpoint, put_request).await?;
146
+
147
+
match response {
148
+
PutRecordResponse::StrongRef { uri, cid, .. } => {
149
+
println!(
150
+
"{}",
151
+
serde_json::to_string_pretty(&serde_json::json!({
152
+
"uri": uri,
153
+
"cid": cid
154
+
}))?
155
+
);
156
+
}
157
+
PutRecordResponse::Error(err) => {
158
+
let error_message = err.error_message();
159
+
tracing::error!(error = %error_message, "putRecord failed");
160
+
anyhow::bail!("putRecord failed: {}", error_message);
161
+
}
162
+
}
163
+
164
+
Ok(())
165
+
}
+31
-5
crates/atproto-client/src/record_resolver.rs
+31
-5
crates/atproto-client/src/record_resolver.rs
···
1
1
//! Helpers for resolving AT Protocol records referenced by URI.
2
2
3
3
use std::str::FromStr;
4
+
use std::sync::Arc;
4
5
5
6
use anyhow::{Result, anyhow, bail};
6
7
use async_trait::async_trait;
8
+
use atproto_identity::traits::IdentityResolver;
7
9
use atproto_record::aturi::ATURI;
8
10
9
11
use crate::{
···
24
26
}
25
27
26
28
/// Resolver that fetches records using public XRPC endpoints.
29
+
///
30
+
/// Uses an identity resolver to dynamically determine the PDS endpoint for each record.
27
31
#[derive(Clone)]
28
32
pub struct HttpRecordResolver {
29
33
http_client: reqwest::Client,
30
-
base_url: String,
34
+
identity_resolver: Arc<dyn IdentityResolver>,
31
35
}
32
36
33
37
impl HttpRecordResolver {
34
-
/// Create a new resolver using the provided HTTP client and PDS base URL.
35
-
pub fn new(http_client: reqwest::Client, base_url: impl Into<String>) -> Self {
38
+
/// Create a new resolver using the provided HTTP client and identity resolver.
39
+
///
40
+
/// The identity resolver is used to dynamically determine the PDS endpoint for each record
41
+
/// based on the authority (DID or handle) in the AT URI.
42
+
pub fn new(
43
+
http_client: reqwest::Client,
44
+
identity_resolver: Arc<dyn IdentityResolver>,
45
+
) -> Self {
36
46
Self {
37
47
http_client,
38
-
base_url: base_url.into(),
48
+
identity_resolver,
39
49
}
40
50
}
41
51
}
···
47
57
T: serde::de::DeserializeOwned + Send,
48
58
{
49
59
let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?;
60
+
61
+
// Resolve the authority (DID or handle) to get the DID document
62
+
let document = self
63
+
.identity_resolver
64
+
.resolve(&parsed.authority)
65
+
.await
66
+
.map_err(|error| {
67
+
anyhow!("Failed to resolve identity for {}: {}", parsed.authority, error)
68
+
})?;
69
+
70
+
// Extract PDS endpoint from the DID document
71
+
let pds_endpoints = document.pds_endpoints();
72
+
let base_url = pds_endpoints
73
+
.first()
74
+
.ok_or_else(|| anyhow!("No PDS endpoint found for {}", parsed.authority))?;
75
+
50
76
let auth = Auth::None;
51
77
52
78
let response = get_record(
53
79
&self.http_client,
54
80
&auth,
55
-
&self.base_url,
81
+
base_url,
56
82
&parsed.authority,
57
83
&parsed.collection,
58
84
&parsed.record_key,
+43
crates/atproto-extras/Cargo.toml
+43
crates/atproto-extras/Cargo.toml
···
1
+
[package]
2
+
name = "atproto-extras"
3
+
version = "0.13.0"
4
+
description = "AT Protocol extras - facet parsing and rich text utilities"
5
+
readme = "README.md"
6
+
homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs"
7
+
documentation = "https://docs.rs/atproto-extras"
8
+
9
+
edition.workspace = true
10
+
rust-version.workspace = true
11
+
authors.workspace = true
12
+
repository.workspace = true
13
+
license.workspace = true
14
+
keywords.workspace = true
15
+
categories.workspace = true
16
+
17
+
[dependencies]
18
+
atproto-identity.workspace = true
19
+
atproto-record.workspace = true
20
+
21
+
anyhow.workspace = true
22
+
async-trait.workspace = true
23
+
clap = { workspace = true, optional = true }
24
+
regex.workspace = true
25
+
reqwest = { workspace = true, optional = true }
26
+
serde_json = { workspace = true, optional = true }
27
+
tokio = { workspace = true, optional = true }
28
+
29
+
[dev-dependencies]
30
+
tokio = { workspace = true, features = ["macros", "rt"] }
31
+
32
+
[features]
33
+
default = ["hickory-dns"]
34
+
hickory-dns = ["atproto-identity/hickory-dns"]
35
+
clap = ["dep:clap"]
36
+
cli = ["dep:clap", "dep:serde_json", "dep:tokio", "dep:reqwest"]
37
+
38
+
[[bin]]
39
+
name = "atproto-extras-parse-facets"
40
+
required-features = ["clap", "cli", "hickory-dns"]
41
+
42
+
[lints]
43
+
workspace = true
+128
crates/atproto-extras/README.md
+128
crates/atproto-extras/README.md
···
1
+
# atproto-extras
2
+
3
+
Extra utilities for AT Protocol applications, including rich text facet parsing.
4
+
5
+
## Features
6
+
7
+
- **Facet Parsing**: Extract mentions (`@handle`), URLs, and hashtags (`#tag`) from plain text with correct UTF-8 byte offset calculation
8
+
- **Identity Integration**: Resolve mention handles to DIDs during parsing
9
+
10
+
## Installation
11
+
12
+
Add to your `Cargo.toml`:
13
+
14
+
```toml
15
+
[dependencies]
16
+
atproto-extras = "0.13"
17
+
```
18
+
19
+
## Usage
20
+
21
+
### Parsing Text for Facets
22
+
23
+
```rust
24
+
use atproto_extras::{parse_urls, parse_tags};
25
+
use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
26
+
27
+
let text = "Check out https://example.com #rust";
28
+
29
+
// Parse URLs and tags - returns Vec<Facet> directly
30
+
let url_facets = parse_urls(text);
31
+
let tag_facets = parse_tags(text);
32
+
33
+
// Each facet includes byte positions and typed features
34
+
for facet in url_facets {
35
+
if let Some(FacetFeature::Link(link)) = facet.features.first() {
36
+
println!("URL at bytes {}..{}: {}",
37
+
facet.index.byte_start, facet.index.byte_end, link.uri);
38
+
}
39
+
}
40
+
41
+
for facet in tag_facets {
42
+
if let Some(FacetFeature::Tag(tag)) = facet.features.first() {
43
+
println!("Tag at bytes {}..{}: #{}",
44
+
facet.index.byte_start, facet.index.byte_end, tag.tag);
45
+
}
46
+
}
47
+
```
48
+
49
+
### Parsing Mentions
50
+
51
+
Mention parsing requires an `IdentityResolver` to convert handles to DIDs:
52
+
53
+
```rust
54
+
use atproto_extras::{parse_mentions, FacetLimits};
55
+
use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
56
+
57
+
let text = "Hello @alice.bsky.social!";
58
+
let limits = FacetLimits::default();
59
+
60
+
// Requires an async context and IdentityResolver
61
+
let facets = parse_mentions(text, &resolver, &limits).await;
62
+
63
+
for facet in facets {
64
+
if let Some(FacetFeature::Mention(mention)) = facet.features.first() {
65
+
println!("Mention at bytes {}..{} resolved to {}",
66
+
facet.index.byte_start, facet.index.byte_end, mention.did);
67
+
}
68
+
}
69
+
```
70
+
71
+
Mentions that cannot be resolved to a valid DID are automatically skipped. Mentions appearing within URLs are also excluded.
72
+
73
+
### Creating AT Protocol Facets
74
+
75
+
```rust
76
+
use atproto_extras::{parse_facets_from_text, FacetLimits};
77
+
78
+
let text = "Hello @alice.bsky.social! Check https://rust-lang.org #rust";
79
+
let limits = FacetLimits::default();
80
+
81
+
// Requires an async context and IdentityResolver
82
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
83
+
84
+
if let Some(facets) = facets {
85
+
for facet in &facets {
86
+
println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end);
87
+
}
88
+
}
89
+
```
90
+
91
+
## Byte Offset Handling
92
+
93
+
AT Protocol facets use UTF-8 byte offsets, not character indices. This is critical for correct handling of multi-byte characters like emojis or non-ASCII text.
94
+
95
+
```rust
96
+
use atproto_extras::parse_urls;
97
+
98
+
// Text with emojis (multi-byte UTF-8 characters)
99
+
let text = "โจ Check https://example.com โจ";
100
+
101
+
let facets = parse_urls(text);
102
+
// Byte positions correctly account for the 4-byte emoji
103
+
assert_eq!(facets[0].index.byte_start, 11); // After "โจ Check " (4 + 1 + 6 = 11 bytes)
104
+
```
105
+
106
+
## Facet Limits
107
+
108
+
Use `FacetLimits` to control the maximum number of facets processed:
109
+
110
+
```rust
111
+
use atproto_extras::FacetLimits;
112
+
113
+
// Default limits
114
+
let limits = FacetLimits::default();
115
+
// mentions_max: 5, tags_max: 5, links_max: 5, max: 10
116
+
117
+
// Custom limits
118
+
let custom = FacetLimits {
119
+
mentions_max: 10,
120
+
tags_max: 10,
121
+
links_max: 10,
122
+
max: 20,
123
+
};
124
+
```
125
+
126
+
## License
127
+
128
+
MIT
+176
crates/atproto-extras/src/bin/atproto-extras-parse-facets.rs
+176
crates/atproto-extras/src/bin/atproto-extras-parse-facets.rs
···
1
+
//! Command-line tool for generating AT Protocol facet arrays from text.
2
+
//!
3
+
//! This tool parses a string and outputs the facet array in JSON format.
4
+
//! Facets include mentions (@handle), URLs (https://...), and hashtags (#tag).
5
+
//!
6
+
//! By default, mentions are detected but output with placeholder DIDs. Use
7
+
//! `--resolve-mentions` to resolve handles to actual DIDs (requires network access).
8
+
//!
9
+
//! # Usage
10
+
//!
11
+
//! ```bash
12
+
//! # Parse facets without resolving mentions
13
+
//! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- "Check out https://example.com and #rust"
14
+
//!
15
+
//! # Resolve mentions to DIDs
16
+
//! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- --resolve-mentions "Hello @bsky.app!"
17
+
//! ```
18
+
19
+
use atproto_extras::{FacetLimits, parse_mentions, parse_tags, parse_urls};
20
+
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
21
+
use atproto_record::lexicon::app::bsky::richtext::facet::{
22
+
ByteSlice, Facet, FacetFeature, Mention,
23
+
};
24
+
use clap::Parser;
25
+
use regex::bytes::Regex;
26
+
use std::sync::Arc;
27
+
28
+
/// Parse text and output AT Protocol facets as JSON.
29
+
#[derive(Parser)]
30
+
#[command(
31
+
name = "atproto-extras-parse-facets",
32
+
version,
33
+
about = "Parse text and output AT Protocol facets as JSON",
34
+
long_about = "This tool parses a string for mentions, URLs, and hashtags,\n\
35
+
then outputs the corresponding AT Protocol facet array in JSON format.\n\n\
36
+
By default, mentions are detected but output with placeholder DIDs.\n\
37
+
Use --resolve-mentions to resolve handles to actual DIDs (requires network)."
38
+
)]
39
+
struct Args {
40
+
/// The text to parse for facets
41
+
text: String,
42
+
43
+
/// Resolve mention handles to DIDs (requires network access)
44
+
#[arg(long)]
45
+
resolve_mentions: bool,
46
+
47
+
/// Show debug information on stderr
48
+
#[arg(long, short = 'd')]
49
+
debug: bool,
50
+
}
51
+
52
+
/// Parse mention spans from text without resolution (returns placeholder DIDs).
53
+
fn parse_mention_spans(text: &str) -> Vec<Facet> {
54
+
let mut facets = Vec::new();
55
+
56
+
// Get URL ranges to exclude mentions within URLs
57
+
let url_facets = parse_urls(text);
58
+
59
+
// Same regex pattern as parse_mentions
60
+
let mention_regex = Regex::new(
61
+
r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)",
62
+
)
63
+
.expect("Invalid mention regex");
64
+
65
+
let text_bytes = text.as_bytes();
66
+
67
+
for capture in mention_regex.captures_iter(text_bytes) {
68
+
if let Some(mention_match) = capture.get(1) {
69
+
let start = mention_match.start();
70
+
let end = mention_match.end();
71
+
72
+
// Check if this mention overlaps with any URL
73
+
let overlaps_url = url_facets.iter().any(|facet| {
74
+
(start >= facet.index.byte_start && start < facet.index.byte_end)
75
+
|| (end > facet.index.byte_start && end <= facet.index.byte_end)
76
+
});
77
+
78
+
if !overlaps_url {
79
+
let handle = std::str::from_utf8(&mention_match.as_bytes()[1..])
80
+
.unwrap_or_default()
81
+
.to_string();
82
+
83
+
facets.push(Facet {
84
+
index: ByteSlice {
85
+
byte_start: start,
86
+
byte_end: end,
87
+
},
88
+
features: vec![FacetFeature::Mention(Mention {
89
+
did: format!("did:plc:<unresolved:{}>", handle),
90
+
})],
91
+
});
92
+
}
93
+
}
94
+
}
95
+
96
+
facets
97
+
}
98
+
99
+
#[tokio::main]
100
+
async fn main() {
101
+
let args = Args::parse();
102
+
let text = &args.text;
103
+
let mut facets: Vec<Facet> = Vec::new();
104
+
let limits = FacetLimits::default();
105
+
106
+
// Parse mentions (either resolved or with placeholders)
107
+
if args.resolve_mentions {
108
+
let http_client = reqwest::Client::new();
109
+
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
110
+
let resolver = InnerIdentityResolver {
111
+
http_client,
112
+
dns_resolver: Arc::new(dns_resolver),
113
+
plc_hostname: "plc.directory".to_string(),
114
+
};
115
+
let mention_facets = parse_mentions(text, &resolver, &limits).await;
116
+
facets.extend(mention_facets);
117
+
} else {
118
+
let mention_facets = parse_mention_spans(text);
119
+
facets.extend(mention_facets);
120
+
}
121
+
122
+
// Parse URLs
123
+
let url_facets = parse_urls(text);
124
+
facets.extend(url_facets);
125
+
126
+
// Parse hashtags
127
+
let tag_facets = parse_tags(text);
128
+
facets.extend(tag_facets);
129
+
130
+
// Sort facets by byte_start for consistent output
131
+
facets.sort_by_key(|f| f.index.byte_start);
132
+
133
+
// Output as JSON
134
+
if facets.is_empty() {
135
+
println!("null");
136
+
} else {
137
+
match serde_json::to_string_pretty(&facets) {
138
+
Ok(json) => println!("{}", json),
139
+
Err(e) => {
140
+
eprintln!(
141
+
"error-atproto-extras-parse-facets-1 Error serializing facets: {}",
142
+
e
143
+
);
144
+
std::process::exit(1);
145
+
}
146
+
}
147
+
}
148
+
149
+
// Show debug info if requested
150
+
if args.debug {
151
+
eprintln!();
152
+
eprintln!("--- Debug Info ---");
153
+
eprintln!("Input text: {:?}", text);
154
+
eprintln!("Text length: {} bytes", text.len());
155
+
eprintln!("Facets found: {}", facets.len());
156
+
eprintln!("Mentions resolved: {}", args.resolve_mentions);
157
+
158
+
// Show byte slice verification
159
+
let text_bytes = text.as_bytes();
160
+
for (i, facet) in facets.iter().enumerate() {
161
+
let start = facet.index.byte_start;
162
+
let end = facet.index.byte_end;
163
+
let slice_text =
164
+
std::str::from_utf8(&text_bytes[start..end]).unwrap_or("<invalid utf8>");
165
+
let feature_type = match &facet.features[0] {
166
+
FacetFeature::Mention(_) => "mention",
167
+
FacetFeature::Link(_) => "link",
168
+
FacetFeature::Tag(_) => "tag",
169
+
};
170
+
eprintln!(
171
+
" [{}] {} @ bytes {}..{}: {:?}",
172
+
i, feature_type, start, end, slice_text
173
+
);
174
+
}
175
+
}
176
+
}
+942
crates/atproto-extras/src/facets.rs
+942
crates/atproto-extras/src/facets.rs
···
1
+
//! Rich text facet parsing for AT Protocol.
2
+
//!
3
+
//! This module provides functionality for extracting semantic annotations (facets)
4
+
//! from plain text. Facets include mentions, links (URLs), and hashtags.
5
+
//!
6
+
//! # Overview
7
+
//!
8
+
//! AT Protocol rich text uses "facets" to annotate specific byte ranges within text with
9
+
//! semantic meaning. This module handles:
10
+
//!
11
+
//! - **Parsing**: Extract mentions, URLs, and hashtags from plain text
12
+
//! - **Facet Creation**: Build proper AT Protocol facet structures with resolved DIDs
13
+
//!
14
+
//! # Byte Offset Calculation
15
+
//!
16
+
//! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol.
17
+
//! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done
18
+
//! using `regex::bytes::Regex` which operates on byte slices and returns byte positions,
19
+
//! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars).
20
+
//!
21
+
//! # Example
22
+
//!
23
+
//! ```ignore
24
+
//! use atproto_extras::facets::{parse_urls, parse_tags, FacetLimits};
25
+
//! use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
26
+
//!
27
+
//! let text = "Check out https://example.com #rust";
28
+
//!
29
+
//! // Parse URLs and tags as Facet objects
30
+
//! let url_facets = parse_urls(text);
31
+
//! let tag_facets = parse_tags(text);
32
+
//!
33
+
//! // Access facet data directly
34
+
//! for facet in url_facets {
35
+
//! if let Some(FacetFeature::Link(link)) = facet.features.first() {
36
+
//! println!("URL at bytes {}..{}: {}",
37
+
//! facet.index.byte_start, facet.index.byte_end, link.uri);
38
+
//! }
39
+
//! }
40
+
//! ```
41
+
42
+
use atproto_identity::resolve::IdentityResolver;
43
+
use atproto_record::lexicon::app::bsky::richtext::facet::{
44
+
ByteSlice, Facet, FacetFeature, Link, Mention, Tag,
45
+
};
46
+
use regex::bytes::Regex;
47
+
48
+
/// Configuration for facet parsing limits.
49
+
///
50
+
/// These limits protect against abuse by capping the number of facets
51
+
/// that will be processed. This is important for both performance and
52
+
/// security when handling user-generated content.
53
+
///
54
+
/// # Example
55
+
///
56
+
/// ```
57
+
/// use atproto_extras::FacetLimits;
58
+
///
59
+
/// // Use defaults
60
+
/// let limits = FacetLimits::default();
61
+
///
62
+
/// // Or customize
63
+
/// let custom = FacetLimits {
64
+
/// mentions_max: 10,
65
+
/// tags_max: 10,
66
+
/// links_max: 10,
67
+
/// max: 20,
68
+
/// };
69
+
/// ```
70
+
#[derive(Debug, Clone, Copy)]
71
+
pub struct FacetLimits {
72
+
/// Maximum number of mention facets to process (default: 5)
73
+
pub mentions_max: usize,
74
+
/// Maximum number of tag facets to process (default: 5)
75
+
pub tags_max: usize,
76
+
/// Maximum number of link facets to process (default: 5)
77
+
pub links_max: usize,
78
+
/// Maximum total number of facets to process (default: 10)
79
+
pub max: usize,
80
+
}
81
+
82
+
impl Default for FacetLimits {
83
+
fn default() -> Self {
84
+
Self {
85
+
mentions_max: 5,
86
+
tags_max: 5,
87
+
links_max: 5,
88
+
max: 10,
89
+
}
90
+
}
91
+
}
92
+
93
+
/// Parse mentions from text and return them as Facet objects with resolved DIDs.
94
+
///
95
+
/// This function extracts AT Protocol handle mentions (e.g., `@alice.bsky.social`)
96
+
/// from text, resolves each handle to a DID using the provided identity resolver,
97
+
/// and returns AT Protocol Facet objects with Mention features.
98
+
///
99
+
/// Mentions that cannot be resolved to a valid DID are skipped. Mentions that
100
+
/// appear within URLs are also excluded to avoid false positives.
101
+
///
102
+
/// # Arguments
103
+
///
104
+
/// * `text` - The text to parse for mentions
105
+
/// * `identity_resolver` - Resolver for converting handles to DIDs
106
+
/// * `limits` - Configuration for maximum mentions to process
107
+
///
108
+
/// # Returns
109
+
///
110
+
/// A vector of Facet objects for successfully resolved mentions.
111
+
///
112
+
/// # Example
113
+
///
114
+
/// ```ignore
115
+
/// use atproto_extras::{parse_mentions, FacetLimits};
116
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
117
+
///
118
+
/// let text = "Hello @alice.bsky.social!";
119
+
/// let limits = FacetLimits::default();
120
+
///
121
+
/// // Requires an async context and identity resolver
122
+
/// let facets = parse_mentions(text, &resolver, &limits).await;
123
+
///
124
+
/// for facet in facets {
125
+
/// if let Some(FacetFeature::Mention(mention)) = facet.features.first() {
126
+
/// println!("Mention {} resolved to {}",
127
+
/// &text[facet.index.byte_start..facet.index.byte_end],
128
+
/// mention.did);
129
+
/// }
130
+
/// }
131
+
/// ```
132
+
pub async fn parse_mentions(
133
+
text: &str,
134
+
identity_resolver: &dyn IdentityResolver,
135
+
limits: &FacetLimits,
136
+
) -> Vec<Facet> {
137
+
let mut facets = Vec::new();
138
+
139
+
// First, parse all URLs to exclude mention matches within them
140
+
let url_facets = parse_urls(text);
141
+
142
+
// Regex based on: https://atproto.com/specs/handle#handle-identifier-syntax
143
+
// Pattern: [$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)
144
+
let mention_regex = Regex::new(
145
+
r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)",
146
+
)
147
+
.unwrap();
148
+
149
+
let text_bytes = text.as_bytes();
150
+
let mut mention_count = 0;
151
+
152
+
for capture in mention_regex.captures_iter(text_bytes) {
153
+
if mention_count >= limits.mentions_max {
154
+
break;
155
+
}
156
+
157
+
if let Some(mention_match) = capture.get(1) {
158
+
let start = mention_match.start();
159
+
let end = mention_match.end();
160
+
161
+
// Check if this mention overlaps with any URL
162
+
let overlaps_url = url_facets.iter().any(|facet| {
163
+
// Check if mention is within or overlaps the URL span
164
+
(start >= facet.index.byte_start && start < facet.index.byte_end)
165
+
|| (end > facet.index.byte_start && end <= facet.index.byte_end)
166
+
});
167
+
168
+
// Only process the mention if it doesn't overlap with a URL
169
+
if !overlaps_url {
170
+
let handle = std::str::from_utf8(&mention_match.as_bytes()[1..])
171
+
.unwrap_or_default()
172
+
.to_string();
173
+
174
+
// Try to resolve the handle to a DID
175
+
// First try with at:// prefix, then without
176
+
let at_uri = format!("at://{}", handle);
177
+
let did_result = match identity_resolver.resolve(&at_uri).await {
178
+
Ok(doc) => Ok(doc),
179
+
Err(_) => identity_resolver.resolve(&handle).await,
180
+
};
181
+
182
+
// Only add the mention facet if we successfully resolved the DID
183
+
if let Ok(did_doc) = did_result {
184
+
facets.push(Facet {
185
+
index: ByteSlice {
186
+
byte_start: start,
187
+
byte_end: end,
188
+
},
189
+
features: vec![FacetFeature::Mention(Mention {
190
+
did: did_doc.id.to_string(),
191
+
})],
192
+
});
193
+
mention_count += 1;
194
+
}
195
+
}
196
+
}
197
+
}
198
+
199
+
facets
200
+
}
201
+
202
+
/// Parse URLs from text and return them as Facet objects.
203
+
///
204
+
/// This function extracts HTTP and HTTPS URLs from text with correct
205
+
/// byte position tracking for UTF-8 text, returning AT Protocol Facet objects
206
+
/// with Link features.
207
+
///
208
+
/// # Supported URL Patterns
209
+
///
210
+
/// - HTTP URLs: `http://example.com`
211
+
/// - HTTPS URLs: `https://example.com`
212
+
/// - URLs with paths, query strings, and fragments
213
+
/// - URLs with subdomains: `https://www.example.com`
214
+
///
215
+
/// # Example
216
+
///
217
+
/// ```
218
+
/// use atproto_extras::parse_urls;
219
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
220
+
///
221
+
/// let text = "Visit https://example.com/path?query=1 for more info";
222
+
/// let facets = parse_urls(text);
223
+
///
224
+
/// assert_eq!(facets.len(), 1);
225
+
/// assert_eq!(facets[0].index.byte_start, 6);
226
+
/// assert_eq!(facets[0].index.byte_end, 38);
227
+
/// if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
228
+
/// assert_eq!(link.uri, "https://example.com/path?query=1");
229
+
/// }
230
+
/// ```
231
+
///
232
+
/// # Multi-byte Character Handling
233
+
///
234
+
/// Byte positions are correctly calculated even with emojis and other
235
+
/// multi-byte UTF-8 characters:
236
+
///
237
+
/// ```
238
+
/// use atproto_extras::parse_urls;
239
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
240
+
///
241
+
/// let text = "Check out https://example.com now!";
242
+
/// let facets = parse_urls(text);
243
+
/// let text_bytes = text.as_bytes();
244
+
///
245
+
/// // The byte slice matches the URL
246
+
/// let url_bytes = &text_bytes[facets[0].index.byte_start..facets[0].index.byte_end];
247
+
/// assert_eq!(std::str::from_utf8(url_bytes).unwrap(), "https://example.com");
248
+
/// ```
249
+
pub fn parse_urls(text: &str) -> Vec<Facet> {
250
+
let mut facets = Vec::new();
251
+
252
+
// Partial/naive URL regex based on: https://stackoverflow.com/a/3809435
253
+
// Pattern: [$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)
254
+
// Modified to use + instead of {1,6} to support longer TLDs and multi-level subdomains
255
+
let url_regex = Regex::new(
256
+
r"(?:^|[^\w])(https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)"
257
+
).unwrap();
258
+
259
+
let text_bytes = text.as_bytes();
260
+
for capture in url_regex.captures_iter(text_bytes) {
261
+
if let Some(url_match) = capture.get(1) {
262
+
let url = std::str::from_utf8(url_match.as_bytes())
263
+
.unwrap_or_default()
264
+
.to_string();
265
+
266
+
facets.push(Facet {
267
+
index: ByteSlice {
268
+
byte_start: url_match.start(),
269
+
byte_end: url_match.end(),
270
+
},
271
+
features: vec![FacetFeature::Link(Link { uri: url })],
272
+
});
273
+
}
274
+
}
275
+
276
+
facets
277
+
}
278
+
279
+
/// Parse hashtags from text and return them as Facet objects.
280
+
///
281
+
/// This function extracts hashtags (e.g., `#rust`, `#ATProto`) from text,
282
+
/// returning AT Protocol Facet objects with Tag features.
283
+
/// It supports both standard `#` and full-width `๏ผ` (U+FF03) hash symbols.
284
+
///
285
+
/// # Tag Syntax
286
+
///
287
+
/// - Tags must start with `#` or `๏ผ` (full-width)
288
+
/// - Tag content follows word character rules (`\w`)
289
+
/// - Purely numeric tags (e.g., `#123`) are excluded
290
+
///
291
+
/// # Example
292
+
///
293
+
/// ```
294
+
/// use atproto_extras::parse_tags;
295
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature;
296
+
///
297
+
/// let text = "Learning #rust and #golang today! #100DaysOfCode";
298
+
/// let facets = parse_tags(text);
299
+
///
300
+
/// assert_eq!(facets.len(), 3);
301
+
/// if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
302
+
/// assert_eq!(tag.tag, "rust");
303
+
/// }
304
+
/// if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() {
305
+
/// assert_eq!(tag.tag, "golang");
306
+
/// }
307
+
/// if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() {
308
+
/// assert_eq!(tag.tag, "100DaysOfCode");
309
+
/// }
310
+
/// ```
311
+
///
312
+
/// # Numeric Tags
313
+
///
314
+
/// Purely numeric tags are excluded:
315
+
///
316
+
/// ```
317
+
/// use atproto_extras::parse_tags;
318
+
///
319
+
/// let text = "Item #42 is special";
320
+
/// let facets = parse_tags(text);
321
+
///
322
+
/// // #42 is not extracted because it's purely numeric
323
+
/// assert_eq!(facets.len(), 0);
324
+
/// ```
325
+
pub fn parse_tags(text: &str) -> Vec<Facet> {
326
+
let mut facets = Vec::new();
327
+
328
+
// Regex based on: https://github.com/bluesky-social/atproto/blob/d91988fe79030b61b556dd6f16a46f0c3b9d0b44/packages/api/src/rich-text/util.ts
329
+
// Simplified for Rust - matches hashtags at word boundaries
330
+
// Pattern matches: start of string or non-word char, then # or ๏ผ, then tag content
331
+
let tag_regex = Regex::new(r"(?:^|[^\w])([#\xEF\xBC\x83])([\w]+(?:[\w]*)*)").unwrap();
332
+
333
+
let text_bytes = text.as_bytes();
334
+
335
+
// Work with bytes for proper position tracking
336
+
for capture in tag_regex.captures_iter(text_bytes) {
337
+
if let (Some(full_match), Some(hash_match), Some(tag_match)) =
338
+
(capture.get(0), capture.get(1), capture.get(2))
339
+
{
340
+
// Calculate the absolute byte position of the hash symbol
341
+
// The full match includes the preceding character (if any)
342
+
// so we need to adjust for that
343
+
let match_start = full_match.start();
344
+
let hash_offset = hash_match.start() - full_match.start();
345
+
let start = match_start + hash_offset;
346
+
let end = match_start + hash_offset + hash_match.len() + tag_match.len();
347
+
348
+
// Extract just the tag text (without the hash symbol)
349
+
let tag = std::str::from_utf8(tag_match.as_bytes()).unwrap_or_default();
350
+
351
+
// Only include tags that are not purely numeric
352
+
if !tag.chars().all(|c| c.is_ascii_digit()) {
353
+
facets.push(Facet {
354
+
index: ByteSlice {
355
+
byte_start: start,
356
+
byte_end: end,
357
+
},
358
+
features: vec![FacetFeature::Tag(Tag {
359
+
tag: tag.to_string(),
360
+
})],
361
+
});
362
+
}
363
+
}
364
+
}
365
+
366
+
facets
367
+
}
368
+
369
+
/// Parse facets from text and return a vector of Facet objects.
370
+
///
371
+
/// This function extracts mentions, URLs, and hashtags from the provided text
372
+
/// and creates AT Protocol facets with proper byte indices.
373
+
///
374
+
/// Mentions are resolved to actual DIDs using the provided identity resolver.
375
+
/// If a handle cannot be resolved to a DID, the mention facet is skipped.
376
+
///
377
+
/// # Arguments
378
+
///
379
+
/// * `text` - The text to extract facets from
380
+
/// * `identity_resolver` - Resolver for converting handles to DIDs
381
+
/// * `limits` - Configuration for maximum facets per type and total
382
+
///
383
+
/// # Returns
384
+
///
385
+
/// Optional vector of facets. Returns `None` if no facets were found.
386
+
///
387
+
/// # Example
388
+
///
389
+
/// ```ignore
390
+
/// use atproto_extras::{parse_facets_from_text, FacetLimits};
391
+
///
392
+
/// let text = "Hello @alice.bsky.social! Check #rust at https://rust-lang.org";
393
+
/// let limits = FacetLimits::default();
394
+
///
395
+
/// // Requires an async context and identity resolver
396
+
/// let facets = parse_facets_from_text(text, &resolver, &limits).await;
397
+
///
398
+
/// if let Some(facets) = facets {
399
+
/// for facet in &facets {
400
+
/// println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end);
401
+
/// }
402
+
/// }
403
+
/// ```
404
+
///
405
+
/// # Mention Resolution
406
+
///
407
+
/// Mentions are only included if the handle resolves to a valid DID:
408
+
///
409
+
/// ```ignore
410
+
/// let text = "@valid.handle.com and @invalid.handle.xyz";
411
+
/// let facets = parse_facets_from_text(text, &resolver, &limits).await;
412
+
///
413
+
/// // Only @valid.handle.com appears as a facet if @invalid.handle.xyz
414
+
/// // cannot be resolved to a DID
415
+
/// ```
416
+
pub async fn parse_facets_from_text(
417
+
text: &str,
418
+
identity_resolver: &dyn IdentityResolver,
419
+
limits: &FacetLimits,
420
+
) -> Option<Vec<Facet>> {
421
+
let mut facets = Vec::new();
422
+
423
+
// Parse mentions (already limited by mentions_max in parse_mentions)
424
+
let mention_facets = parse_mentions(text, identity_resolver, limits).await;
425
+
facets.extend(mention_facets);
426
+
427
+
// Parse URLs (limited by links_max)
428
+
let url_facets = parse_urls(text);
429
+
for (idx, facet) in url_facets.into_iter().enumerate() {
430
+
if idx >= limits.links_max {
431
+
break;
432
+
}
433
+
facets.push(facet);
434
+
}
435
+
436
+
// Parse hashtags (limited by tags_max)
437
+
let tag_facets = parse_tags(text);
438
+
for (idx, facet) in tag_facets.into_iter().enumerate() {
439
+
if idx >= limits.tags_max {
440
+
break;
441
+
}
442
+
facets.push(facet);
443
+
}
444
+
445
+
// Apply global facet limit (truncate if exceeds max)
446
+
if facets.len() > limits.max {
447
+
facets.truncate(limits.max);
448
+
}
449
+
450
+
// Only return facets if we found any
451
+
if !facets.is_empty() {
452
+
Some(facets)
453
+
} else {
454
+
None
455
+
}
456
+
}
457
+
458
+
#[cfg(test)]
459
+
mod tests {
460
+
use async_trait::async_trait;
461
+
use atproto_identity::model::Document;
462
+
use std::collections::HashMap;
463
+
464
+
use super::*;
465
+
466
+
/// Mock identity resolver for testing
467
+
struct MockIdentityResolver {
468
+
handles_to_dids: HashMap<String, String>,
469
+
}
470
+
471
+
impl MockIdentityResolver {
472
+
fn new() -> Self {
473
+
let mut handles_to_dids = HashMap::new();
474
+
handles_to_dids.insert(
475
+
"alice.bsky.social".to_string(),
476
+
"did:plc:alice123".to_string(),
477
+
);
478
+
handles_to_dids.insert(
479
+
"at://alice.bsky.social".to_string(),
480
+
"did:plc:alice123".to_string(),
481
+
);
482
+
Self { handles_to_dids }
483
+
}
484
+
485
+
fn add_identity(&mut self, handle: &str, did: &str) {
486
+
self.handles_to_dids
487
+
.insert(handle.to_string(), did.to_string());
488
+
self.handles_to_dids
489
+
.insert(format!("at://{}", handle), did.to_string());
490
+
}
491
+
}
492
+
493
+
#[async_trait]
494
+
impl IdentityResolver for MockIdentityResolver {
495
+
async fn resolve(&self, handle: &str) -> anyhow::Result<Document> {
496
+
let handle_key = handle.to_string();
497
+
498
+
if let Some(did) = self.handles_to_dids.get(&handle_key) {
499
+
Ok(Document {
500
+
context: vec![],
501
+
id: did.clone(),
502
+
also_known_as: vec![format!("at://{}", handle_key.trim_start_matches("at://"))],
503
+
verification_method: vec![],
504
+
service: vec![],
505
+
extra: HashMap::new(),
506
+
})
507
+
} else {
508
+
Err(anyhow::anyhow!("Handle not found"))
509
+
}
510
+
}
511
+
}
512
+
513
+
#[tokio::test]
514
+
async fn test_parse_facets_from_text_comprehensive() {
515
+
let mut resolver = MockIdentityResolver::new();
516
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
517
+
518
+
let limits = FacetLimits::default();
519
+
let text = "Join @alice.bsky.social and @bob.test.com at https://example.com #rust #golang";
520
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
521
+
522
+
assert!(facets.is_some());
523
+
let facets = facets.unwrap();
524
+
assert_eq!(facets.len(), 5); // 2 mentions, 1 URL, 2 hashtags
525
+
526
+
// Check first mention
527
+
assert_eq!(facets[0].index.byte_start, 5);
528
+
assert_eq!(facets[0].index.byte_end, 23);
529
+
if let FacetFeature::Mention(ref mention) = facets[0].features[0] {
530
+
assert_eq!(mention.did, "did:plc:alice123");
531
+
} else {
532
+
panic!("Expected Mention feature");
533
+
}
534
+
535
+
// Check second mention
536
+
assert_eq!(facets[1].index.byte_start, 28);
537
+
assert_eq!(facets[1].index.byte_end, 41);
538
+
if let FacetFeature::Mention(mention) = &facets[1].features[0] {
539
+
assert_eq!(mention.did, "did:plc:bob456");
540
+
} else {
541
+
panic!("Expected Mention feature");
542
+
}
543
+
544
+
// Check URL
545
+
assert_eq!(facets[2].index.byte_start, 45);
546
+
assert_eq!(facets[2].index.byte_end, 64);
547
+
if let FacetFeature::Link(link) = &facets[2].features[0] {
548
+
assert_eq!(link.uri, "https://example.com");
549
+
} else {
550
+
panic!("Expected Link feature");
551
+
}
552
+
553
+
// Check first hashtag
554
+
assert_eq!(facets[3].index.byte_start, 65);
555
+
assert_eq!(facets[3].index.byte_end, 70);
556
+
if let FacetFeature::Tag(tag) = &facets[3].features[0] {
557
+
assert_eq!(tag.tag, "rust");
558
+
} else {
559
+
panic!("Expected Tag feature");
560
+
}
561
+
562
+
// Check second hashtag
563
+
assert_eq!(facets[4].index.byte_start, 71);
564
+
assert_eq!(facets[4].index.byte_end, 78);
565
+
if let FacetFeature::Tag(tag) = &facets[4].features[0] {
566
+
assert_eq!(tag.tag, "golang");
567
+
} else {
568
+
panic!("Expected Tag feature");
569
+
}
570
+
}
571
+
572
+
#[tokio::test]
573
+
async fn test_parse_facets_from_text_with_unresolvable_mention() {
574
+
let resolver = MockIdentityResolver::new();
575
+
let limits = FacetLimits::default();
576
+
577
+
// Only alice.bsky.social is in the resolver, not unknown.handle.com
578
+
let text = "Contact @unknown.handle.com for details #rust";
579
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
580
+
581
+
assert!(facets.is_some());
582
+
let facets = facets.unwrap();
583
+
// Should only have 1 facet (the hashtag) since the mention couldn't be resolved
584
+
assert_eq!(facets.len(), 1);
585
+
586
+
// Check that it's the hashtag facet
587
+
if let FacetFeature::Tag(tag) = &facets[0].features[0] {
588
+
assert_eq!(tag.tag, "rust");
589
+
} else {
590
+
panic!("Expected Tag feature");
591
+
}
592
+
}
593
+
594
+
#[tokio::test]
595
+
async fn test_parse_facets_from_text_empty() {
596
+
let resolver = MockIdentityResolver::new();
597
+
let limits = FacetLimits::default();
598
+
let text = "No mentions, URLs, or hashtags here";
599
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
600
+
assert!(facets.is_none());
601
+
}
602
+
603
+
#[tokio::test]
604
+
async fn test_parse_facets_from_text_url_with_at_mention() {
605
+
let resolver = MockIdentityResolver::new();
606
+
let limits = FacetLimits::default();
607
+
608
+
// URLs with @ should not create mention facets
609
+
let text = "Tangled https://tangled.org/@smokesignal.events";
610
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
611
+
612
+
assert!(facets.is_some());
613
+
let facets = facets.unwrap();
614
+
615
+
// Should have exactly 1 facet (the URL), not 2 (URL + mention)
616
+
assert_eq!(
617
+
facets.len(),
618
+
1,
619
+
"Expected 1 facet (URL only), got {}",
620
+
facets.len()
621
+
);
622
+
623
+
// Verify it's a link facet, not a mention
624
+
if let FacetFeature::Link(link) = &facets[0].features[0] {
625
+
assert_eq!(link.uri, "https://tangled.org/@smokesignal.events");
626
+
} else {
627
+
panic!("Expected Link feature, got Mention or Tag instead");
628
+
}
629
+
}
630
+
631
+
#[tokio::test]
632
+
async fn test_parse_facets_with_mention_limit() {
633
+
let mut resolver = MockIdentityResolver::new();
634
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
635
+
resolver.add_identity("charlie.test.com", "did:plc:charlie789");
636
+
637
+
// Limit to 2 mentions
638
+
let limits = FacetLimits {
639
+
mentions_max: 2,
640
+
tags_max: 5,
641
+
links_max: 5,
642
+
max: 10,
643
+
};
644
+
645
+
let text = "Join @alice.bsky.social @bob.test.com @charlie.test.com";
646
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
647
+
648
+
assert!(facets.is_some());
649
+
let facets = facets.unwrap();
650
+
// Should only have 2 mentions (alice and bob), charlie should be skipped
651
+
assert_eq!(facets.len(), 2);
652
+
653
+
// Verify they're both mentions
654
+
for facet in &facets {
655
+
assert!(matches!(facet.features[0], FacetFeature::Mention(_)));
656
+
}
657
+
}
658
+
659
+
#[tokio::test]
660
+
async fn test_parse_facets_with_global_limit() {
661
+
let mut resolver = MockIdentityResolver::new();
662
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
663
+
664
+
// Very restrictive global limit
665
+
let limits = FacetLimits {
666
+
mentions_max: 5,
667
+
tags_max: 5,
668
+
links_max: 5,
669
+
max: 3, // Only allow 3 total facets
670
+
};
671
+
672
+
let text =
673
+
"Join @alice.bsky.social @bob.test.com at https://example.com #rust #golang #python";
674
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
675
+
676
+
assert!(facets.is_some());
677
+
let facets = facets.unwrap();
678
+
// Should be truncated to 3 facets total
679
+
assert_eq!(facets.len(), 3);
680
+
}
681
+
682
+
#[test]
683
+
fn test_parse_urls_multiple_links() {
684
+
let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164";
685
+
686
+
let facets = parse_urls(text);
687
+
688
+
// Should find both URLs
689
+
assert_eq!(
690
+
facets.len(),
691
+
2,
692
+
"Expected 2 URLs but found {}",
693
+
facets.len()
694
+
);
695
+
696
+
// Check first URL
697
+
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
698
+
assert_eq!(link.uri, "https://www.ietf.org/meeting/124/");
699
+
} else {
700
+
panic!("Expected Link feature");
701
+
}
702
+
703
+
// Check second URL
704
+
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
705
+
assert_eq!(
706
+
link.uri,
707
+
"https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"
708
+
);
709
+
} else {
710
+
panic!("Expected Link feature");
711
+
}
712
+
}
713
+
714
+
#[test]
715
+
fn test_parse_urls_with_html_entity() {
716
+
// Test with the HTML entity & in the text
717
+
let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164";
718
+
719
+
let facets = parse_urls(text);
720
+
721
+
// Should find both URLs
722
+
assert_eq!(
723
+
facets.len(),
724
+
2,
725
+
"Expected 2 URLs but found {}",
726
+
facets.len()
727
+
);
728
+
729
+
// Check first URL
730
+
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
731
+
assert_eq!(link.uri, "https://www.ietf.org/meeting/124/");
732
+
} else {
733
+
panic!("Expected Link feature");
734
+
}
735
+
736
+
// Check second URL
737
+
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
738
+
assert_eq!(
739
+
link.uri,
740
+
"https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"
741
+
);
742
+
} else {
743
+
panic!("Expected Link feature");
744
+
}
745
+
}
746
+
747
+
#[test]
748
+
fn test_byte_offset_with_html_entities() {
749
+
// This test demonstrates that HTML entity escaping shifts byte positions.
750
+
// The byte positions shift:
751
+
// In original: '&' is at byte 8 (1 byte)
752
+
// In escaped: '&' starts at byte 8 (5 bytes)
753
+
// This causes facet byte offsets to be misaligned if text is escaped before rendering.
754
+
755
+
// If we have a URL after the ampersand in the original:
756
+
let original_with_url = "Nov 3rd & Tuesday https://example.com";
757
+
let escaped_with_url = "Nov 3rd & Tuesday https://example.com";
758
+
759
+
// Parse URLs from both versions
760
+
let original_facets = parse_urls(original_with_url);
761
+
let escaped_facets = parse_urls(escaped_with_url);
762
+
763
+
// Both should find the URL, but at different byte positions
764
+
assert_eq!(original_facets.len(), 1);
765
+
assert_eq!(escaped_facets.len(), 1);
766
+
767
+
// The byte positions will be different
768
+
assert_eq!(original_facets[0].index.byte_start, 18); // After "Nov 3rd & Tuesday "
769
+
assert_eq!(escaped_facets[0].index.byte_start, 22); // After "Nov 3rd & Tuesday " (4 extra bytes for &)
770
+
}
771
+
772
+
#[test]
773
+
fn test_parse_urls_from_atproto_record_text() {
774
+
// Test parsing URLs from real AT Protocol record description text.
775
+
// This demonstrates the correct byte positions that should be used for facets.
776
+
let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\nโจthe danielโจ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/";
777
+
778
+
let facets = parse_urls(text);
779
+
780
+
assert_eq!(facets.len(), 2, "Should find 2 URLs");
781
+
782
+
// First URL: https://stream.place/psingletary.com
783
+
assert_eq!(facets[0].index.byte_start, 221);
784
+
assert_eq!(facets[0].index.byte_end, 257);
785
+
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
786
+
assert_eq!(link.uri, "https://stream.place/psingletary.com");
787
+
}
788
+
789
+
// Second URL: https://atprotocalls.leaflet.pub/
790
+
assert_eq!(facets[1].index.byte_start, 290);
791
+
assert_eq!(facets[1].index.byte_end, 323);
792
+
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
793
+
assert_eq!(link.uri, "https://atprotocalls.leaflet.pub/");
794
+
}
795
+
796
+
// Verify the byte slices match the expected text
797
+
let text_bytes = text.as_bytes();
798
+
assert_eq!(
799
+
std::str::from_utf8(&text_bytes[221..257]).unwrap(),
800
+
"https://stream.place/psingletary.com"
801
+
);
802
+
assert_eq!(
803
+
std::str::from_utf8(&text_bytes[290..323]).unwrap(),
804
+
"https://atprotocalls.leaflet.pub/"
805
+
);
806
+
}
807
+
808
+
#[tokio::test]
809
+
async fn test_parse_mentions_basic() {
810
+
let resolver = MockIdentityResolver::new();
811
+
let limits = FacetLimits::default();
812
+
let text = "Hello @alice.bsky.social!";
813
+
let facets = parse_mentions(text, &resolver, &limits).await;
814
+
815
+
assert_eq!(facets.len(), 1);
816
+
assert_eq!(facets[0].index.byte_start, 6);
817
+
assert_eq!(facets[0].index.byte_end, 24);
818
+
if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() {
819
+
assert_eq!(mention.did, "did:plc:alice123");
820
+
} else {
821
+
panic!("Expected Mention feature");
822
+
}
823
+
}
824
+
825
+
#[tokio::test]
826
+
async fn test_parse_mentions_multiple() {
827
+
let mut resolver = MockIdentityResolver::new();
828
+
resolver.add_identity("bob.example.com", "did:plc:bob456");
829
+
let limits = FacetLimits::default();
830
+
let text = "CC @alice.bsky.social and @bob.example.com";
831
+
let facets = parse_mentions(text, &resolver, &limits).await;
832
+
833
+
assert_eq!(facets.len(), 2);
834
+
if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() {
835
+
assert_eq!(mention.did, "did:plc:alice123");
836
+
}
837
+
if let Some(FacetFeature::Mention(mention)) = facets[1].features.first() {
838
+
assert_eq!(mention.did, "did:plc:bob456");
839
+
}
840
+
}
841
+
842
+
#[tokio::test]
843
+
async fn test_parse_mentions_unresolvable() {
844
+
let resolver = MockIdentityResolver::new();
845
+
let limits = FacetLimits::default();
846
+
// unknown.handle.com is not in the resolver
847
+
let text = "Hello @unknown.handle.com!";
848
+
let facets = parse_mentions(text, &resolver, &limits).await;
849
+
850
+
// Should be empty since the handle can't be resolved
851
+
assert_eq!(facets.len(), 0);
852
+
}
853
+
854
+
#[tokio::test]
855
+
async fn test_parse_mentions_in_url_excluded() {
856
+
let resolver = MockIdentityResolver::new();
857
+
let limits = FacetLimits::default();
858
+
// The @smokesignal.events is inside a URL and should not be parsed as a mention
859
+
let text = "Check https://tangled.org/@smokesignal.events";
860
+
let facets = parse_mentions(text, &resolver, &limits).await;
861
+
862
+
// Should be empty since the mention is inside a URL
863
+
assert_eq!(facets.len(), 0);
864
+
}
865
+
866
+
#[test]
867
+
fn test_parse_tags_basic() {
868
+
let text = "Learning #rust today!";
869
+
let facets = parse_tags(text);
870
+
871
+
assert_eq!(facets.len(), 1);
872
+
assert_eq!(facets[0].index.byte_start, 9);
873
+
assert_eq!(facets[0].index.byte_end, 14);
874
+
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
875
+
assert_eq!(tag.tag, "rust");
876
+
} else {
877
+
panic!("Expected Tag feature");
878
+
}
879
+
}
880
+
881
+
#[test]
882
+
fn test_parse_tags_multiple() {
883
+
let text = "#rust #golang #python are great!";
884
+
let facets = parse_tags(text);
885
+
886
+
assert_eq!(facets.len(), 3);
887
+
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
888
+
assert_eq!(tag.tag, "rust");
889
+
}
890
+
if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() {
891
+
assert_eq!(tag.tag, "golang");
892
+
}
893
+
if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() {
894
+
assert_eq!(tag.tag, "python");
895
+
}
896
+
}
897
+
898
+
#[test]
899
+
fn test_parse_tags_excludes_numeric() {
900
+
let text = "Item #42 is special #test123";
901
+
let facets = parse_tags(text);
902
+
903
+
// #42 should be excluded (purely numeric), #test123 should be included
904
+
assert_eq!(facets.len(), 1);
905
+
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
906
+
assert_eq!(tag.tag, "test123");
907
+
}
908
+
}
909
+
910
+
#[test]
911
+
fn test_parse_urls_basic() {
912
+
let text = "Visit https://example.com today!";
913
+
let facets = parse_urls(text);
914
+
915
+
assert_eq!(facets.len(), 1);
916
+
assert_eq!(facets[0].index.byte_start, 6);
917
+
assert_eq!(facets[0].index.byte_end, 25);
918
+
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
919
+
assert_eq!(link.uri, "https://example.com");
920
+
}
921
+
}
922
+
923
+
#[test]
924
+
fn test_parse_urls_with_path() {
925
+
let text = "Check https://example.com/path/to/page?query=1#section";
926
+
let facets = parse_urls(text);
927
+
928
+
assert_eq!(facets.len(), 1);
929
+
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
930
+
assert_eq!(link.uri, "https://example.com/path/to/page?query=1#section");
931
+
}
932
+
}
933
+
934
+
#[test]
935
+
fn test_facet_limits_default() {
936
+
let limits = FacetLimits::default();
937
+
assert_eq!(limits.mentions_max, 5);
938
+
assert_eq!(limits.tags_max, 5);
939
+
assert_eq!(limits.links_max, 5);
940
+
assert_eq!(limits.max, 10);
941
+
}
942
+
}
+50
crates/atproto-extras/src/lib.rs
+50
crates/atproto-extras/src/lib.rs
···
1
+
//! Extra utilities for AT Protocol applications.
2
+
//!
3
+
//! This crate provides additional utilities that complement the core AT Protocol
4
+
//! identity and record crates. Currently, it focuses on rich text facet parsing.
5
+
//!
6
+
//! ## Features
7
+
//!
8
+
//! - **Facet Parsing**: Extract mentions, URLs, and hashtags from plain text
9
+
//! with correct UTF-8 byte offset calculation
10
+
//! - **Identity Integration**: Resolve mention handles to DIDs during parsing
11
+
//!
12
+
//! ## Example
13
+
//!
14
+
//! ```ignore
15
+
//! use atproto_extras::{parse_facets_from_text, FacetLimits};
16
+
//!
17
+
//! // Parse facets from text (requires an IdentityResolver)
18
+
//! let text = "Hello @alice.bsky.social! Check out https://example.com #rust";
19
+
//! let limits = FacetLimits::default();
20
+
//! let facets = parse_facets_from_text(text, &resolver, &limits).await;
21
+
//! ```
22
+
//!
23
+
//! ## Byte Offset Calculation
24
+
//!
25
+
//! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol.
26
+
//! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done
27
+
//! using `regex::bytes::Regex` which operates on byte slices and returns byte positions,
28
+
//! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars).
29
+
30
+
#![forbid(unsafe_code)]
31
+
#![warn(missing_docs)]
32
+
33
+
/// Rich text facet parsing for AT Protocol.
34
+
///
35
+
/// This module provides functionality for extracting semantic annotations (facets)
36
+
/// from plain text. Facets include:
37
+
///
38
+
/// - **Mentions**: User handles prefixed with `@` (e.g., `@alice.bsky.social`)
39
+
/// - **Links**: HTTP/HTTPS URLs
40
+
/// - **Tags**: Hashtags prefixed with `#` or `๏ผ` (e.g., `#rust`)
41
+
///
42
+
/// ## Byte Offsets
43
+
///
44
+
/// All facet indices use UTF-8 byte offsets, not character indices. This is
45
+
/// critical for correct handling of multi-byte characters like emojis or
46
+
/// non-ASCII text.
47
+
pub mod facets;
48
+
49
+
/// Re-export commonly used types for convenience.
50
+
pub use facets::{FacetLimits, parse_facets_from_text, parse_mentions, parse_tags, parse_urls};
+19
-1
crates/atproto-identity/src/model.rs
+19
-1
crates/atproto-identity/src/model.rs
···
70
70
/// The DID identifier (e.g., "did:plc:abc123").
71
71
pub id: String,
72
72
/// Alternative identifiers like handles and domains.
73
+
#[serde(default)]
73
74
pub also_known_as: Vec<String>,
74
75
/// Available services for this identity.
76
+
#[serde(default)]
75
77
pub service: Vec<Service>,
76
78
77
79
/// Cryptographic verification methods.
78
-
#[serde(alias = "verificationMethod")]
80
+
#[serde(alias = "verificationMethod", default)]
79
81
pub verification_method: Vec<VerificationMethod>,
80
82
81
83
/// Additional document properties not explicitly defined.
···
402
404
let document = document.unwrap();
403
405
assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
404
406
}
407
+
}
408
+
409
+
#[test]
410
+
fn test_deserialize_service_did_document() {
411
+
// DID document from api.bsky.app - a service DID without alsoKnownAs
412
+
let document = serde_json::from_str::<Document>(
413
+
r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##,
414
+
);
415
+
assert!(document.is_ok(), "Failed to parse: {:?}", document.err());
416
+
417
+
let document = document.unwrap();
418
+
assert_eq!(document.id, "did:web:api.bsky.app");
419
+
assert!(document.also_known_as.is_empty());
420
+
assert_eq!(document.service.len(), 2);
421
+
assert_eq!(document.service[0].id, "#bsky_notif");
422
+
assert_eq!(document.service[1].id, "#bsky_appview");
405
423
}
406
424
}
+75
-24
crates/atproto-jetstream/src/consumer.rs
+75
-24
crates/atproto-jetstream/src/consumer.rs
···
2
2
//!
3
3
//! WebSocket event consumption with background processing and
4
4
//! customizable event handler dispatch.
5
+
//!
6
+
//! ## Memory Efficiency
7
+
//!
8
+
//! This module is optimized for high-throughput event processing with minimal allocations:
9
+
//!
10
+
//! - **Arc-based event sharing**: Events are wrapped in `Arc` and shared across all handlers,
11
+
//! avoiding expensive clones of event data structures.
12
+
//! - **Zero-copy handler IDs**: Handler identifiers use string slices to avoid allocations
13
+
//! during registration and dispatch.
14
+
//! - **Optimized query building**: WebSocket query strings are built with pre-allocated
15
+
//! capacity to minimize reallocations.
16
+
//!
17
+
//! ## Usage
18
+
//!
19
+
//! Implement the `EventHandler` trait to process events:
20
+
//!
21
+
//! ```rust
22
+
//! use atproto_jetstream::{EventHandler, JetstreamEvent};
23
+
//! use async_trait::async_trait;
24
+
//! use std::sync::Arc;
25
+
//! use anyhow::Result;
26
+
//!
27
+
//! struct MyHandler;
28
+
//!
29
+
//! #[async_trait]
30
+
//! impl EventHandler for MyHandler {
31
+
//! async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()> {
32
+
//! // Process event without cloning
33
+
//! Ok(())
34
+
//! }
35
+
//!
36
+
//! fn handler_id(&self) -> &str {
37
+
//! "my-handler"
38
+
//! }
39
+
//! }
40
+
//! ```
5
41
6
42
use crate::errors::ConsumerError;
7
43
use anyhow::Result;
···
133
169
#[async_trait]
134
170
pub trait EventHandler: Send + Sync {
135
171
/// Handle a received event
136
-
async fn handle_event(&self, event: JetstreamEvent) -> Result<()>;
172
+
///
173
+
/// Events are wrapped in Arc to enable efficient sharing across multiple handlers
174
+
/// without cloning the entire event data structure.
175
+
async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()>;
137
176
138
177
/// Get the handler's identifier
139
-
fn handler_id(&self) -> String;
178
+
///
179
+
/// Returns a string slice to avoid unnecessary allocations.
180
+
fn handler_id(&self) -> &str;
140
181
}
141
182
142
183
#[cfg_attr(debug_assertions, derive(Debug))]
···
167
208
pub struct Consumer {
168
209
config: ConsumerTaskConfig,
169
210
handlers: Arc<RwLock<HashMap<String, Arc<dyn EventHandler>>>>,
170
-
event_sender: Arc<RwLock<Option<broadcast::Sender<JetstreamEvent>>>>,
211
+
event_sender: Arc<RwLock<Option<broadcast::Sender<Arc<JetstreamEvent>>>>>,
171
212
}
172
213
173
214
impl Consumer {
···
185
226
let handler_id = handler.handler_id();
186
227
let mut handlers = self.handlers.write().await;
187
228
188
-
if handlers.contains_key(&handler_id) {
229
+
if handlers.contains_key(handler_id) {
189
230
return Err(ConsumerError::HandlerRegistrationFailed(format!(
190
231
"Handler with ID '{}' already registered",
191
232
handler_id
···
193
234
.into());
194
235
}
195
236
196
-
handlers.insert(handler_id.clone(), handler);
237
+
handlers.insert(handler_id.to_string(), handler);
197
238
Ok(())
198
239
}
199
240
···
205
246
}
206
247
207
248
/// Get a broadcast receiver for events
208
-
pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<JetstreamEvent>> {
249
+
///
250
+
/// Events are wrapped in Arc to enable efficient sharing without cloning.
251
+
pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<Arc<JetstreamEvent>>> {
209
252
let sender_guard = self.event_sender.read().await;
210
253
match sender_guard.as_ref() {
211
254
Some(sender) => Ok(sender.subscribe()),
···
249
292
tracing::info!("Starting Jetstream consumer");
250
293
251
294
// Build WebSocket URL with query parameters
252
-
let mut query_params = vec![];
295
+
// Pre-allocate capacity to avoid reallocations during string building
296
+
let capacity = 50 // Base parameters
297
+
+ self.config.collections.len() * 30 // Estimate per collection
298
+
+ self.config.dids.len() * 60; // Estimate per DID
299
+
let mut query_string = String::with_capacity(capacity);
253
300
254
301
// Add compression parameter
255
-
query_params.push(format!("compress={}", self.config.compression));
302
+
query_string.push_str("compress=");
303
+
query_string.push_str(if self.config.compression { "true" } else { "false" });
256
304
257
305
// Add requireHello parameter
258
-
query_params.push(format!("requireHello={}", self.config.require_hello));
306
+
query_string.push_str("&requireHello=");
307
+
query_string.push_str(if self.config.require_hello { "true" } else { "false" });
259
308
260
309
// Add wantedCollections if specified (each collection as a separate query parameter)
261
310
if !self.config.collections.is_empty() && !self.config.require_hello {
262
311
for collection in &self.config.collections {
263
-
query_params.push(format!(
264
-
"wantedCollections={}",
265
-
urlencoding::encode(collection)
266
-
));
312
+
query_string.push_str("&wantedCollections=");
313
+
query_string.push_str(&urlencoding::encode(collection));
267
314
}
268
315
}
269
316
270
317
// Add wantedDids if specified (each DID as a separate query parameter)
271
318
if !self.config.dids.is_empty() && !self.config.require_hello {
272
319
for did in &self.config.dids {
273
-
query_params.push(format!("wantedDids={}", urlencoding::encode(did)));
320
+
query_string.push_str("&wantedDids=");
321
+
query_string.push_str(&urlencoding::encode(did));
274
322
}
275
323
}
276
324
277
325
// Add maxMessageSizeBytes if specified
278
326
if let Some(max_size) = self.config.max_message_size_bytes {
279
-
query_params.push(format!("maxMessageSizeBytes={}", max_size));
327
+
use std::fmt::Write;
328
+
write!(&mut query_string, "&maxMessageSizeBytes={}", max_size).unwrap();
280
329
}
281
330
282
331
// Add cursor if specified
283
332
if let Some(cursor) = self.config.cursor {
284
-
query_params.push(format!("cursor={}", cursor));
333
+
use std::fmt::Write;
334
+
write!(&mut query_string, "&cursor={}", cursor).unwrap();
285
335
}
286
-
287
-
let query_string = query_params.join("&");
288
336
let ws_url = Uri::from_str(&format!(
289
337
"wss://{}/subscribe?{}",
290
338
self.config.jetstream_hostname, query_string
···
335
383
break;
336
384
},
337
385
() = &mut sleeper => {
338
-
// consumer_control_insert(&self.pool, &self.config.jetstream_hostname, time_usec).await?;
339
-
340
386
sleeper.as_mut().reset(Instant::now() + interval);
341
387
},
342
388
item = client.next() => {
···
404
450
}
405
451
406
452
/// Dispatch event to all registered handlers
453
+
///
454
+
/// Wraps the event in Arc once and shares it across all handlers,
455
+
/// avoiding expensive clones of the event data structure.
407
456
async fn dispatch_to_handlers(&self, event: JetstreamEvent) -> Result<()> {
408
457
let handlers = self.handlers.read().await;
458
+
let event = Arc::new(event);
409
459
410
460
for (handler_id, handler) in handlers.iter() {
411
461
let handler_span = tracing::debug_span!("handler_dispatch", handler_id = %handler_id);
462
+
let event_ref = Arc::clone(&event);
412
463
async {
413
-
if let Err(err) = handler.handle_event(event.clone()).await {
464
+
if let Err(err) = handler.handle_event(event_ref).await {
414
465
tracing::error!(
415
466
error = ?err,
416
467
handler_id = %handler_id,
···
440
491
441
492
#[async_trait]
442
493
impl EventHandler for LoggingHandler {
443
-
async fn handle_event(&self, _event: JetstreamEvent) -> Result<()> {
494
+
async fn handle_event(&self, _event: Arc<JetstreamEvent>) -> Result<()> {
444
495
Ok(())
445
496
}
446
497
447
-
fn handler_id(&self) -> String {
448
-
self.id.clone()
498
+
fn handler_id(&self) -> &str {
499
+
&self.id
449
500
}
450
501
}
451
502
+374
-5
crates/atproto-oauth/src/scopes.rs
+374
-5
crates/atproto-oauth/src/scopes.rs
···
38
38
Atproto,
39
39
/// Transition scope for migration operations
40
40
Transition(TransitionScope),
41
+
/// Include scope for referencing permission sets by NSID
42
+
Include(IncludeScope),
41
43
/// OpenID Connect scope - required for OpenID Connect authentication
42
44
OpenId,
43
45
/// Profile scope - access to user profile information
···
91
93
Generic,
92
94
/// Email transition operations
93
95
Email,
96
+
}
97
+
98
+
/// Include scope for referencing permission sets by NSID
99
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100
+
pub struct IncludeScope {
101
+
/// The permission set NSID (e.g., "app.example.authFull")
102
+
pub nsid: String,
103
+
/// Optional audience DID for inherited RPC permissions
104
+
pub aud: Option<String>,
94
105
}
95
106
96
107
/// Blob scope with mime type constraints
···
310
321
"rpc",
311
322
"atproto",
312
323
"transition",
324
+
"include",
313
325
"openid",
314
326
"profile",
315
327
"email",
···
349
361
"rpc" => Self::parse_rpc(suffix),
350
362
"atproto" => Self::parse_atproto(suffix),
351
363
"transition" => Self::parse_transition(suffix),
364
+
"include" => Self::parse_include(suffix),
352
365
"openid" => Self::parse_openid(suffix),
353
366
"profile" => Self::parse_profile(suffix),
354
367
"email" => Self::parse_email(suffix),
···
573
586
Ok(Scope::Transition(scope))
574
587
}
575
588
589
+
fn parse_include(suffix: Option<&str>) -> Result<Self, ParseError> {
590
+
let (nsid, params) = match suffix {
591
+
Some(s) => {
592
+
if let Some(pos) = s.find('?') {
593
+
(&s[..pos], Some(&s[pos + 1..]))
594
+
} else {
595
+
(s, None)
596
+
}
597
+
}
598
+
None => return Err(ParseError::MissingResource),
599
+
};
600
+
601
+
if nsid.is_empty() {
602
+
return Err(ParseError::MissingResource);
603
+
}
604
+
605
+
let aud = if let Some(params) = params {
606
+
let parsed_params = parse_query_string(params);
607
+
parsed_params
608
+
.get("aud")
609
+
.and_then(|v| v.first())
610
+
.map(|s| url_decode(s))
611
+
} else {
612
+
None
613
+
};
614
+
615
+
Ok(Scope::Include(IncludeScope {
616
+
nsid: nsid.to_string(),
617
+
aud,
618
+
}))
619
+
}
620
+
576
621
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
577
622
if suffix.is_some() {
578
623
return Err(ParseError::InvalidResource(
···
677
722
if let Some(lxm) = scope.lxm.iter().next() {
678
723
match lxm {
679
724
RpcLexicon::All => "rpc:*".to_string(),
680
-
RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
725
+
RpcLexicon::Nsid(nsid) => format!("rpc:{}?aud=*", nsid),
726
+
}
727
+
} else {
728
+
"rpc:*".to_string()
729
+
}
730
+
} else if scope.lxm.len() == 1 && scope.aud.len() == 1 {
731
+
// Single lxm and single aud (aud is not All, handled above)
732
+
if let (Some(lxm), Some(aud)) =
733
+
(scope.lxm.iter().next(), scope.aud.iter().next())
734
+
{
735
+
match (lxm, aud) {
736
+
(RpcLexicon::Nsid(nsid), RpcAudience::Did(did)) => {
737
+
format!("rpc:{}?aud={}", nsid, did)
738
+
}
739
+
(RpcLexicon::All, RpcAudience::Did(did)) => {
740
+
format!("rpc:*?aud={}", did)
741
+
}
742
+
_ => "rpc:*".to_string(),
681
743
}
682
744
} else {
683
745
"rpc:*".to_string()
···
713
775
TransitionScope::Generic => "transition:generic".to_string(),
714
776
TransitionScope::Email => "transition:email".to_string(),
715
777
},
778
+
Scope::Include(scope) => {
779
+
if let Some(ref aud) = scope.aud {
780
+
format!("include:{}?aud={}", scope.nsid, url_encode(aud))
781
+
} else {
782
+
format!("include:{}", scope.nsid)
783
+
}
784
+
}
716
785
Scope::OpenId => "openid".to_string(),
717
786
Scope::Profile => "profile".to_string(),
718
787
Scope::Email => "email".to_string(),
···
732
801
// Other scopes don't grant transition scopes
733
802
(_, Scope::Transition(_)) => false,
734
803
(Scope::Transition(_), _) => false,
804
+
// Include scopes only grant themselves (exact match including aud)
805
+
(Scope::Include(a), Scope::Include(b)) => a == b,
806
+
// Other scopes don't grant include scopes
807
+
(_, Scope::Include(_)) => false,
808
+
(Scope::Include(_), _) => false,
735
809
// OpenID Connect scopes only grant themselves
736
810
(Scope::OpenId, Scope::OpenId) => true,
737
811
(Scope::OpenId, _) => false,
···
873
947
params
874
948
}
875
949
950
+
/// Decode a percent-encoded string
951
+
fn url_decode(s: &str) -> String {
952
+
let mut result = String::with_capacity(s.len());
953
+
let mut chars = s.chars().peekable();
954
+
955
+
while let Some(c) = chars.next() {
956
+
if c == '%' {
957
+
let hex: String = chars.by_ref().take(2).collect();
958
+
if hex.len() == 2 {
959
+
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
960
+
result.push(byte as char);
961
+
continue;
962
+
}
963
+
}
964
+
result.push('%');
965
+
result.push_str(&hex);
966
+
} else {
967
+
result.push(c);
968
+
}
969
+
}
970
+
971
+
result
972
+
}
973
+
974
+
/// Encode a string for use in a URL query parameter
975
+
fn url_encode(s: &str) -> String {
976
+
let mut result = String::with_capacity(s.len() * 3);
977
+
978
+
for c in s.chars() {
979
+
match c {
980
+
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => {
981
+
result.push(c);
982
+
}
983
+
_ => {
984
+
for byte in c.to_string().as_bytes() {
985
+
result.push_str(&format!("%{:02X}", byte));
986
+
}
987
+
}
988
+
}
989
+
}
990
+
991
+
result
992
+
}
993
+
876
994
/// Error type for scope parsing
877
995
#[derive(Debug, Clone, PartialEq, Eq)]
878
996
pub enum ParseError {
···
1056
1174
("repo:foo.bar", "repo:foo.bar"),
1057
1175
("repo:foo.bar?action=create", "repo:foo.bar?action=create"),
1058
1176
("rpc:*", "rpc:*"),
1177
+
("rpc:com.example.service", "rpc:com.example.service?aud=*"),
1178
+
(
1179
+
"rpc:com.example.service?aud=did:example:123",
1180
+
"rpc:com.example.service?aud=did:example:123",
1181
+
),
1059
1182
];
1060
1183
1061
1184
for (input, expected) in tests {
···
1677
1800
1678
1801
// Test with complex scopes including query parameters
1679
1802
let scopes = vec![
1680
-
Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method")
1681
-
.unwrap(),
1803
+
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1682
1804
Scope::parse("repo:foo.bar?action=create&action=update").unwrap(),
1683
1805
Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1684
1806
];
1685
1807
let result = Scope::serialize_multiple(&scopes);
1686
1808
// The result should be sorted alphabetically
1687
-
// Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1809
+
// Single lxm + single aud is serialized as "rpc:[lxm]?aud=[aud]"
1688
1810
assert!(result.starts_with("blob:"));
1689
1811
assert!(result.contains(" repo:"));
1690
-
assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service"));
1812
+
assert!(result.contains("rpc:com.example.service?aud=did:example:123"));
1691
1813
1692
1814
// Test with transition scopes
1693
1815
let scopes = vec![
···
1835
1957
assert!(!result.contains(&Scope::parse("account:email").unwrap()));
1836
1958
assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
1837
1959
assert!(result.contains(&Scope::parse("account:repo").unwrap()));
1960
+
}
1961
+
1962
+
#[test]
1963
+
fn test_repo_nsid_with_wildcard_suffix() {
1964
+
// Test parsing "repo:app.bsky.feed.*" - the asterisk is treated as a literal part of the NSID,
1965
+
// not as a wildcard pattern. Only "repo:*" has special wildcard behavior for ALL collections.
1966
+
let scope = Scope::parse("repo:app.bsky.feed.*").unwrap();
1967
+
1968
+
// Verify it parses as a specific NSID, not as a wildcard
1969
+
assert_eq!(
1970
+
scope,
1971
+
Scope::Repo(RepoScope {
1972
+
collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()),
1973
+
actions: {
1974
+
let mut actions = BTreeSet::new();
1975
+
actions.insert(RepoAction::Create);
1976
+
actions.insert(RepoAction::Update);
1977
+
actions.insert(RepoAction::Delete);
1978
+
actions
1979
+
}
1980
+
})
1981
+
);
1982
+
1983
+
// Verify normalization preserves the literal NSID
1984
+
assert_eq!(scope.to_string_normalized(), "repo:app.bsky.feed.*");
1985
+
1986
+
// Test that it does NOT grant access to "app.bsky.feed.post"
1987
+
// (because "app.bsky.feed.*" is a literal NSID, not a pattern)
1988
+
let specific_feed = Scope::parse("repo:app.bsky.feed.post").unwrap();
1989
+
assert!(!scope.grants(&specific_feed));
1990
+
1991
+
// Test that only "repo:*" grants access to "app.bsky.feed.*"
1992
+
let repo_all = Scope::parse("repo:*").unwrap();
1993
+
assert!(repo_all.grants(&scope));
1994
+
1995
+
// Test that "repo:app.bsky.feed.*" only grants itself
1996
+
assert!(scope.grants(&scope));
1997
+
1998
+
// Test with actions
1999
+
let scope_with_create = Scope::parse("repo:app.bsky.feed.*?action=create").unwrap();
2000
+
assert_eq!(
2001
+
scope_with_create,
2002
+
Scope::Repo(RepoScope {
2003
+
collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()),
2004
+
actions: {
2005
+
let mut actions = BTreeSet::new();
2006
+
actions.insert(RepoAction::Create);
2007
+
actions
2008
+
}
2009
+
})
2010
+
);
2011
+
2012
+
// The full scope (with all actions) grants the create-only scope
2013
+
assert!(scope.grants(&scope_with_create));
2014
+
// But the create-only scope does NOT grant the full scope
2015
+
assert!(!scope_with_create.grants(&scope));
2016
+
2017
+
// Test parsing multiple scopes with NSID wildcards
2018
+
let scopes = Scope::parse_multiple("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap();
2019
+
assert_eq!(scopes.len(), 3);
2020
+
2021
+
// Test that parse_multiple_reduced properly reduces when "repo:*" is present
2022
+
let reduced = Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap();
2023
+
assert_eq!(reduced.len(), 1);
2024
+
assert_eq!(reduced[0], repo_all);
2025
+
}
2026
+
2027
+
#[test]
2028
+
fn test_include_scope_parsing() {
2029
+
// Test basic include scope
2030
+
let scope = Scope::parse("include:app.example.authFull").unwrap();
2031
+
assert_eq!(
2032
+
scope,
2033
+
Scope::Include(IncludeScope {
2034
+
nsid: "app.example.authFull".to_string(),
2035
+
aud: None,
2036
+
})
2037
+
);
2038
+
2039
+
// Test include scope with audience
2040
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap();
2041
+
assert_eq!(
2042
+
scope,
2043
+
Scope::Include(IncludeScope {
2044
+
nsid: "app.example.authFull".to_string(),
2045
+
aud: Some("did:web:api.example.com".to_string()),
2046
+
})
2047
+
);
2048
+
2049
+
// Test include scope with URL-encoded audience (with fragment)
2050
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap();
2051
+
assert_eq!(
2052
+
scope,
2053
+
Scope::Include(IncludeScope {
2054
+
nsid: "app.example.authFull".to_string(),
2055
+
aud: Some("did:web:api.example.com#svc_chat".to_string()),
2056
+
})
2057
+
);
2058
+
2059
+
// Test missing NSID
2060
+
assert!(matches!(
2061
+
Scope::parse("include"),
2062
+
Err(ParseError::MissingResource)
2063
+
));
2064
+
2065
+
// Test empty NSID with query params
2066
+
assert!(matches!(
2067
+
Scope::parse("include:?aud=did:example:123"),
2068
+
Err(ParseError::MissingResource)
2069
+
));
2070
+
}
2071
+
2072
+
#[test]
2073
+
fn test_include_scope_normalization() {
2074
+
// Test normalization without audience
2075
+
let scope = Scope::parse("include:com.example.authBasic").unwrap();
2076
+
assert_eq!(scope.to_string_normalized(), "include:com.example.authBasic");
2077
+
2078
+
// Test normalization with audience (no special chars)
2079
+
let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap();
2080
+
assert_eq!(
2081
+
scope.to_string_normalized(),
2082
+
"include:com.example.authBasic?aud=did:plc:xyz123"
2083
+
);
2084
+
2085
+
// Test normalization with URL encoding (fragment needs encoding)
2086
+
let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap();
2087
+
let normalized = scope.to_string_normalized();
2088
+
assert_eq!(
2089
+
normalized,
2090
+
"include:app.example.authFull?aud=did:web:api.example.com%23svc_chat"
2091
+
);
2092
+
}
2093
+
2094
+
#[test]
2095
+
fn test_include_scope_grants() {
2096
+
let include1 = Scope::parse("include:app.example.authFull").unwrap();
2097
+
let include2 = Scope::parse("include:app.example.authBasic").unwrap();
2098
+
let include1_with_aud = Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap();
2099
+
let account = Scope::parse("account:email").unwrap();
2100
+
2101
+
// Include scopes only grant themselves (exact match)
2102
+
assert!(include1.grants(&include1));
2103
+
assert!(!include1.grants(&include2));
2104
+
assert!(!include1.grants(&include1_with_aud)); // Different because aud differs
2105
+
assert!(include1_with_aud.grants(&include1_with_aud));
2106
+
2107
+
// Include scopes don't grant other scope types
2108
+
assert!(!include1.grants(&account));
2109
+
assert!(!account.grants(&include1));
2110
+
2111
+
// Include scopes don't grant atproto or transition
2112
+
let atproto = Scope::parse("atproto").unwrap();
2113
+
let transition = Scope::parse("transition:generic").unwrap();
2114
+
assert!(!include1.grants(&atproto));
2115
+
assert!(!include1.grants(&transition));
2116
+
assert!(!atproto.grants(&include1));
2117
+
assert!(!transition.grants(&include1));
2118
+
}
2119
+
2120
+
#[test]
2121
+
fn test_parse_multiple_with_include() {
2122
+
let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap();
2123
+
assert_eq!(scopes.len(), 3);
2124
+
assert_eq!(scopes[0], Scope::Atproto);
2125
+
assert!(matches!(scopes[1], Scope::Include(_)));
2126
+
assert!(matches!(scopes[2], Scope::Repo(_)));
2127
+
2128
+
// Test with URL-encoded audience
2129
+
let scopes = Scope::parse_multiple(
2130
+
"include:app.example.auth?aud=did:web:api.example.com%23svc account:email"
2131
+
).unwrap();
2132
+
assert_eq!(scopes.len(), 2);
2133
+
if let Scope::Include(inc) = &scopes[0] {
2134
+
assert_eq!(inc.nsid, "app.example.auth");
2135
+
assert_eq!(inc.aud, Some("did:web:api.example.com#svc".to_string()));
2136
+
} else {
2137
+
panic!("Expected Include scope");
2138
+
}
2139
+
}
2140
+
2141
+
#[test]
2142
+
fn test_parse_multiple_reduced_with_include() {
2143
+
// Include scopes don't reduce each other (each is distinct)
2144
+
let scopes = Scope::parse_multiple_reduced(
2145
+
"include:app.example.auth include:app.example.other include:app.example.auth"
2146
+
).unwrap();
2147
+
assert_eq!(scopes.len(), 2); // Duplicates are removed
2148
+
assert!(scopes.contains(&Scope::Include(IncludeScope {
2149
+
nsid: "app.example.auth".to_string(),
2150
+
aud: None,
2151
+
})));
2152
+
assert!(scopes.contains(&Scope::Include(IncludeScope {
2153
+
nsid: "app.example.other".to_string(),
2154
+
aud: None,
2155
+
})));
2156
+
2157
+
// Include scopes with different audiences are not duplicates
2158
+
let scopes = Scope::parse_multiple_reduced(
2159
+
"include:app.example.auth include:app.example.auth?aud=did:plc:xyz"
2160
+
).unwrap();
2161
+
assert_eq!(scopes.len(), 2);
2162
+
}
2163
+
2164
+
#[test]
2165
+
fn test_serialize_multiple_with_include() {
2166
+
let scopes = vec![
2167
+
Scope::parse("repo:*").unwrap(),
2168
+
Scope::parse("include:app.example.authFull").unwrap(),
2169
+
Scope::Atproto,
2170
+
];
2171
+
let result = Scope::serialize_multiple(&scopes);
2172
+
assert_eq!(result, "atproto include:app.example.authFull repo:*");
2173
+
2174
+
// Test with URL-encoded audience
2175
+
let scopes = vec![
2176
+
Scope::Include(IncludeScope {
2177
+
nsid: "app.example.auth".to_string(),
2178
+
aud: Some("did:web:api.example.com#svc".to_string()),
2179
+
}),
2180
+
];
2181
+
let result = Scope::serialize_multiple(&scopes);
2182
+
assert_eq!(result, "include:app.example.auth?aud=did:web:api.example.com%23svc");
2183
+
}
2184
+
2185
+
#[test]
2186
+
fn test_remove_scope_with_include() {
2187
+
let scopes = vec![
2188
+
Scope::Atproto,
2189
+
Scope::parse("include:app.example.auth").unwrap(),
2190
+
Scope::parse("account:email").unwrap(),
2191
+
];
2192
+
let to_remove = Scope::parse("include:app.example.auth").unwrap();
2193
+
let result = Scope::remove_scope(&scopes, &to_remove);
2194
+
assert_eq!(result.len(), 2);
2195
+
assert!(!result.contains(&to_remove));
2196
+
assert!(result.contains(&Scope::Atproto));
2197
+
}
2198
+
2199
+
#[test]
2200
+
fn test_include_scope_roundtrip() {
2201
+
// Test that parse and serialize are inverses
2202
+
let original = "include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview";
2203
+
let scope = Scope::parse(original).unwrap();
2204
+
let serialized = scope.to_string_normalized();
2205
+
let reparsed = Scope::parse(&serialized).unwrap();
2206
+
assert_eq!(scope, reparsed);
1838
2207
}
1839
2208
}
+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
···
1
+
//! AT Protocol rich text facet types.
2
+
//!
3
+
//! This module provides types for annotating rich text content with semantic
4
+
//! meaning, based on the `app.bsky.richtext.facet` lexicon. Facets enable
5
+
//! mentions, links, hashtags, and other structured metadata to be attached
6
+
//! to specific byte ranges within text content.
7
+
//!
8
+
//! # Overview
9
+
//!
10
+
//! Facets consist of:
11
+
//! - A byte range (start/end indices in UTF-8 encoded text)
12
+
//! - One or more features (mention, link, tag) that apply to that range
13
+
//!
14
+
//! # Example
15
+
//!
16
+
//! ```ignore
17
+
//! use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, ByteSlice, FacetFeature, Mention};
18
+
//!
19
+
//! // Create a mention facet for "@alice.bsky.social"
20
+
//! let facet = Facet {
21
+
//! index: ByteSlice { byte_start: 0, byte_end: 19 },
22
+
//! features: vec![
23
+
//! FacetFeature::Mention(Mention {
24
+
//! did: "did:plc:alice123".to_string(),
25
+
//! })
26
+
//! ],
27
+
//! };
28
+
//! ```
29
+
30
+
use serde::{Deserialize, Serialize};
31
+
32
+
/// Byte range specification for facet features.
33
+
///
34
+
/// Specifies the sub-string range a facet feature applies to using
35
+
/// zero-indexed byte offsets in UTF-8 encoded text. Start index is
36
+
/// inclusive, end index is exclusive.
37
+
///
38
+
/// # Example
39
+
///
40
+
/// ```ignore
41
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice;
42
+
///
43
+
/// // Represents bytes 0-5 of the text
44
+
/// let slice = ByteSlice {
45
+
/// byte_start: 0,
46
+
/// byte_end: 5,
47
+
/// };
48
+
/// ```
49
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
50
+
#[serde(rename_all = "camelCase")]
51
+
pub struct ByteSlice {
52
+
/// Starting byte index (inclusive)
53
+
pub byte_start: usize,
54
+
55
+
/// Ending byte index (exclusive)
56
+
pub byte_end: usize,
57
+
}
58
+
59
+
/// Mention facet feature for referencing another account.
60
+
///
61
+
/// The text content typically displays a handle with '@' prefix (e.g., "@alice.bsky.social"),
62
+
/// but the facet reference must use the account's DID for stable identification.
63
+
///
64
+
/// # Example
65
+
///
66
+
/// ```ignore
67
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Mention;
68
+
///
69
+
/// let mention = Mention {
70
+
/// did: "did:plc:alice123".to_string(),
71
+
/// };
72
+
/// ```
73
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
74
+
pub struct Mention {
75
+
/// DID of the mentioned account
76
+
pub did: String,
77
+
}
78
+
79
+
/// Link facet feature for URL references.
80
+
///
81
+
/// The text content may be simplified or truncated for display purposes,
82
+
/// but the facet reference should contain the complete, valid URL.
83
+
///
84
+
/// # Example
85
+
///
86
+
/// ```ignore
87
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Link;
88
+
///
89
+
/// let link = Link {
90
+
/// uri: "https://example.com/full/path".to_string(),
91
+
/// };
92
+
/// ```
93
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
94
+
pub struct Link {
95
+
/// Complete URI/URL for the link
96
+
pub uri: String,
97
+
}
98
+
99
+
/// Tag facet feature for hashtags.
100
+
///
101
+
/// The text content typically includes a '#' prefix for display,
102
+
/// but the facet reference should contain only the tag text without the prefix.
103
+
///
104
+
/// # Example
105
+
///
106
+
/// ```ignore
107
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Tag;
108
+
///
109
+
/// // For text "#atproto", store just "atproto"
110
+
/// let tag = Tag {
111
+
/// tag: "atproto".to_string(),
112
+
/// };
113
+
/// ```
114
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
115
+
pub struct Tag {
116
+
/// Tag text without '#' prefix
117
+
pub tag: String,
118
+
}
119
+
120
+
/// Discriminated union of facet feature types.
121
+
///
122
+
/// Represents the different types of semantic annotations that can be
123
+
/// applied to text ranges. Each variant corresponds to a specific lexicon
124
+
/// type in the `app.bsky.richtext.facet` namespace.
125
+
///
126
+
/// # Example
127
+
///
128
+
/// ```ignore
129
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::{FacetFeature, Mention, Link, Tag};
130
+
///
131
+
/// // Create different feature types
132
+
/// let mention = FacetFeature::Mention(Mention {
133
+
/// did: "did:plc:alice123".to_string(),
134
+
/// });
135
+
///
136
+
/// let link = FacetFeature::Link(Link {
137
+
/// uri: "https://example.com".to_string(),
138
+
/// });
139
+
///
140
+
/// let tag = FacetFeature::Tag(Tag {
141
+
/// tag: "rust".to_string(),
142
+
/// });
143
+
/// ```
144
+
#[derive(Serialize, Deserialize, Clone, PartialEq)]
145
+
#[cfg_attr(debug_assertions, derive(Debug))]
146
+
#[serde(tag = "$type")]
147
+
pub enum FacetFeature {
148
+
/// Account mention feature
149
+
#[serde(rename = "app.bsky.richtext.facet#mention")]
150
+
Mention(Mention),
151
+
152
+
/// URL link feature
153
+
#[serde(rename = "app.bsky.richtext.facet#link")]
154
+
Link(Link),
155
+
156
+
/// Hashtag feature
157
+
#[serde(rename = "app.bsky.richtext.facet#tag")]
158
+
Tag(Tag),
159
+
}
160
+
161
+
/// Rich text facet annotation.
162
+
///
163
+
/// Associates one or more semantic features with a specific byte range
164
+
/// within text content. Multiple features can apply to the same range
165
+
/// (e.g., a URL that is also a hashtag).
166
+
///
167
+
/// # Example
168
+
///
169
+
/// ```ignore
170
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::{
171
+
/// Facet, ByteSlice, FacetFeature, Mention, Link
172
+
/// };
173
+
///
174
+
/// // Annotate "@alice.bsky.social" at bytes 0-19
175
+
/// let facet = Facet {
176
+
/// index: ByteSlice { byte_start: 0, byte_end: 19 },
177
+
/// features: vec![
178
+
/// FacetFeature::Mention(Mention {
179
+
/// did: "did:plc:alice123".to_string(),
180
+
/// }),
181
+
/// ],
182
+
/// };
183
+
///
184
+
/// // Multiple features for the same range
185
+
/// let multi_facet = Facet {
186
+
/// index: ByteSlice { byte_start: 20, byte_end: 35 },
187
+
/// features: vec![
188
+
/// FacetFeature::Link(Link {
189
+
/// uri: "https://example.com".to_string(),
190
+
/// }),
191
+
/// FacetFeature::Tag(Tag {
192
+
/// tag: "example".to_string(),
193
+
/// }),
194
+
/// ],
195
+
/// };
196
+
/// ```
197
+
#[derive(Serialize, Deserialize, Clone, PartialEq)]
198
+
#[cfg_attr(debug_assertions, derive(Debug))]
199
+
pub struct Facet {
200
+
/// Byte range this facet applies to
201
+
pub index: ByteSlice,
202
+
203
+
/// Semantic features applied to this range
204
+
pub features: Vec<FacetFeature>,
205
+
}
+19
-68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
+19
-68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
···
30
30
///
31
31
/// // Inline signature
32
32
/// let inline = SignatureOrRef::Inline(create_typed_signature(
33
-
/// "did:plc:issuer".to_string(),
34
33
/// Bytes { bytes: b"signature".to_vec() },
35
34
/// ));
36
35
///
···
55
54
56
55
/// Cryptographic signature structure.
57
56
///
58
-
/// Represents a signature created by an issuer (identified by DID) over
59
-
/// some data. The signature can be used to verify authenticity, authorization,
60
-
/// or other properties of the signed content.
57
+
/// Represents a cryptographic signature over some data. The signature can be
58
+
/// used to verify authenticity, authorization, or other properties of the
59
+
/// signed content.
61
60
///
62
61
/// # Fields
63
62
///
64
-
/// - `issuer`: DID of the entity that created the signature
65
63
/// - `signature`: The actual signature bytes
66
64
/// - `extra`: Additional fields that may be present in the signature
67
65
///
···
73
71
/// use std::collections::HashMap;
74
72
///
75
73
/// let sig = Signature {
76
-
/// issuer: "did:plc:example".to_string(),
77
74
/// signature: Bytes { bytes: b"signature_bytes".to_vec() },
78
75
/// extra: HashMap::new(),
79
76
/// };
···
81
78
#[derive(Deserialize, Serialize, Clone, PartialEq)]
82
79
#[cfg_attr(debug_assertions, derive(Debug))]
83
80
pub struct Signature {
84
-
/// DID of the entity that created this signature
85
-
pub issuer: String,
86
-
87
81
/// The cryptographic signature bytes
88
82
pub signature: Bytes,
89
83
···
116
110
///
117
111
/// # Arguments
118
112
///
119
-
/// * `issuer` - DID of the signature issuer
120
113
/// * `signature` - The signature bytes
121
114
///
122
115
/// # Example
···
126
119
/// use atproto_record::lexicon::Bytes;
127
120
///
128
121
/// let sig = create_typed_signature(
129
-
/// "did:plc:issuer".to_string(),
130
122
/// Bytes { bytes: b"sig_data".to_vec() },
131
123
/// );
132
124
/// ```
133
-
pub fn create_typed_signature(issuer: String, signature: Bytes) -> TypedSignature {
125
+
pub fn create_typed_signature(signature: Bytes) -> TypedSignature {
134
126
TypedLexicon::new(Signature {
135
-
issuer,
136
127
signature,
137
128
extra: HashMap::new(),
138
129
})
···
150
141
let json_str = r#"{
151
142
"$type": "community.lexicon.attestation.signature",
152
143
"issuedAt": "2025-08-19T20:17:17.133Z",
153
-
"issuer": "did:web:acudo-dev.smokesignal.tools",
154
144
"signature": {
155
145
"$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA"
156
146
}
···
160
150
let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str);
161
151
match &typed_sig_result {
162
152
Ok(sig) => {
163
-
println!("TypedSignature OK: issuer={}", sig.inner.issuer);
164
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
153
+
println!("TypedSignature OK: signature bytes len={}", sig.inner.signature.bytes.len());
154
+
assert_eq!(sig.inner.signature.bytes.len(), 64);
165
155
}
166
156
Err(e) => {
167
157
eprintln!("TypedSignature deserialization error: {}", e);
···
172
162
let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str);
173
163
match &sig_or_ref_result {
174
164
Ok(SignatureOrRef::Inline(sig)) => {
175
-
println!("SignatureOrRef OK (Inline): issuer={}", sig.inner.issuer);
176
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
165
+
println!("SignatureOrRef OK (Inline): signature bytes len={}", sig.inner.signature.bytes.len());
166
+
assert_eq!(sig.inner.signature.bytes.len(), 64);
177
167
}
178
168
Ok(SignatureOrRef::Reference(_)) => {
179
169
panic!("Expected Inline signature, got Reference");
···
186
176
// Try without $type field
187
177
let json_no_type = r#"{
188
178
"issuedAt": "2025-08-19T20:17:17.133Z",
189
-
"issuer": "did:web:acudo-dev.smokesignal.tools",
190
179
"signature": {
191
180
"$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA"
192
181
}
···
195
184
let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type);
196
185
match &no_type_result {
197
186
Ok(sig) => {
198
-
println!("Signature (no type) OK: issuer={}", sig.issuer);
199
-
assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools");
187
+
println!("Signature (no type) OK: signature bytes len={}", sig.signature.bytes.len());
200
188
assert_eq!(sig.signature.bytes.len(), 64);
201
189
202
190
// Now wrap it in TypedLexicon and try as SignatureOrRef
···
220
208
fn test_signature_deserialization() {
221
209
let json_str = r#"{
222
210
"$type": "community.lexicon.attestation.signature",
223
-
"issuer": "did:plc:test123",
224
211
"signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}
225
212
}"#;
226
213
227
214
let signature: Signature = serde_json::from_str(json_str).unwrap();
228
215
229
-
assert_eq!(signature.issuer, "did:plc:test123");
230
216
assert_eq!(signature.signature.bytes, b"test signature");
231
217
// The $type field will be captured in extra due to #[serde(flatten)]
232
218
assert_eq!(signature.extra.len(), 1);
···
237
223
fn test_signature_deserialization_with_extra_fields() {
238
224
let json_str = r#"{
239
225
"$type": "community.lexicon.attestation.signature",
240
-
"issuer": "did:plc:test123",
241
226
"signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="},
242
227
"issuedAt": "2024-01-01T00:00:00.000Z",
243
228
"purpose": "verification"
···
245
230
246
231
let signature: Signature = serde_json::from_str(json_str).unwrap();
247
232
248
-
assert_eq!(signature.issuer, "did:plc:test123");
249
233
assert_eq!(signature.signature.bytes, b"test signature");
250
234
// 3 extra fields: $type, issuedAt, purpose
251
235
assert_eq!(signature.extra.len(), 3);
···
263
247
extra.insert("custom_field".to_string(), json!("custom_value"));
264
248
265
249
let signature = Signature {
266
-
issuer: "did:plc:serializer".to_string(),
267
250
signature: Bytes {
268
251
bytes: b"hello world".to_vec(),
269
252
},
···
274
257
275
258
// Without custom Serialize impl, $type is not automatically added
276
259
assert!(!json.as_object().unwrap().contains_key("$type"));
277
-
assert_eq!(json["issuer"], "did:plc:serializer");
278
260
// "hello world" base64 encoded is "aGVsbG8gd29ybGQ="
279
261
assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ=");
280
262
assert_eq!(json["custom_field"], "custom_value");
···
283
265
#[test]
284
266
fn test_signature_round_trip() {
285
267
let original = Signature {
286
-
issuer: "did:plc:roundtrip".to_string(),
287
268
signature: Bytes {
288
269
bytes: b"round trip test".to_vec(),
289
270
},
···
296
277
// Deserialize back
297
278
let deserialized: Signature = serde_json::from_str(&json).unwrap();
298
279
299
-
assert_eq!(original.issuer, deserialized.issuer);
300
280
assert_eq!(original.signature.bytes, deserialized.signature.bytes);
301
281
// Without the custom Serialize impl, no $type is added
302
282
// so the round-trip preserves the empty extra map
···
317
297
extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"]));
318
298
319
299
let signature = Signature {
320
-
issuer: "did:plc:complex".to_string(),
321
300
signature: Bytes {
322
301
bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA],
323
302
},
···
328
307
329
308
// Without custom Serialize impl, $type is not automatically added
330
309
assert!(!json.as_object().unwrap().contains_key("$type"));
331
-
assert_eq!(json["issuer"], "did:plc:complex");
332
310
assert_eq!(json["timestamp"], 1234567890);
333
311
assert_eq!(json["metadata"]["version"], "1.0");
334
312
assert_eq!(json["metadata"]["algorithm"], "ES256");
···
338
316
#[test]
339
317
fn test_empty_signature() {
340
318
let signature = Signature {
341
-
issuer: String::new(),
342
319
signature: Bytes { bytes: Vec::new() },
343
320
extra: HashMap::new(),
344
321
};
···
347
324
348
325
// Without custom Serialize impl, $type is not automatically added
349
326
assert!(!json.as_object().unwrap().contains_key("$type"));
350
-
assert_eq!(json["issuer"], "");
351
327
assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string
352
328
}
353
329
···
356
332
// Test with plain Vec<Signature> for basic signature serialization
357
333
let signatures: Vec<Signature> = vec![
358
334
Signature {
359
-
issuer: "did:plc:first".to_string(),
360
335
signature: Bytes {
361
336
bytes: b"first".to_vec(),
362
337
},
363
338
extra: HashMap::new(),
364
339
},
365
340
Signature {
366
-
issuer: "did:plc:second".to_string(),
367
341
signature: Bytes {
368
342
bytes: b"second".to_vec(),
369
343
},
···
375
349
376
350
assert!(json.is_array());
377
351
assert_eq!(json.as_array().unwrap().len(), 2);
378
-
assert_eq!(json[0]["issuer"], "did:plc:first");
379
-
assert_eq!(json[1]["issuer"], "did:plc:second");
352
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
353
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
380
354
}
381
355
382
356
#[test]
···
384
358
// Test the new Signatures type with inline signatures
385
359
let signatures: Signatures = vec![
386
360
SignatureOrRef::Inline(create_typed_signature(
387
-
"did:plc:first".to_string(),
388
361
Bytes {
389
362
bytes: b"first".to_vec(),
390
363
},
391
364
)),
392
365
SignatureOrRef::Inline(create_typed_signature(
393
-
"did:plc:second".to_string(),
394
366
Bytes {
395
367
bytes: b"second".to_vec(),
396
368
},
···
402
374
assert!(json.is_array());
403
375
assert_eq!(json.as_array().unwrap().len(), 2);
404
376
assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature");
405
-
assert_eq!(json[0]["issuer"], "did:plc:first");
377
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
406
378
assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature");
407
-
assert_eq!(json[1]["issuer"], "did:plc:second");
379
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
408
380
}
409
381
410
382
#[test]
411
383
fn test_typed_signature_serialization() {
412
384
let typed_sig = create_typed_signature(
413
-
"did:plc:typed".to_string(),
414
385
Bytes {
415
386
bytes: b"typed signature".to_vec(),
416
387
},
···
419
390
let json = serde_json::to_value(&typed_sig).unwrap();
420
391
421
392
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
422
-
assert_eq!(json["issuer"], "did:plc:typed");
423
393
// "typed signature" base64 encoded
424
394
assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl");
425
395
}
···
428
398
fn test_typed_signature_deserialization() {
429
399
let json = json!({
430
400
"$type": "community.lexicon.attestation.signature",
431
-
"issuer": "did:plc:typed",
432
401
"signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"}
433
402
});
434
403
435
404
let typed_sig: TypedSignature = serde_json::from_value(json).unwrap();
436
405
437
-
assert_eq!(typed_sig.inner.issuer, "did:plc:typed");
438
406
assert_eq!(typed_sig.inner.signature.bytes, b"typed signature");
439
407
assert!(typed_sig.has_type_field());
440
408
assert!(typed_sig.validate().is_ok());
···
443
411
#[test]
444
412
fn test_typed_signature_without_type_field() {
445
413
let json = json!({
446
-
"issuer": "did:plc:notype",
447
414
"signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64
448
415
});
449
416
450
417
let typed_sig: TypedSignature = serde_json::from_value(json).unwrap();
451
418
452
-
assert_eq!(typed_sig.inner.issuer, "did:plc:notype");
453
419
assert_eq!(typed_sig.inner.signature.bytes, b"no type");
454
420
assert!(!typed_sig.has_type_field());
455
421
// Validation should still pass because type_required() returns false for Signature
···
459
425
#[test]
460
426
fn test_typed_signature_with_extra_fields() {
461
427
let mut sig = Signature {
462
-
issuer: "did:plc:extra".to_string(),
463
428
signature: Bytes {
464
429
bytes: b"extra test".to_vec(),
465
430
},
···
474
439
let json = serde_json::to_value(&typed_sig).unwrap();
475
440
476
441
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
477
-
assert_eq!(json["issuer"], "did:plc:extra");
478
442
assert_eq!(json["customField"], "customValue");
479
443
assert_eq!(json["timestamp"], 1234567890);
480
444
}
···
482
446
#[test]
483
447
fn test_typed_signature_round_trip() {
484
448
let original = Signature {
485
-
issuer: "did:plc:roundtrip2".to_string(),
486
449
signature: Bytes {
487
450
bytes: b"round trip typed".to_vec(),
488
451
},
···
494
457
let json = serde_json::to_string(&typed).unwrap();
495
458
let deserialized: TypedSignature = serde_json::from_str(&json).unwrap();
496
459
497
-
assert_eq!(deserialized.inner.issuer, original.issuer);
498
460
assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes);
499
461
assert!(deserialized.has_type_field());
500
462
}
···
503
465
fn test_typed_signatures_vec() {
504
466
let typed_sigs: Vec<TypedSignature> = vec![
505
467
create_typed_signature(
506
-
"did:plc:first".to_string(),
507
468
Bytes {
508
469
bytes: b"first".to_vec(),
509
470
},
510
471
),
511
472
create_typed_signature(
512
-
"did:plc:second".to_string(),
513
473
Bytes {
514
474
bytes: b"second".to_vec(),
515
475
},
···
520
480
521
481
assert!(json.is_array());
522
482
assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature");
523
-
assert_eq!(json[0]["issuer"], "did:plc:first");
483
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
524
484
assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature");
525
-
assert_eq!(json[1]["issuer"], "did:plc:second");
485
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
526
486
}
527
487
528
488
#[test]
529
489
fn test_plain_vs_typed_signature() {
530
490
// Plain Signature doesn't include $type field
531
491
let plain_sig = Signature {
532
-
issuer: "did:plc:plain".to_string(),
533
492
signature: Bytes {
534
493
bytes: b"plain sig".to_vec(),
535
494
},
···
548
507
);
549
508
550
509
// Both have the same core data
551
-
assert_eq!(plain_json["issuer"], typed_json["issuer"]);
552
510
assert_eq!(plain_json["signature"], typed_json["signature"]);
553
511
}
554
512
···
556
514
fn test_signature_or_ref_inline() {
557
515
// Test inline signature
558
516
let inline_sig = create_typed_signature(
559
-
"did:plc:inline".to_string(),
560
517
Bytes {
561
518
bytes: b"inline signature".to_vec(),
562
519
},
···
567
524
// Serialize
568
525
let json = serde_json::to_value(&sig_or_ref).unwrap();
569
526
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
570
-
assert_eq!(json["issuer"], "did:plc:inline");
571
527
assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64
572
528
573
529
// Deserialize
574
530
let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap();
575
531
match deserialized {
576
532
SignatureOrRef::Inline(sig) => {
577
-
assert_eq!(sig.inner.issuer, "did:plc:inline");
578
533
assert_eq!(sig.inner.signature.bytes, b"inline signature");
579
534
}
580
535
_ => panic!("Expected inline signature"),
···
621
576
let signatures: Signatures = vec![
622
577
// Inline signature
623
578
SignatureOrRef::Inline(create_typed_signature(
624
-
"did:plc:signer1".to_string(),
625
579
Bytes {
626
580
bytes: b"sig1".to_vec(),
627
581
},
···
633
587
})),
634
588
// Another inline signature
635
589
SignatureOrRef::Inline(create_typed_signature(
636
-
"did:plc:signer3".to_string(),
637
590
Bytes {
638
591
bytes: b"sig3".to_vec(),
639
592
},
···
648
601
649
602
// First element should be inline signature
650
603
assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature");
651
-
assert_eq!(array[0]["issuer"], "did:plc:signer1");
604
+
assert_eq!(array[0]["signature"]["$bytes"], "c2lnMQ=="); // "sig1" in base64
652
605
653
606
// Second element should be reference
654
607
assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef");
···
659
612
660
613
// Third element should be inline signature
661
614
assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature");
662
-
assert_eq!(array[2]["issuer"], "did:plc:signer3");
615
+
assert_eq!(array[2]["signature"]["$bytes"], "c2lnMw=="); // "sig3" in base64
663
616
664
617
// Deserialize back
665
618
let deserialized: Signatures = serde_json::from_value(json).unwrap();
···
667
620
668
621
// Verify each element
669
622
match &deserialized[0] {
670
-
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer1"),
623
+
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig1"),
671
624
_ => panic!("Expected inline signature at index 0"),
672
625
}
673
626
···
682
635
}
683
636
684
637
match &deserialized[2] {
685
-
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer3"),
638
+
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig3"),
686
639
_ => panic!("Expected inline signature at index 2"),
687
640
}
688
641
}
···
694
647
// Inline signature JSON
695
648
let inline_json = r#"{
696
649
"$type": "community.lexicon.attestation.signature",
697
-
"issuer": "did:plc:testinline",
698
650
"signature": {"$bytes": "aGVsbG8="}
699
651
}"#;
700
652
701
653
let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap();
702
654
match inline_deser {
703
655
SignatureOrRef::Inline(sig) => {
704
-
assert_eq!(sig.inner.issuer, "did:plc:testinline");
705
656
assert_eq!(sig.inner.signature.bytes, b"hello");
706
657
}
707
658
_ => panic!("Expected inline signature"),
+1
-2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
+1
-2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
···
311
311
// The signature should be inline in this test
312
312
match sig_or_ref {
313
313
crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => {
314
-
assert_eq!(sig.issuer, "did:plc:issuer");
315
314
// The bytes should match the decoded base64 value
316
315
// "dGVzdCBzaWduYXR1cmU=" decodes to "test signature"
317
-
assert_eq!(sig.signature.bytes, b"test signature".to_vec());
316
+
assert_eq!(sig.inner.signature.bytes, b"test signature".to_vec());
318
317
}
319
318
_ => panic!("Expected inline signature"),
320
319
}
+43
-9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
+43
-9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
···
10
10
11
11
use crate::datetime::format as datetime_format;
12
12
use crate::datetime::optional_format as optional_datetime_format;
13
+
use crate::lexicon::app::bsky::richtext::facet::Facet;
13
14
use crate::lexicon::TypedBlob;
14
15
use crate::lexicon::community::lexicon::location::Locations;
15
16
use crate::typed::{LexiconType, TypedLexicon};
16
17
17
-
/// The namespace identifier for events
18
+
/// Lexicon namespace identifier for calendar events.
19
+
///
20
+
/// Used as the `$type` field value for event records in the AT Protocol.
18
21
pub const NSID: &str = "community.lexicon.calendar.event";
19
22
20
23
/// Event status enumeration.
···
65
68
Hybrid,
66
69
}
67
70
68
-
/// The namespace identifier for named URIs
71
+
/// Lexicon namespace identifier for named URIs in calendar events.
72
+
///
73
+
/// Used as the `$type` field value for URI references associated with events.
69
74
pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri";
70
75
71
76
/// Named URI structure.
···
89
94
}
90
95
}
91
96
92
-
/// Type alias for NamedUri with automatic $type field handling
97
+
/// Type alias for NamedUri with automatic $type field handling.
98
+
///
99
+
/// Wraps `NamedUri` in `TypedLexicon` to ensure proper serialization
100
+
/// and deserialization of the `$type` field.
93
101
pub type TypedNamedUri = TypedLexicon<NamedUri>;
94
102
95
-
/// The namespace identifier for event links
103
+
/// Lexicon namespace identifier for event links.
104
+
///
105
+
/// Used as the `$type` field value for event link references.
106
+
/// Note: This shares the same NSID as `NAMED_URI_NSID` for compatibility.
96
107
pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri";
97
108
98
109
/// Event link structure.
···
116
127
}
117
128
}
118
129
119
-
/// Type alias for EventLink with automatic $type field handling
130
+
/// Type alias for EventLink with automatic $type field handling.
131
+
///
132
+
/// Wraps `EventLink` in `TypedLexicon` to ensure proper serialization
133
+
/// and deserialization of the `$type` field.
120
134
pub type TypedEventLink = TypedLexicon<EventLink>;
121
135
122
-
/// A vector of typed event links
136
+
/// Collection of typed event links.
137
+
///
138
+
/// Represents multiple URI references associated with an event,
139
+
/// such as registration pages, live streams, or related content.
123
140
pub type EventLinks = Vec<TypedEventLink>;
124
141
125
142
/// Aspect ratio for media content.
···
134
151
pub height: u64,
135
152
}
136
153
137
-
/// The namespace identifier for media
154
+
/// Lexicon namespace identifier for event media.
155
+
///
156
+
/// Used as the `$type` field value for media attachments associated with events.
138
157
pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media";
139
158
140
159
/// Media structure for event-related visual content.
···
163
182
}
164
183
}
165
184
166
-
/// Type alias for Media with automatic $type field handling
185
+
/// Type alias for Media with automatic $type field handling.
186
+
///
187
+
/// Wraps `Media` in `TypedLexicon` to ensure proper serialization
188
+
/// and deserialization of the `$type` field.
167
189
pub type TypedMedia = TypedLexicon<Media>;
168
190
169
-
/// A vector of typed media items
191
+
/// Collection of typed media items.
192
+
///
193
+
/// Represents multiple media attachments for an event, such as banners,
194
+
/// posters, thumbnails, or promotional images.
170
195
pub type MediaList = Vec<TypedMedia>;
171
196
172
197
/// Calendar event structure.
···
248
273
#[serde(skip_serializing_if = "Vec::is_empty", default)]
249
274
pub media: MediaList,
250
275
276
+
/// Rich text facets for semantic annotations in description field.
277
+
///
278
+
/// Enables mentions, links, and hashtags to be embedded in the event
279
+
/// description text with proper semantic metadata.
280
+
#[serde(skip_serializing_if = "Option::is_none")]
281
+
pub facets: Option<Vec<Facet>>,
282
+
251
283
/// Extension fields for forward compatibility.
252
284
/// This catch-all allows unknown fields to be preserved and indexed
253
285
/// for potential future use without requiring re-indexing.
···
312
344
locations: vec![],
313
345
uris: vec![],
314
346
media: vec![],
347
+
facets: None,
315
348
extra: HashMap::new(),
316
349
};
317
350
···
466
499
locations: vec![],
467
500
uris: vec![TypedLexicon::new(event_link)],
468
501
media: vec![TypedLexicon::new(media)],
502
+
facets: None,
469
503
extra: HashMap::new(),
470
504
};
471
505
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
···
294
294
assert_eq!(typed_rsvp.inner.signatures.len(), 1);
295
295
match &typed_rsvp.inner.signatures[0] {
296
296
SignatureOrRef::Inline(sig) => {
297
-
assert_eq!(sig.inner.issuer, "did:plc:issuer");
298
297
assert_eq!(sig.inner.signature.bytes, b"test signature");
299
298
}
300
299
_ => panic!("Expected inline signature"),
···
364
363
assert_eq!(typed_rsvp.inner.signatures.len(), 1);
365
364
match &typed_rsvp.inner.signatures[0] {
366
365
SignatureOrRef::Inline(sig) => {
367
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
368
-
369
366
// Verify the issuedAt field if present
370
367
if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") {
371
368
assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
+22
crates/atproto-record/src/lexicon/mod.rs
+22
crates/atproto-record/src/lexicon/mod.rs
···
37
37
mod community_lexicon_calendar_event;
38
38
mod community_lexicon_calendar_rsvp;
39
39
mod community_lexicon_location;
40
+
mod app_bsky_richtext_facet;
40
41
mod primatives;
41
42
43
+
// Re-export primitive types for convenience
42
44
pub use primatives::*;
45
+
46
+
/// Bluesky application namespace.
47
+
///
48
+
/// Contains lexicon types specific to the Bluesky application,
49
+
/// including rich text formatting and social features.
50
+
pub mod app {
51
+
/// Bluesky namespace.
52
+
pub mod bsky {
53
+
/// Rich text formatting types.
54
+
pub mod richtext {
55
+
/// Facet types for semantic text annotations.
56
+
///
57
+
/// Provides types for mentions, links, hashtags, and other
58
+
/// structured metadata that can be attached to text content.
59
+
pub mod facet {
60
+
pub use crate::lexicon::app_bsky_richtext_facet::*;
61
+
}
62
+
}
63
+
}
64
+
}
43
65
44
66
/// AT Protocol core types namespace
45
67
pub mod com {
+53
crates/atproto-tap/Cargo.toml
+53
crates/atproto-tap/Cargo.toml
···
1
+
[package]
2
+
name = "atproto-tap"
3
+
version = "0.13.0"
4
+
description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer"
5
+
readme = "README.md"
6
+
homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs"
7
+
documentation = "https://docs.rs/atproto-tap"
8
+
9
+
edition.workspace = true
10
+
rust-version.workspace = true
11
+
authors.workspace = true
12
+
repository.workspace = true
13
+
license.workspace = true
14
+
keywords.workspace = true
15
+
categories.workspace = true
16
+
17
+
[dependencies]
18
+
tokio = { workspace = true, features = ["sync", "time"] }
19
+
tokio-stream = "0.1"
20
+
tokio-websockets = { workspace = true }
21
+
futures = { workspace = true }
22
+
reqwest = { workspace = true }
23
+
serde = { workspace = true }
24
+
serde_json = { workspace = true }
25
+
thiserror = { workspace = true }
26
+
tracing = { workspace = true }
27
+
http = { workspace = true }
28
+
base64 = { workspace = true }
29
+
atproto-identity.workspace = true
30
+
atproto-client = { workspace = true, optional = true }
31
+
32
+
# Memory efficiency
33
+
compact_str = { version = "0.8", features = ["serde"] }
34
+
itoa = "1.0"
35
+
36
+
# Optional for CLI
37
+
clap = { workspace = true, optional = true }
38
+
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
39
+
40
+
[features]
41
+
default = []
42
+
clap = ["dep:clap", "dep:tracing-subscriber", "dep:atproto-client", "tokio/rt-multi-thread", "tokio/macros", "tokio/signal"]
43
+
44
+
[[bin]]
45
+
name = "atproto-tap-client"
46
+
required-features = ["clap"]
47
+
48
+
[[bin]]
49
+
name = "atproto-tap-extras"
50
+
required-features = ["clap"]
51
+
52
+
[lints]
53
+
workspace = true
+351
crates/atproto-tap/src/bin/atproto-tap-client.rs
+351
crates/atproto-tap/src/bin/atproto-tap-client.rs
···
1
+
//! Command-line client for TAP services.
2
+
//!
3
+
//! This tool provides commands for consuming TAP events and managing tracked repositories.
4
+
//!
5
+
//! # Usage
6
+
//!
7
+
//! ```bash
8
+
//! # Stream events from a TAP service
9
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 read
10
+
//!
11
+
//! # Stream with authentication and filters
12
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret read --live-only
13
+
//!
14
+
//! # Add repositories to track
15
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos add did:plc:xyz did:plc:abc
16
+
//!
17
+
//! # Remove repositories from tracking
18
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos remove did:plc:xyz
19
+
//!
20
+
//! # Resolve a DID to its DID document
21
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz
22
+
//!
23
+
//! # Resolve a DID and only output the handle
24
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz --handle-only
25
+
//!
26
+
//! # Get repository tracking info
27
+
//! cargo run --features cli --bin atproto-tap-client -- localhost:2480 info did:plc:xyz
28
+
//! ```
29
+
30
+
use atproto_tap::{TapClient, TapConfig, TapEvent, connect};
31
+
use clap::{Parser, Subcommand};
32
+
use std::time::Duration;
33
+
use tokio_stream::StreamExt;
34
+
35
+
/// TAP service client for consuming events and managing repositories.
36
+
#[derive(Parser)]
37
+
#[command(
38
+
name = "atproto-tap-client",
39
+
version,
40
+
about = "TAP service client for AT Protocol",
41
+
long_about = "Connect to a TAP service to stream repository/identity events or manage tracked repositories.\n\n\
42
+
Events are printed to stdout as JSON, one per line.\n\
43
+
Use Ctrl+C to gracefully stop the consumer."
44
+
)]
45
+
struct Args {
46
+
/// TAP service hostname (e.g., localhost:2480)
47
+
hostname: String,
48
+
49
+
/// Admin password for authentication
50
+
#[arg(short, long, global = true)]
51
+
password: Option<String>,
52
+
53
+
#[command(subcommand)]
54
+
command: Command,
55
+
}
56
+
57
+
#[derive(Subcommand)]
58
+
enum Command {
59
+
/// Connect to TAP and stream events as JSON
60
+
Read {
61
+
/// Disable acknowledgments
62
+
#[arg(long)]
63
+
no_acks: bool,
64
+
65
+
/// Maximum reconnection attempts (0 = unlimited)
66
+
#[arg(long, default_value = "0")]
67
+
max_reconnects: u32,
68
+
69
+
/// Print debug information to stderr
70
+
#[arg(short, long)]
71
+
debug: bool,
72
+
73
+
/// Filter to specific collections (comma-separated)
74
+
#[arg(long)]
75
+
collections: Option<String>,
76
+
77
+
/// Only show live events (skip backfill)
78
+
#[arg(long)]
79
+
live_only: bool,
80
+
},
81
+
82
+
/// Manage tracked repositories
83
+
Repos {
84
+
#[command(subcommand)]
85
+
action: ReposAction,
86
+
},
87
+
88
+
/// Resolve a DID to its DID document
89
+
Resolve {
90
+
/// DID to resolve (e.g., did:plc:xyz123)
91
+
did: String,
92
+
93
+
/// Only output the handle (instead of full DID document)
94
+
#[arg(long)]
95
+
handle_only: bool,
96
+
},
97
+
98
+
/// Get tracking info for a repository
99
+
Info {
100
+
/// DID to get info for (e.g., did:plc:xyz123)
101
+
did: String,
102
+
},
103
+
}
104
+
105
+
#[derive(Subcommand)]
106
+
enum ReposAction {
107
+
/// Add repositories to track
108
+
Add {
109
+
/// DIDs to add (e.g., did:plc:xyz123)
110
+
#[arg(required = true)]
111
+
dids: Vec<String>,
112
+
},
113
+
114
+
/// Remove repositories from tracking
115
+
Remove {
116
+
/// DIDs to remove
117
+
#[arg(required = true)]
118
+
dids: Vec<String>,
119
+
},
120
+
}
121
+
122
+
#[tokio::main]
123
+
async fn main() {
124
+
let args = Args::parse();
125
+
126
+
match args.command {
127
+
Command::Read {
128
+
no_acks,
129
+
max_reconnects,
130
+
debug,
131
+
collections,
132
+
live_only,
133
+
} => {
134
+
run_read(
135
+
&args.hostname,
136
+
args.password,
137
+
no_acks,
138
+
max_reconnects,
139
+
debug,
140
+
collections,
141
+
live_only,
142
+
)
143
+
.await;
144
+
}
145
+
Command::Repos { action } => {
146
+
run_repos(&args.hostname, args.password, action).await;
147
+
}
148
+
Command::Resolve { did, handle_only } => {
149
+
run_resolve(&args.hostname, args.password, &did, handle_only).await;
150
+
}
151
+
Command::Info { did } => {
152
+
run_info(&args.hostname, args.password, &did).await;
153
+
}
154
+
}
155
+
}
156
+
157
+
async fn run_read(
158
+
hostname: &str,
159
+
password: Option<String>,
160
+
no_acks: bool,
161
+
max_reconnects: u32,
162
+
debug: bool,
163
+
collections: Option<String>,
164
+
live_only: bool,
165
+
) {
166
+
// Initialize tracing if debug mode
167
+
if debug {
168
+
tracing_subscriber::fmt()
169
+
.with_env_filter("atproto_tap=debug")
170
+
.with_writer(std::io::stderr)
171
+
.init();
172
+
}
173
+
174
+
// Build configuration
175
+
let mut config_builder = TapConfig::builder()
176
+
.hostname(hostname)
177
+
.send_acks(!no_acks);
178
+
179
+
if let Some(password) = password {
180
+
config_builder = config_builder.admin_password(password);
181
+
}
182
+
183
+
if max_reconnects > 0 {
184
+
config_builder = config_builder.max_reconnect_attempts(Some(max_reconnects));
185
+
}
186
+
187
+
// Set reasonable defaults for CLI usage
188
+
config_builder = config_builder
189
+
.initial_reconnect_delay(Duration::from_secs(1))
190
+
.max_reconnect_delay(Duration::from_secs(30));
191
+
192
+
let config = config_builder.build();
193
+
194
+
eprintln!("Connecting to TAP service at {}...", hostname);
195
+
196
+
let mut stream = connect(config);
197
+
198
+
// Parse collection filters
199
+
let collection_filters: Vec<String> = collections
200
+
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
201
+
.unwrap_or_default();
202
+
203
+
// Handle Ctrl+C
204
+
let ctrl_c = tokio::signal::ctrl_c();
205
+
tokio::pin!(ctrl_c);
206
+
207
+
loop {
208
+
tokio::select! {
209
+
Some(result) = stream.next() => {
210
+
match result {
211
+
Ok(event) => {
212
+
// Apply filters
213
+
let should_print = match event.as_ref() {
214
+
TapEvent::Record { record, .. } => {
215
+
// Filter by live flag
216
+
if live_only && !record.live {
217
+
false
218
+
}
219
+
// Filter by collection
220
+
else if !collection_filters.is_empty() {
221
+
collection_filters.iter().any(|c| record.collection.as_ref() == c)
222
+
} else {
223
+
true
224
+
}
225
+
}
226
+
TapEvent::Identity { .. } => !live_only, // Always show identity unless live_only
227
+
};
228
+
229
+
if should_print {
230
+
// Print as JSON to stdout
231
+
match serde_json::to_string(event.as_ref()) {
232
+
Ok(json) => println!("{}", json),
233
+
Err(e) => {
234
+
eprintln!("Failed to serialize event: {}", e);
235
+
}
236
+
}
237
+
}
238
+
}
239
+
Err(e) => {
240
+
eprintln!("Error: {}", e);
241
+
242
+
// Exit on fatal errors
243
+
if e.is_fatal() {
244
+
eprintln!("Fatal error, exiting");
245
+
std::process::exit(1);
246
+
}
247
+
}
248
+
}
249
+
}
250
+
_ = &mut ctrl_c => {
251
+
eprintln!("\nReceived Ctrl+C, shutting down...");
252
+
stream.close().await;
253
+
break;
254
+
}
255
+
}
256
+
}
257
+
258
+
eprintln!("Client stopped");
259
+
}
260
+
261
+
async fn run_repos(hostname: &str, password: Option<String>, action: ReposAction) {
262
+
let client = TapClient::new(hostname, password);
263
+
264
+
match action {
265
+
ReposAction::Add { dids } => {
266
+
let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect();
267
+
268
+
match client.add_repos(&did_refs).await {
269
+
Ok(()) => {
270
+
eprintln!("Added {} repository(ies) to tracking", dids.len());
271
+
for did in &dids {
272
+
println!("{}", did);
273
+
}
274
+
}
275
+
Err(e) => {
276
+
eprintln!("Failed to add repositories: {}", e);
277
+
std::process::exit(1);
278
+
}
279
+
}
280
+
}
281
+
ReposAction::Remove { dids } => {
282
+
let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect();
283
+
284
+
match client.remove_repos(&did_refs).await {
285
+
Ok(()) => {
286
+
eprintln!("Removed {} repository(ies) from tracking", dids.len());
287
+
for did in &dids {
288
+
println!("{}", did);
289
+
}
290
+
}
291
+
Err(e) => {
292
+
eprintln!("Failed to remove repositories: {}", e);
293
+
std::process::exit(1);
294
+
}
295
+
}
296
+
}
297
+
}
298
+
}
299
+
300
+
async fn run_resolve(hostname: &str, password: Option<String>, did: &str, handle_only: bool) {
301
+
let client = TapClient::new(hostname, password);
302
+
303
+
match client.resolve(did).await {
304
+
Ok(doc) => {
305
+
if handle_only {
306
+
// Use the handles() method from atproto_identity::model::Document
307
+
match doc.handles() {
308
+
Some(handle) => println!("{}", handle),
309
+
None => {
310
+
eprintln!("No handle found in DID document");
311
+
std::process::exit(1);
312
+
}
313
+
}
314
+
} else {
315
+
// Print full DID document as JSON
316
+
match serde_json::to_string_pretty(&doc) {
317
+
Ok(json) => println!("{}", json),
318
+
Err(e) => {
319
+
eprintln!("Failed to serialize DID document: {}", e);
320
+
std::process::exit(1);
321
+
}
322
+
}
323
+
}
324
+
}
325
+
Err(e) => {
326
+
eprintln!("Failed to resolve DID: {}", e);
327
+
std::process::exit(1);
328
+
}
329
+
}
330
+
}
331
+
332
+
async fn run_info(hostname: &str, password: Option<String>, did: &str) {
333
+
let client = TapClient::new(hostname, password);
334
+
335
+
match client.info(did).await {
336
+
Ok(info) => {
337
+
// Print as JSON for easy parsing
338
+
match serde_json::to_string_pretty(&info) {
339
+
Ok(json) => println!("{}", json),
340
+
Err(e) => {
341
+
eprintln!("Failed to serialize info: {}", e);
342
+
std::process::exit(1);
343
+
}
344
+
}
345
+
}
346
+
Err(e) => {
347
+
eprintln!("Failed to get repository info: {}", e);
348
+
std::process::exit(1);
349
+
}
350
+
}
351
+
}
+214
crates/atproto-tap/src/bin/atproto-tap-extras.rs
+214
crates/atproto-tap/src/bin/atproto-tap-extras.rs
···
1
+
//! Additional TAP client utilities for AT Protocol.
2
+
//!
3
+
//! This tool provides extra commands for managing TAP tracked repositories
4
+
//! based on social graph data.
5
+
//!
6
+
//! # Usage
7
+
//!
8
+
//! ```bash
9
+
//! # Add all accounts followed by a DID to TAP tracking
10
+
//! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 repos-add-followers did:plc:xyz
11
+
//!
12
+
//! # With authentication
13
+
//! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 -p secret repos-add-followers did:plc:xyz
14
+
//! ```
15
+
16
+
use atproto_client::client::Auth;
17
+
use atproto_client::com::atproto::repo::{ListRecordsParams, list_records};
18
+
use atproto_identity::plc::query as plc_query;
19
+
use atproto_tap::TapClient;
20
+
use clap::{Parser, Subcommand};
21
+
use serde::Deserialize;
22
+
23
+
/// TAP extras utility for managing tracked repositories.
24
+
#[derive(Parser)]
25
+
#[command(
26
+
name = "atproto-tap-extras",
27
+
version,
28
+
about = "TAP extras utility for AT Protocol",
29
+
long_about = "Additional utilities for managing TAP tracked repositories based on social graph data."
30
+
)]
31
+
struct Args {
32
+
/// TAP service hostname (e.g., localhost:2480)
33
+
hostname: String,
34
+
35
+
/// Admin password for TAP authentication
36
+
#[arg(short, long, global = true)]
37
+
password: Option<String>,
38
+
39
+
/// PLC directory hostname for DID resolution
40
+
#[arg(long, default_value = "plc.directory", global = true)]
41
+
plc_hostname: String,
42
+
43
+
#[command(subcommand)]
44
+
command: Command,
45
+
}
46
+
47
+
#[derive(Subcommand)]
48
+
enum Command {
49
+
/// Add accounts followed by a DID to TAP tracking.
50
+
///
51
+
/// Fetches all app.bsky.graph.follow records from the specified DID's repository
52
+
/// and adds the followed DIDs to TAP for tracking.
53
+
ReposAddFollowers {
54
+
/// DID to read followers from (e.g., did:plc:xyz123)
55
+
did: String,
56
+
57
+
/// Batch size for adding repos to TAP
58
+
#[arg(long, default_value = "100")]
59
+
batch_size: usize,
60
+
61
+
/// Dry run - print DIDs without adding to TAP
62
+
#[arg(long)]
63
+
dry_run: bool,
64
+
},
65
+
}
66
+
67
+
/// Follow record structure from app.bsky.graph.follow.
68
+
#[derive(Debug, Deserialize)]
69
+
struct FollowRecord {
70
+
/// The DID of the account being followed.
71
+
subject: String,
72
+
}
73
+
74
+
#[tokio::main]
75
+
async fn main() {
76
+
let args = Args::parse();
77
+
78
+
match args.command {
79
+
Command::ReposAddFollowers {
80
+
did,
81
+
batch_size,
82
+
dry_run,
83
+
} => {
84
+
run_repos_add_followers(
85
+
&args.hostname,
86
+
args.password,
87
+
&args.plc_hostname,
88
+
&did,
89
+
batch_size,
90
+
dry_run,
91
+
)
92
+
.await;
93
+
}
94
+
}
95
+
}
96
+
97
+
async fn run_repos_add_followers(
98
+
tap_hostname: &str,
99
+
tap_password: Option<String>,
100
+
plc_hostname: &str,
101
+
did: &str,
102
+
batch_size: usize,
103
+
dry_run: bool,
104
+
) {
105
+
let http_client = reqwest::Client::new();
106
+
107
+
// Resolve the DID to get the PDS endpoint
108
+
eprintln!("Resolving DID: {}", did);
109
+
let document = match plc_query(&http_client, plc_hostname, did).await {
110
+
Ok(doc) => doc,
111
+
Err(e) => {
112
+
eprintln!("Failed to resolve DID: {}", e);
113
+
std::process::exit(1);
114
+
}
115
+
};
116
+
117
+
let pds_endpoints = document.pds_endpoints();
118
+
if pds_endpoints.is_empty() {
119
+
eprintln!("No PDS endpoint found in DID document");
120
+
std::process::exit(1);
121
+
}
122
+
let pds_url = pds_endpoints[0];
123
+
eprintln!("Using PDS: {}", pds_url);
124
+
125
+
// Collect all followed DIDs
126
+
let mut followed_dids: Vec<String> = Vec::new();
127
+
let mut cursor: Option<String> = None;
128
+
let collection = "app.bsky.graph.follow".to_string();
129
+
130
+
eprintln!("Fetching follow records...");
131
+
132
+
loop {
133
+
let params = if let Some(c) = cursor.take() {
134
+
ListRecordsParams::new().limit(100).cursor(c)
135
+
} else {
136
+
ListRecordsParams::new().limit(100)
137
+
};
138
+
139
+
let response = match list_records::<FollowRecord>(
140
+
&http_client,
141
+
&Auth::None,
142
+
pds_url,
143
+
did.to_string(),
144
+
collection.clone(),
145
+
params,
146
+
)
147
+
.await
148
+
{
149
+
Ok(resp) => resp,
150
+
Err(e) => {
151
+
eprintln!("Failed to list records: {}", e);
152
+
std::process::exit(1);
153
+
}
154
+
};
155
+
156
+
for record in &response.records {
157
+
followed_dids.push(record.value.subject.clone());
158
+
}
159
+
160
+
eprintln!(
161
+
" Fetched {} records (total: {})",
162
+
response.records.len(),
163
+
followed_dids.len()
164
+
);
165
+
166
+
match response.cursor {
167
+
Some(c) if !response.records.is_empty() => {
168
+
cursor = Some(c);
169
+
}
170
+
_ => break,
171
+
}
172
+
}
173
+
174
+
if followed_dids.is_empty() {
175
+
eprintln!("No follow records found");
176
+
return;
177
+
}
178
+
179
+
eprintln!("Found {} followed accounts", followed_dids.len());
180
+
181
+
if dry_run {
182
+
eprintln!("\nDry run - would add these DIDs to TAP:");
183
+
for did in &followed_dids {
184
+
println!("{}", did);
185
+
}
186
+
return;
187
+
}
188
+
189
+
// Add to TAP in batches
190
+
let tap_client = TapClient::new(tap_hostname, tap_password);
191
+
let mut added = 0;
192
+
193
+
for chunk in followed_dids.chunks(batch_size) {
194
+
let did_refs: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
195
+
196
+
match tap_client.add_repos(&did_refs).await {
197
+
Ok(()) => {
198
+
added += chunk.len();
199
+
eprintln!("Added {} DIDs to TAP (total: {})", chunk.len(), added);
200
+
}
201
+
Err(e) => {
202
+
eprintln!("Failed to add repos to TAP: {}", e);
203
+
std::process::exit(1);
204
+
}
205
+
}
206
+
}
207
+
208
+
eprintln!("Successfully added {} DIDs to TAP", added);
209
+
210
+
// Print all added DIDs
211
+
for did in &followed_dids {
212
+
println!("{}", did);
213
+
}
214
+
}
+371
crates/atproto-tap/src/client.rs
+371
crates/atproto-tap/src/client.rs
···
1
+
//! HTTP client for TAP management API.
2
+
//!
3
+
//! This module provides [`TapClient`] for interacting with the TAP service's
4
+
//! HTTP management endpoints, including adding/removing tracked repositories.
5
+
6
+
use crate::errors::TapError;
7
+
use atproto_identity::model::Document;
8
+
use base64::Engine;
9
+
use base64::engine::general_purpose::STANDARD as BASE64;
10
+
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
11
+
use serde::{Deserialize, Serialize};
12
+
13
+
/// HTTP client for TAP management API.
14
+
///
15
+
/// Provides methods for managing which repositories the TAP service tracks,
16
+
/// checking service health, and querying repository status.
17
+
///
18
+
/// # Example
19
+
///
20
+
/// ```ignore
21
+
/// use atproto_tap::TapClient;
22
+
///
23
+
/// let client = TapClient::new("localhost:2480", Some("admin_password".to_string()));
24
+
///
25
+
/// // Add repositories to track
26
+
/// client.add_repos(&["did:plc:xyz123", "did:plc:abc456"]).await?;
27
+
///
28
+
/// // Check health
29
+
/// if client.health().await? {
30
+
/// println!("TAP service is healthy");
31
+
/// }
32
+
/// ```
33
+
#[derive(Debug, Clone)]
34
+
pub struct TapClient {
35
+
http_client: reqwest::Client,
36
+
base_url: String,
37
+
auth_header: Option<HeaderValue>,
38
+
}
39
+
40
+
impl TapClient {
41
+
/// Create a new TAP management client.
42
+
///
43
+
/// # Arguments
44
+
///
45
+
/// * `hostname` - TAP service hostname (e.g., "localhost:2480")
46
+
/// * `admin_password` - Optional admin password for authentication
47
+
pub fn new(hostname: &str, admin_password: Option<String>) -> Self {
48
+
let auth_header = admin_password.map(|password| {
49
+
let credentials = format!("admin:{}", password);
50
+
let encoded = BASE64.encode(credentials.as_bytes());
51
+
HeaderValue::from_str(&format!("Basic {}", encoded))
52
+
.expect("Invalid auth header value")
53
+
});
54
+
55
+
Self {
56
+
http_client: reqwest::Client::new(),
57
+
base_url: format!("http://{}", hostname),
58
+
auth_header,
59
+
}
60
+
}
61
+
62
+
/// Create default headers for requests.
63
+
fn default_headers(&self) -> HeaderMap {
64
+
let mut headers = HeaderMap::new();
65
+
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
66
+
if let Some(auth) = &self.auth_header {
67
+
headers.insert(AUTHORIZATION, auth.clone());
68
+
}
69
+
headers
70
+
}
71
+
72
+
/// Add repositories to track.
73
+
///
74
+
/// Sends a POST request to `/repos/add` with the list of DIDs.
75
+
///
76
+
/// # Arguments
77
+
///
78
+
/// * `dids` - Slice of DID strings to track
79
+
///
80
+
/// # Example
81
+
///
82
+
/// ```ignore
83
+
/// client.add_repos(&[
84
+
/// "did:plc:z72i7hdynmk6r22z27h6tvur",
85
+
/// "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
86
+
/// ]).await?;
87
+
/// ```
88
+
pub async fn add_repos(&self, dids: &[&str]) -> Result<(), TapError> {
89
+
let url = format!("{}/repos/add", self.base_url);
90
+
let body = AddReposRequest {
91
+
dids: dids.iter().map(|s| s.to_string()).collect(),
92
+
};
93
+
94
+
let response = self
95
+
.http_client
96
+
.post(&url)
97
+
.headers(self.default_headers())
98
+
.json(&body)
99
+
.send()
100
+
.await?;
101
+
102
+
if response.status().is_success() {
103
+
tracing::debug!(count = dids.len(), "Added repositories to TAP");
104
+
Ok(())
105
+
} else {
106
+
let status = response.status().as_u16();
107
+
let message = response.text().await.unwrap_or_default();
108
+
Err(TapError::HttpResponseError { status, message })
109
+
}
110
+
}
111
+
112
+
/// Remove repositories from tracking.
113
+
///
114
+
/// Sends a POST request to `/repos/remove` with the list of DIDs.
115
+
///
116
+
/// # Arguments
117
+
///
118
+
/// * `dids` - Slice of DID strings to stop tracking
119
+
pub async fn remove_repos(&self, dids: &[&str]) -> Result<(), TapError> {
120
+
let url = format!("{}/repos/remove", self.base_url);
121
+
let body = AddReposRequest {
122
+
dids: dids.iter().map(|s| s.to_string()).collect(),
123
+
};
124
+
125
+
let response = self
126
+
.http_client
127
+
.post(&url)
128
+
.headers(self.default_headers())
129
+
.json(&body)
130
+
.send()
131
+
.await?;
132
+
133
+
if response.status().is_success() {
134
+
tracing::debug!(count = dids.len(), "Removed repositories from TAP");
135
+
Ok(())
136
+
} else {
137
+
let status = response.status().as_u16();
138
+
let message = response.text().await.unwrap_or_default();
139
+
Err(TapError::HttpResponseError { status, message })
140
+
}
141
+
}
142
+
143
+
/// Check service health.
144
+
///
145
+
/// Sends a GET request to `/health`.
146
+
///
147
+
/// # Returns
148
+
///
149
+
/// `true` if the service is healthy, `false` otherwise.
150
+
pub async fn health(&self) -> Result<bool, TapError> {
151
+
let url = format!("{}/health", self.base_url);
152
+
153
+
let response = self
154
+
.http_client
155
+
.get(&url)
156
+
.headers(self.default_headers())
157
+
.send()
158
+
.await?;
159
+
160
+
Ok(response.status().is_success())
161
+
}
162
+
163
+
/// Resolve a DID to its DID document.
164
+
///
165
+
/// Sends a GET request to `/resolve/:did`.
166
+
///
167
+
/// # Arguments
168
+
///
169
+
/// * `did` - The DID to resolve
170
+
///
171
+
/// # Returns
172
+
///
173
+
/// The DID document for the identity.
174
+
pub async fn resolve(&self, did: &str) -> Result<Document, TapError> {
175
+
let url = format!("{}/resolve/{}", self.base_url, did);
176
+
177
+
let response = self
178
+
.http_client
179
+
.get(&url)
180
+
.headers(self.default_headers())
181
+
.send()
182
+
.await?;
183
+
184
+
if response.status().is_success() {
185
+
let doc: Document = response.json().await?;
186
+
Ok(doc)
187
+
} else {
188
+
let status = response.status().as_u16();
189
+
let message = response.text().await.unwrap_or_default();
190
+
Err(TapError::HttpResponseError { status, message })
191
+
}
192
+
}
193
+
194
+
/// Get info about a tracked repository.
195
+
///
196
+
/// Sends a GET request to `/info/:did`.
197
+
///
198
+
/// # Arguments
199
+
///
200
+
/// * `did` - The DID to get info for
201
+
///
202
+
/// # Returns
203
+
///
204
+
/// Repository tracking information.
205
+
pub async fn info(&self, did: &str) -> Result<RepoInfo, TapError> {
206
+
let url = format!("{}/info/{}", self.base_url, did);
207
+
208
+
let response = self
209
+
.http_client
210
+
.get(&url)
211
+
.headers(self.default_headers())
212
+
.send()
213
+
.await?;
214
+
215
+
if response.status().is_success() {
216
+
let info: RepoInfo = response.json().await?;
217
+
Ok(info)
218
+
} else {
219
+
let status = response.status().as_u16();
220
+
let message = response.text().await.unwrap_or_default();
221
+
Err(TapError::HttpResponseError { status, message })
222
+
}
223
+
}
224
+
}
225
+
226
+
/// Request body for adding/removing repositories.
227
+
#[derive(Debug, Serialize)]
228
+
struct AddReposRequest {
229
+
dids: Vec<String>,
230
+
}
231
+
232
+
/// Repository tracking information.
233
+
#[derive(Debug, Clone, Serialize, Deserialize)]
234
+
pub struct RepoInfo {
235
+
/// The repository DID.
236
+
pub did: Box<str>,
237
+
/// Current sync state.
238
+
pub state: RepoState,
239
+
/// The handle for the repository.
240
+
#[serde(default)]
241
+
pub handle: Option<Box<str>>,
242
+
/// Number of records in the repository.
243
+
#[serde(default)]
244
+
pub records: u64,
245
+
/// Current repository revision.
246
+
#[serde(default)]
247
+
pub rev: Option<Box<str>>,
248
+
/// Number of retries for syncing.
249
+
#[serde(default)]
250
+
pub retries: u32,
251
+
/// Error message if any.
252
+
#[serde(default)]
253
+
pub error: Option<Box<str>>,
254
+
/// Additional fields may be present depending on TAP version.
255
+
#[serde(flatten)]
256
+
pub extra: serde_json::Value,
257
+
}
258
+
259
+
/// Repository sync state.
260
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
261
+
#[serde(rename_all = "lowercase")]
262
+
pub enum RepoState {
263
+
/// Repository is active and synced.
264
+
Active,
265
+
/// Repository is currently syncing.
266
+
Syncing,
267
+
/// Repository is fully synced.
268
+
Synced,
269
+
/// Sync failed for this repository.
270
+
Failed,
271
+
/// Repository is queued for sync.
272
+
Queued,
273
+
/// Unknown state.
274
+
#[serde(other)]
275
+
Unknown,
276
+
}
277
+
278
+
/// Deprecated alias for RepoState.
279
+
#[deprecated(since = "0.13.0", note = "Use RepoState instead")]
280
+
pub type RepoStatus = RepoState;
281
+
282
+
impl std::fmt::Display for RepoState {
283
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284
+
match self {
285
+
RepoState::Active => write!(f, "active"),
286
+
RepoState::Syncing => write!(f, "syncing"),
287
+
RepoState::Synced => write!(f, "synced"),
288
+
RepoState::Failed => write!(f, "failed"),
289
+
RepoState::Queued => write!(f, "queued"),
290
+
RepoState::Unknown => write!(f, "unknown"),
291
+
}
292
+
}
293
+
}
294
+
295
+
#[cfg(test)]
296
+
mod tests {
297
+
use super::*;
298
+
299
+
#[test]
300
+
fn test_client_creation() {
301
+
let client = TapClient::new("localhost:2480", None);
302
+
assert_eq!(client.base_url, "http://localhost:2480");
303
+
assert!(client.auth_header.is_none());
304
+
305
+
let client = TapClient::new("localhost:2480", Some("secret".to_string()));
306
+
assert!(client.auth_header.is_some());
307
+
}
308
+
309
+
#[test]
310
+
fn test_repo_state_display() {
311
+
assert_eq!(RepoState::Active.to_string(), "active");
312
+
assert_eq!(RepoState::Syncing.to_string(), "syncing");
313
+
assert_eq!(RepoState::Synced.to_string(), "synced");
314
+
assert_eq!(RepoState::Failed.to_string(), "failed");
315
+
assert_eq!(RepoState::Queued.to_string(), "queued");
316
+
assert_eq!(RepoState::Unknown.to_string(), "unknown");
317
+
}
318
+
319
+
#[test]
320
+
fn test_repo_state_deserialize() {
321
+
let json = r#""active""#;
322
+
let state: RepoState = serde_json::from_str(json).unwrap();
323
+
assert_eq!(state, RepoState::Active);
324
+
325
+
let json = r#""syncing""#;
326
+
let state: RepoState = serde_json::from_str(json).unwrap();
327
+
assert_eq!(state, RepoState::Syncing);
328
+
329
+
let json = r#""some_new_state""#;
330
+
let state: RepoState = serde_json::from_str(json).unwrap();
331
+
assert_eq!(state, RepoState::Unknown);
332
+
}
333
+
334
+
#[test]
335
+
fn test_repo_info_deserialize() {
336
+
let json = r#"{"did":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","error":"","handle":"ngerakines.me","records":21382,"retries":0,"rev":"3mam4aazabs2m","state":"active"}"#;
337
+
let info: RepoInfo = serde_json::from_str(json).unwrap();
338
+
assert_eq!(&*info.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
339
+
assert_eq!(info.state, RepoState::Active);
340
+
assert_eq!(info.handle.as_deref(), Some("ngerakines.me"));
341
+
assert_eq!(info.records, 21382);
342
+
assert_eq!(info.retries, 0);
343
+
assert_eq!(info.rev.as_deref(), Some("3mam4aazabs2m"));
344
+
// Empty string deserializes as Some("")
345
+
assert_eq!(info.error.as_deref(), Some(""));
346
+
}
347
+
348
+
#[test]
349
+
fn test_repo_info_deserialize_minimal() {
350
+
// Test with only required fields
351
+
let json = r#"{"did":"did:plc:test","state":"syncing"}"#;
352
+
let info: RepoInfo = serde_json::from_str(json).unwrap();
353
+
assert_eq!(&*info.did, "did:plc:test");
354
+
assert_eq!(info.state, RepoState::Syncing);
355
+
assert_eq!(info.handle, None);
356
+
assert_eq!(info.records, 0);
357
+
assert_eq!(info.retries, 0);
358
+
assert_eq!(info.rev, None);
359
+
assert_eq!(info.error, None);
360
+
}
361
+
362
+
#[test]
363
+
fn test_add_repos_request_serialize() {
364
+
let req = AddReposRequest {
365
+
dids: vec!["did:plc:xyz".to_string(), "did:plc:abc".to_string()],
366
+
};
367
+
let json = serde_json::to_string(&req).unwrap();
368
+
assert!(json.contains("dids"));
369
+
assert!(json.contains("did:plc:xyz"));
370
+
}
371
+
}
+220
crates/atproto-tap/src/config.rs
+220
crates/atproto-tap/src/config.rs
···
1
+
//! Configuration for TAP stream connections.
2
+
//!
3
+
//! This module provides the [`TapConfig`] struct for configuring TAP stream
4
+
//! connections, including hostname, authentication, and reconnection behavior.
5
+
6
+
use std::time::Duration;
7
+
8
+
/// Configuration for a TAP stream connection.
9
+
///
10
+
/// Use [`TapConfig::builder()`] for ergonomic construction with defaults.
11
+
///
12
+
/// # Example
13
+
///
14
+
/// ```
15
+
/// use atproto_tap::TapConfig;
16
+
/// use std::time::Duration;
17
+
///
18
+
/// let config = TapConfig::builder()
19
+
/// .hostname("localhost:2480")
20
+
/// .admin_password("secret")
21
+
/// .send_acks(true)
22
+
/// .max_reconnect_attempts(Some(10))
23
+
/// .build();
24
+
/// ```
25
+
#[derive(Debug, Clone)]
26
+
pub struct TapConfig {
27
+
/// TAP service hostname (e.g., "localhost:2480").
28
+
///
29
+
/// The WebSocket URL is constructed as `ws://{hostname}/channel`.
30
+
pub hostname: String,
31
+
32
+
/// Optional admin password for authentication.
33
+
///
34
+
/// If set, HTTP Basic Auth is used with username "admin".
35
+
pub admin_password: Option<String>,
36
+
37
+
/// Whether to send acknowledgments for received messages.
38
+
///
39
+
/// Default: `true`. Set to `false` if the TAP service has acks disabled.
40
+
pub send_acks: bool,
41
+
42
+
/// User-Agent header value for WebSocket connections.
43
+
pub user_agent: String,
44
+
45
+
/// Maximum reconnection attempts before giving up.
46
+
///
47
+
/// `None` means unlimited reconnection attempts (default).
48
+
pub max_reconnect_attempts: Option<u32>,
49
+
50
+
/// Initial delay before first reconnection attempt.
51
+
///
52
+
/// Default: 1 second.
53
+
pub initial_reconnect_delay: Duration,
54
+
55
+
/// Maximum delay between reconnection attempts.
56
+
///
57
+
/// Default: 60 seconds.
58
+
pub max_reconnect_delay: Duration,
59
+
60
+
/// Multiplier for exponential backoff between reconnections.
61
+
///
62
+
/// Default: 2.0 (doubles the delay each attempt).
63
+
pub reconnect_backoff_multiplier: f64,
64
+
}
65
+
66
+
impl Default for TapConfig {
67
+
fn default() -> Self {
68
+
Self {
69
+
hostname: "localhost:2480".to_string(),
70
+
admin_password: None,
71
+
send_acks: true,
72
+
user_agent: format!("atproto-tap/{}", env!("CARGO_PKG_VERSION")),
73
+
max_reconnect_attempts: None,
74
+
initial_reconnect_delay: Duration::from_secs(1),
75
+
max_reconnect_delay: Duration::from_secs(60),
76
+
reconnect_backoff_multiplier: 2.0,
77
+
}
78
+
}
79
+
}
80
+
81
+
impl TapConfig {
82
+
/// Create a new configuration builder with defaults.
83
+
pub fn builder() -> TapConfigBuilder {
84
+
TapConfigBuilder::default()
85
+
}
86
+
87
+
/// Create a minimal configuration for the given hostname.
88
+
pub fn new(hostname: impl Into<String>) -> Self {
89
+
Self {
90
+
hostname: hostname.into(),
91
+
..Default::default()
92
+
}
93
+
}
94
+
95
+
/// Returns the WebSocket URL for the TAP channel.
96
+
pub fn ws_url(&self) -> String {
97
+
format!("ws://{}/channel", self.hostname)
98
+
}
99
+
100
+
/// Returns the HTTP base URL for the TAP management API.
101
+
pub fn http_base_url(&self) -> String {
102
+
format!("http://{}", self.hostname)
103
+
}
104
+
}
105
+
106
+
/// Builder for [`TapConfig`].
107
+
#[derive(Debug, Clone, Default)]
108
+
pub struct TapConfigBuilder {
109
+
config: TapConfig,
110
+
}
111
+
112
+
impl TapConfigBuilder {
113
+
/// Set the TAP service hostname.
114
+
pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
115
+
self.config.hostname = hostname.into();
116
+
self
117
+
}
118
+
119
+
/// Set the admin password for authentication.
120
+
pub fn admin_password(mut self, password: impl Into<String>) -> Self {
121
+
self.config.admin_password = Some(password.into());
122
+
self
123
+
}
124
+
125
+
/// Set whether to send acknowledgments.
126
+
pub fn send_acks(mut self, send_acks: bool) -> Self {
127
+
self.config.send_acks = send_acks;
128
+
self
129
+
}
130
+
131
+
/// Set the User-Agent header value.
132
+
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
133
+
self.config.user_agent = user_agent.into();
134
+
self
135
+
}
136
+
137
+
/// Set the maximum reconnection attempts.
138
+
///
139
+
/// `None` means unlimited attempts.
140
+
pub fn max_reconnect_attempts(mut self, max: Option<u32>) -> Self {
141
+
self.config.max_reconnect_attempts = max;
142
+
self
143
+
}
144
+
145
+
/// Set the initial reconnection delay.
146
+
pub fn initial_reconnect_delay(mut self, delay: Duration) -> Self {
147
+
self.config.initial_reconnect_delay = delay;
148
+
self
149
+
}
150
+
151
+
/// Set the maximum reconnection delay.
152
+
pub fn max_reconnect_delay(mut self, delay: Duration) -> Self {
153
+
self.config.max_reconnect_delay = delay;
154
+
self
155
+
}
156
+
157
+
/// Set the reconnection backoff multiplier.
158
+
pub fn reconnect_backoff_multiplier(mut self, multiplier: f64) -> Self {
159
+
self.config.reconnect_backoff_multiplier = multiplier;
160
+
self
161
+
}
162
+
163
+
/// Build the configuration.
164
+
pub fn build(self) -> TapConfig {
165
+
self.config
166
+
}
167
+
}
168
+
169
+
#[cfg(test)]
170
+
mod tests {
171
+
use super::*;
172
+
173
+
#[test]
174
+
fn test_default_config() {
175
+
let config = TapConfig::default();
176
+
assert_eq!(config.hostname, "localhost:2480");
177
+
assert!(config.admin_password.is_none());
178
+
assert!(config.send_acks);
179
+
assert!(config.max_reconnect_attempts.is_none());
180
+
assert_eq!(config.initial_reconnect_delay, Duration::from_secs(1));
181
+
assert_eq!(config.max_reconnect_delay, Duration::from_secs(60));
182
+
assert!((config.reconnect_backoff_multiplier - 2.0).abs() < f64::EPSILON);
183
+
}
184
+
185
+
#[test]
186
+
fn test_builder() {
187
+
let config = TapConfig::builder()
188
+
.hostname("tap.example.com:2480")
189
+
.admin_password("secret123")
190
+
.send_acks(false)
191
+
.max_reconnect_attempts(Some(5))
192
+
.initial_reconnect_delay(Duration::from_millis(500))
193
+
.max_reconnect_delay(Duration::from_secs(30))
194
+
.reconnect_backoff_multiplier(1.5)
195
+
.build();
196
+
197
+
assert_eq!(config.hostname, "tap.example.com:2480");
198
+
assert_eq!(config.admin_password, Some("secret123".to_string()));
199
+
assert!(!config.send_acks);
200
+
assert_eq!(config.max_reconnect_attempts, Some(5));
201
+
assert_eq!(config.initial_reconnect_delay, Duration::from_millis(500));
202
+
assert_eq!(config.max_reconnect_delay, Duration::from_secs(30));
203
+
assert!((config.reconnect_backoff_multiplier - 1.5).abs() < f64::EPSILON);
204
+
}
205
+
206
+
#[test]
207
+
fn test_ws_url() {
208
+
let config = TapConfig::new("localhost:2480");
209
+
assert_eq!(config.ws_url(), "ws://localhost:2480/channel");
210
+
211
+
let config = TapConfig::new("tap.example.com:8080");
212
+
assert_eq!(config.ws_url(), "ws://tap.example.com:8080/channel");
213
+
}
214
+
215
+
#[test]
216
+
fn test_http_base_url() {
217
+
let config = TapConfig::new("localhost:2480");
218
+
assert_eq!(config.http_base_url(), "http://localhost:2480");
219
+
}
220
+
}
+168
crates/atproto-tap/src/connection.rs
+168
crates/atproto-tap/src/connection.rs
···
1
+
//! WebSocket connection management for TAP streams.
2
+
//!
3
+
//! This module handles the low-level WebSocket connection to a TAP service,
4
+
//! including authentication and message sending/receiving.
5
+
6
+
use crate::config::TapConfig;
7
+
use crate::errors::TapError;
8
+
use base64::Engine;
9
+
use base64::engine::general_purpose::STANDARD as BASE64;
10
+
use futures::{SinkExt, StreamExt};
11
+
use http::Uri;
12
+
use std::str::FromStr;
13
+
use tokio_websockets::{ClientBuilder, Message, WebSocketStream};
14
+
use tokio_websockets::MaybeTlsStream;
15
+
use tokio::net::TcpStream;
16
+
17
+
/// WebSocket connection to a TAP service.
18
+
pub(crate) struct TapConnection {
19
+
/// The underlying WebSocket stream.
20
+
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
21
+
/// Pre-allocated buffer for acknowledgment messages.
22
+
ack_buffer: Vec<u8>,
23
+
}
24
+
25
+
impl TapConnection {
26
+
/// Establish a new WebSocket connection to the TAP service.
27
+
pub async fn connect(config: &TapConfig) -> Result<Self, TapError> {
28
+
let uri = Uri::from_str(&config.ws_url())
29
+
.map_err(|e| TapError::InvalidUrl(e.to_string()))?;
30
+
31
+
let mut builder = ClientBuilder::from_uri(uri);
32
+
33
+
// Add User-Agent header
34
+
builder = builder
35
+
.add_header(
36
+
http::header::USER_AGENT,
37
+
http::HeaderValue::from_str(&config.user_agent)
38
+
.map_err(|e| TapError::ConnectionFailed(format!("Invalid user agent: {}", e)))?,
39
+
)
40
+
.map_err(|e| TapError::ConnectionFailed(format!("Failed to add header: {}", e)))?;
41
+
42
+
// Add Basic Auth header if password is configured
43
+
if let Some(password) = &config.admin_password {
44
+
let credentials = format!("admin:{}", password);
45
+
let encoded = BASE64.encode(credentials.as_bytes());
46
+
let auth_value = format!("Basic {}", encoded);
47
+
48
+
builder = builder
49
+
.add_header(
50
+
http::header::AUTHORIZATION,
51
+
http::HeaderValue::from_str(&auth_value)
52
+
.map_err(|e| TapError::ConnectionFailed(format!("Invalid auth header: {}", e)))?,
53
+
)
54
+
.map_err(|e| TapError::ConnectionFailed(format!("Failed to add auth header: {}", e)))?;
55
+
}
56
+
57
+
// Connect
58
+
let (ws, _response) = builder
59
+
.connect()
60
+
.await
61
+
.map_err(|e| TapError::ConnectionFailed(e.to_string()))?;
62
+
63
+
tracing::debug!(hostname = %config.hostname, "Connected to TAP service");
64
+
65
+
Ok(Self {
66
+
ws,
67
+
ack_buffer: Vec::with_capacity(48), // {"type":"ack","id":18446744073709551615} is 40 bytes max
68
+
})
69
+
}
70
+
71
+
/// Receive the next message from the WebSocket.
72
+
///
73
+
/// Returns `None` if the connection was closed cleanly.
74
+
pub async fn recv(&mut self) -> Result<Option<String>, TapError> {
75
+
match self.ws.next().await {
76
+
Some(Ok(msg)) => {
77
+
if msg.is_text() {
78
+
msg.as_text()
79
+
.map(|s| Some(s.to_string()))
80
+
.ok_or_else(|| TapError::ParseError("Failed to get text from message".into()))
81
+
} else if msg.is_close() {
82
+
tracing::debug!("Received close frame from TAP service");
83
+
Ok(None)
84
+
} else {
85
+
// Ignore ping/pong and binary messages
86
+
tracing::trace!("Received non-text message, ignoring");
87
+
// Recurse to get the next text message
88
+
Box::pin(self.recv()).await
89
+
}
90
+
}
91
+
Some(Err(e)) => Err(TapError::ConnectionFailed(e.to_string())),
92
+
None => {
93
+
tracing::debug!("WebSocket stream ended");
94
+
Ok(None)
95
+
}
96
+
}
97
+
}
98
+
99
+
/// Send an acknowledgment for the given event ID.
100
+
///
101
+
/// Uses a pre-allocated buffer and itoa for allocation-free formatting.
102
+
/// Format: `{"type":"ack","id":12345}`
103
+
pub async fn send_ack(&mut self, id: u64) -> Result<(), TapError> {
104
+
self.ack_buffer.clear();
105
+
self.ack_buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":");
106
+
let mut itoa_buf = itoa::Buffer::new();
107
+
self.ack_buffer.extend_from_slice(itoa_buf.format(id).as_bytes());
108
+
self.ack_buffer.push(b'}');
109
+
110
+
// All bytes are ASCII so this is always valid UTF-8
111
+
let msg = std::str::from_utf8(&self.ack_buffer)
112
+
.expect("ack buffer contains only ASCII");
113
+
114
+
self.ws
115
+
.send(Message::text(msg.to_string()))
116
+
.await
117
+
.map_err(|e| TapError::AckFailed(e.to_string()))?;
118
+
119
+
// Flush to ensure the ack is sent immediately
120
+
self.ws
121
+
.flush()
122
+
.await
123
+
.map_err(|e| TapError::AckFailed(format!("Failed to flush ack: {}", e)))?;
124
+
125
+
tracing::trace!(id, "Sent ack");
126
+
Ok(())
127
+
}
128
+
129
+
/// Close the WebSocket connection gracefully.
130
+
pub async fn close(&mut self) -> Result<(), TapError> {
131
+
self.ws
132
+
.close()
133
+
.await
134
+
.map_err(|e| TapError::ConnectionFailed(format!("Failed to close: {}", e)))?;
135
+
Ok(())
136
+
}
137
+
}
138
+
139
+
#[cfg(test)]
140
+
mod tests {
141
+
#[test]
142
+
fn test_ack_buffer_format() {
143
+
// Test that our manual JSON formatting is correct
144
+
// Format: {"type":"ack","id":12345}
145
+
let mut buffer = Vec::with_capacity(64);
146
+
147
+
let id: u64 = 12345;
148
+
buffer.clear();
149
+
buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":");
150
+
let mut itoa_buf = itoa::Buffer::new();
151
+
buffer.extend_from_slice(itoa_buf.format(id).as_bytes());
152
+
buffer.push(b'}');
153
+
154
+
let result = std::str::from_utf8(&buffer).unwrap();
155
+
assert_eq!(result, r#"{"type":"ack","id":12345}"#);
156
+
157
+
// Test max u64
158
+
let id: u64 = u64::MAX;
159
+
buffer.clear();
160
+
buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":");
161
+
buffer.extend_from_slice(itoa_buf.format(id).as_bytes());
162
+
buffer.push(b'}');
163
+
164
+
let result = std::str::from_utf8(&buffer).unwrap();
165
+
assert_eq!(result, r#"{"type":"ack","id":18446744073709551615}"#);
166
+
assert!(buffer.len() <= 64); // Fits in our pre-allocated buffer
167
+
}
168
+
}
+143
crates/atproto-tap/src/errors.rs
+143
crates/atproto-tap/src/errors.rs
···
1
+
//! Error types for TAP operations.
2
+
//!
3
+
//! This module defines the error types returned by TAP stream and client operations.
4
+
5
+
use thiserror::Error;
6
+
7
+
/// Errors that can occur during TAP operations.
8
+
#[derive(Debug, Error)]
9
+
pub enum TapError {
10
+
/// WebSocket connection failed.
11
+
#[error("error-atproto-tap-connection-1 WebSocket connection failed: {0}")]
12
+
ConnectionFailed(String),
13
+
14
+
/// Connection was closed unexpectedly.
15
+
#[error("error-atproto-tap-connection-2 Connection closed unexpectedly")]
16
+
ConnectionClosed,
17
+
18
+
/// Maximum reconnection attempts exceeded.
19
+
#[error("error-atproto-tap-connection-3 Maximum reconnection attempts exceeded after {0} attempts")]
20
+
MaxReconnectAttemptsExceeded(u32),
21
+
22
+
/// Authentication failed.
23
+
#[error("error-atproto-tap-auth-1 Authentication failed: {0}")]
24
+
AuthenticationFailed(String),
25
+
26
+
/// Failed to parse a message from the server.
27
+
#[error("error-atproto-tap-parse-1 Failed to parse message: {0}")]
28
+
ParseError(String),
29
+
30
+
/// Failed to send an acknowledgment.
31
+
#[error("error-atproto-tap-ack-1 Failed to send acknowledgment: {0}")]
32
+
AckFailed(String),
33
+
34
+
/// HTTP request failed.
35
+
#[error("error-atproto-tap-http-1 HTTP request failed: {0}")]
36
+
HttpError(String),
37
+
38
+
/// HTTP response indicated an error.
39
+
#[error("error-atproto-tap-http-2 HTTP error response: {status} - {message}")]
40
+
HttpResponseError {
41
+
/// HTTP status code.
42
+
status: u16,
43
+
/// Error message from response.
44
+
message: String,
45
+
},
46
+
47
+
/// Invalid URL.
48
+
#[error("error-atproto-tap-url-1 Invalid URL: {0}")]
49
+
InvalidUrl(String),
50
+
51
+
/// I/O error.
52
+
#[error("error-atproto-tap-io-1 I/O error: {0}")]
53
+
IoError(#[from] std::io::Error),
54
+
55
+
/// JSON serialization/deserialization error.
56
+
#[error("error-atproto-tap-json-1 JSON error: {0}")]
57
+
JsonError(#[from] serde_json::Error),
58
+
59
+
/// Stream has been closed and cannot be used.
60
+
#[error("error-atproto-tap-stream-1 Stream is closed")]
61
+
StreamClosed,
62
+
63
+
/// Operation timed out.
64
+
#[error("error-atproto-tap-timeout-1 Operation timed out")]
65
+
Timeout,
66
+
}
67
+
68
+
impl TapError {
69
+
/// Returns true if this error indicates a connection issue that may be recoverable.
70
+
pub fn is_connection_error(&self) -> bool {
71
+
matches!(
72
+
self,
73
+
TapError::ConnectionFailed(_)
74
+
| TapError::ConnectionClosed
75
+
| TapError::IoError(_)
76
+
| TapError::Timeout
77
+
)
78
+
}
79
+
80
+
/// Returns true if this error is a parse error that doesn't affect connection state.
81
+
pub fn is_parse_error(&self) -> bool {
82
+
matches!(self, TapError::ParseError(_) | TapError::JsonError(_))
83
+
}
84
+
85
+
/// Returns true if this error is fatal and the stream should not attempt recovery.
86
+
pub fn is_fatal(&self) -> bool {
87
+
matches!(
88
+
self,
89
+
TapError::MaxReconnectAttemptsExceeded(_)
90
+
| TapError::AuthenticationFailed(_)
91
+
| TapError::StreamClosed
92
+
)
93
+
}
94
+
}
95
+
96
+
impl From<reqwest::Error> for TapError {
97
+
fn from(err: reqwest::Error) -> Self {
98
+
if err.is_timeout() {
99
+
TapError::Timeout
100
+
} else if err.is_connect() {
101
+
TapError::ConnectionFailed(err.to_string())
102
+
} else {
103
+
TapError::HttpError(err.to_string())
104
+
}
105
+
}
106
+
}
107
+
108
+
#[cfg(test)]
109
+
mod tests {
110
+
use super::*;
111
+
112
+
#[test]
113
+
fn test_error_classification() {
114
+
assert!(TapError::ConnectionFailed("test".into()).is_connection_error());
115
+
assert!(TapError::ConnectionClosed.is_connection_error());
116
+
assert!(TapError::Timeout.is_connection_error());
117
+
118
+
assert!(TapError::ParseError("test".into()).is_parse_error());
119
+
assert!(TapError::JsonError(serde_json::from_str::<()>("invalid").unwrap_err()).is_parse_error());
120
+
121
+
assert!(TapError::MaxReconnectAttemptsExceeded(5).is_fatal());
122
+
assert!(TapError::AuthenticationFailed("test".into()).is_fatal());
123
+
assert!(TapError::StreamClosed.is_fatal());
124
+
125
+
// Non-fatal errors
126
+
assert!(!TapError::ConnectionFailed("test".into()).is_fatal());
127
+
assert!(!TapError::ParseError("test".into()).is_fatal());
128
+
}
129
+
130
+
#[test]
131
+
fn test_error_display() {
132
+
let err = TapError::ConnectionFailed("refused".to_string());
133
+
assert!(err.to_string().contains("error-atproto-tap-connection-1"));
134
+
assert!(err.to_string().contains("refused"));
135
+
136
+
let err = TapError::HttpResponseError {
137
+
status: 404,
138
+
message: "Not Found".to_string(),
139
+
};
140
+
assert!(err.to_string().contains("404"));
141
+
assert!(err.to_string().contains("Not Found"));
142
+
}
143
+
}
+488
crates/atproto-tap/src/events.rs
+488
crates/atproto-tap/src/events.rs
···
1
+
//! TAP event types for AT Protocol record and identity events.
2
+
//!
3
+
//! This module defines the event structures received from a TAP service.
4
+
//! Events are optimized for memory efficiency using:
5
+
//! - `CompactString` for small strings (SSO for โค24 bytes)
6
+
//! - `Box<str>` for immutable strings (no capacity overhead)
7
+
//! - `serde_json::Value` for record payloads (allows lazy access)
8
+
9
+
use compact_str::CompactString;
10
+
use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor};
11
+
use serde::{Deserialize, Serialize, de::DeserializeOwned};
12
+
use std::fmt;
13
+
14
+
/// A TAP event received from the stream.
15
+
///
16
+
/// TAP delivers two types of events:
17
+
/// - `Record`: Repository record changes (create, update, delete)
18
+
/// - `Identity`: Identity/handle changes for accounts
19
+
#[derive(Debug, Clone, Serialize, Deserialize)]
20
+
#[serde(tag = "type", rename_all = "lowercase")]
21
+
pub enum TapEvent {
22
+
/// A repository record event (create, update, or delete).
23
+
Record {
24
+
/// Sequential event identifier.
25
+
id: u64,
26
+
/// The record event data.
27
+
record: RecordEvent,
28
+
},
29
+
/// An identity change event.
30
+
Identity {
31
+
/// Sequential event identifier.
32
+
id: u64,
33
+
/// The identity event data.
34
+
identity: IdentityEvent,
35
+
},
36
+
}
37
+
38
+
impl TapEvent {
39
+
/// Returns the event ID.
40
+
pub fn id(&self) -> u64 {
41
+
match self {
42
+
TapEvent::Record { id, .. } => *id,
43
+
TapEvent::Identity { id, .. } => *id,
44
+
}
45
+
}
46
+
}
47
+
48
+
/// Extract only the event ID from a JSON string without fully parsing it.
49
+
///
50
+
/// This is a fallback parser used when full `TapEvent` parsing fails (e.g., due to
51
+
/// deeply nested records hitting serde_json's recursion limit). It uses `IgnoredAny`
52
+
/// to efficiently skip over nested content without building data structures, allowing
53
+
/// us to extract the ID for acknowledgment even when full parsing fails.
54
+
///
55
+
/// # Example
56
+
///
57
+
/// ```
58
+
/// use atproto_tap::extract_event_id;
59
+
///
60
+
/// let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#;
61
+
/// assert_eq!(extract_event_id(json), Some(12345));
62
+
/// ```
63
+
pub fn extract_event_id(json: &str) -> Option<u64> {
64
+
let mut deserializer = serde_json::Deserializer::from_str(json);
65
+
deserializer.disable_recursion_limit();
66
+
EventIdOnly::deserialize(&mut deserializer).ok().map(|e| e.id)
67
+
}
68
+
69
+
/// Internal struct for extracting only the "id" field from a TAP event.
70
+
#[derive(Debug)]
71
+
struct EventIdOnly {
72
+
id: u64,
73
+
}
74
+
75
+
impl<'de> Deserialize<'de> for EventIdOnly {
76
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77
+
where
78
+
D: Deserializer<'de>,
79
+
{
80
+
deserializer.deserialize_map(EventIdOnlyVisitor)
81
+
}
82
+
}
83
+
84
+
struct EventIdOnlyVisitor;
85
+
86
+
impl<'de> Visitor<'de> for EventIdOnlyVisitor {
87
+
type Value = EventIdOnly;
88
+
89
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
90
+
formatter.write_str("a map with an 'id' field")
91
+
}
92
+
93
+
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
94
+
where
95
+
M: MapAccess<'de>,
96
+
{
97
+
let mut id: Option<u64> = None;
98
+
99
+
while let Some(key) = map.next_key::<&str>()? {
100
+
if key == "id" {
101
+
id = Some(map.next_value()?);
102
+
// Found what we need - skip the rest efficiently using IgnoredAny
103
+
// which handles deeply nested structures without recursion issues
104
+
while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
105
+
break;
106
+
} else {
107
+
// Skip this value without fully parsing it
108
+
map.next_value::<IgnoredAny>()?;
109
+
}
110
+
}
111
+
112
+
id.map(|id| EventIdOnly { id })
113
+
.ok_or_else(|| de::Error::missing_field("id"))
114
+
}
115
+
}
116
+
117
+
/// A repository record event from TAP.
118
+
///
119
+
/// Contains information about a record change in a user's repository,
120
+
/// including the action taken and the record data (for creates/updates).
121
+
#[derive(Debug, Clone, Serialize, Deserialize)]
122
+
pub struct RecordEvent {
123
+
/// True if from live firehose, false if from backfill/resync.
124
+
///
125
+
/// During initial sync or recovery, TAP delivers historical events
126
+
/// with `live: false`. Once caught up, live events have `live: true`.
127
+
pub live: bool,
128
+
129
+
/// Repository revision identifier.
130
+
///
131
+
/// Typically 13 characters, stored inline via CompactString SSO.
132
+
pub rev: CompactString,
133
+
134
+
/// Actor DID (e.g., "did:plc:xyz123").
135
+
pub did: Box<str>,
136
+
137
+
/// Collection NSID (e.g., "app.bsky.feed.post").
138
+
pub collection: Box<str>,
139
+
140
+
/// Record key within the collection.
141
+
///
142
+
/// Typically a TID (13 characters), stored inline via CompactString SSO.
143
+
pub rkey: CompactString,
144
+
145
+
/// The action performed on the record.
146
+
pub action: RecordAction,
147
+
148
+
/// Content identifier (CID) of the record.
149
+
///
150
+
/// Present for create and update actions, absent for delete.
151
+
#[serde(skip_serializing_if = "Option::is_none")]
152
+
pub cid: Option<CompactString>,
153
+
154
+
/// Record data as JSON value.
155
+
///
156
+
/// Present for create and update actions, absent for delete.
157
+
/// Use [`parse_record`](Self::parse_record) to deserialize on demand.
158
+
#[serde(skip_serializing_if = "Option::is_none")]
159
+
pub record: Option<serde_json::Value>,
160
+
}
161
+
162
+
impl RecordEvent {
163
+
/// Parse the record payload into a typed structure.
164
+
///
165
+
/// This method deserializes the raw JSON on demand, avoiding
166
+
/// unnecessary allocations when the record data isn't needed.
167
+
///
168
+
/// # Errors
169
+
///
170
+
/// Returns an error if the record is absent (delete events) or
171
+
/// if deserialization fails.
172
+
///
173
+
/// # Example
174
+
///
175
+
/// ```ignore
176
+
/// use serde::Deserialize;
177
+
///
178
+
/// #[derive(Deserialize)]
179
+
/// struct Post {
180
+
/// text: String,
181
+
/// #[serde(rename = "createdAt")]
182
+
/// created_at: String,
183
+
/// }
184
+
///
185
+
/// let post: Post = record_event.parse_record()?;
186
+
/// println!("Post text: {}", post.text);
187
+
/// ```
188
+
pub fn parse_record<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
189
+
match &self.record {
190
+
Some(value) => serde_json::from_value(value.clone()),
191
+
None => Err(serde::de::Error::custom("no record data (delete event)")),
192
+
}
193
+
}
194
+
195
+
/// Returns the record as a JSON Value reference, if present.
196
+
pub fn record_value(&self) -> Option<&serde_json::Value> {
197
+
self.record.as_ref()
198
+
}
199
+
200
+
/// Returns true if this is a delete event.
201
+
pub fn is_delete(&self) -> bool {
202
+
self.action == RecordAction::Delete
203
+
}
204
+
205
+
/// Returns the AT-URI for this record.
206
+
///
207
+
/// Format: `at://{did}/{collection}/{rkey}`
208
+
pub fn at_uri(&self) -> String {
209
+
format!("at://{}/{}/{}", self.did, self.collection, self.rkey)
210
+
}
211
+
}
212
+
213
+
/// The action performed on a record.
214
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
215
+
#[serde(rename_all = "lowercase")]
216
+
pub enum RecordAction {
217
+
/// A new record was created.
218
+
Create,
219
+
/// An existing record was updated.
220
+
Update,
221
+
/// A record was deleted.
222
+
Delete,
223
+
}
224
+
225
+
impl std::fmt::Display for RecordAction {
226
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227
+
match self {
228
+
RecordAction::Create => write!(f, "create"),
229
+
RecordAction::Update => write!(f, "update"),
230
+
RecordAction::Delete => write!(f, "delete"),
231
+
}
232
+
}
233
+
}
234
+
235
+
/// An identity change event from TAP.
236
+
///
237
+
/// Contains information about handle or account status changes.
238
+
#[derive(Debug, Clone, Serialize, Deserialize)]
239
+
pub struct IdentityEvent {
240
+
/// Actor DID.
241
+
pub did: Box<str>,
242
+
243
+
/// Current handle for the account.
244
+
pub handle: Box<str>,
245
+
246
+
/// Whether the account is currently active.
247
+
#[serde(default)]
248
+
pub is_active: bool,
249
+
250
+
/// Account status.
251
+
#[serde(default)]
252
+
pub status: IdentityStatus,
253
+
}
254
+
255
+
/// Account status in an identity event.
256
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
257
+
#[serde(rename_all = "lowercase")]
258
+
pub enum IdentityStatus {
259
+
/// Account is active and in good standing.
260
+
#[default]
261
+
Active,
262
+
/// Account has been deactivated by the user.
263
+
Deactivated,
264
+
/// Account has been suspended.
265
+
Suspended,
266
+
/// Account has been deleted.
267
+
Deleted,
268
+
/// Account has been taken down.
269
+
Takendown,
270
+
}
271
+
272
+
impl std::fmt::Display for IdentityStatus {
273
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274
+
match self {
275
+
IdentityStatus::Active => write!(f, "active"),
276
+
IdentityStatus::Deactivated => write!(f, "deactivated"),
277
+
IdentityStatus::Suspended => write!(f, "suspended"),
278
+
IdentityStatus::Deleted => write!(f, "deleted"),
279
+
IdentityStatus::Takendown => write!(f, "takendown"),
280
+
}
281
+
}
282
+
}
283
+
284
+
#[cfg(test)]
285
+
mod tests {
286
+
use super::*;
287
+
288
+
#[test]
289
+
fn test_parse_record_event() {
290
+
let json = r#"{
291
+
"id": 12345,
292
+
"type": "record",
293
+
"record": {
294
+
"live": true,
295
+
"rev": "3lyileto4q52k",
296
+
"did": "did:plc:z72i7hdynmk6r22z27h6tvur",
297
+
"collection": "app.bsky.feed.post",
298
+
"rkey": "3lyiletddxt2c",
299
+
"action": "create",
300
+
"cid": "bafyreigroo6vhxt62ufcndhaxzas6btq4jmniuz4egszbwuqgiyisqwqoy",
301
+
"record": {"$type": "app.bsky.feed.post", "text": "Hello world!", "createdAt": "2025-01-01T00:00:00Z"}
302
+
}
303
+
}"#;
304
+
305
+
let event: TapEvent = serde_json::from_str(json).expect("Failed to parse");
306
+
307
+
match event {
308
+
TapEvent::Record { id, record } => {
309
+
assert_eq!(id, 12345);
310
+
assert!(record.live);
311
+
assert_eq!(record.rev.as_str(), "3lyileto4q52k");
312
+
assert_eq!(&*record.did, "did:plc:z72i7hdynmk6r22z27h6tvur");
313
+
assert_eq!(&*record.collection, "app.bsky.feed.post");
314
+
assert_eq!(record.rkey.as_str(), "3lyiletddxt2c");
315
+
assert_eq!(record.action, RecordAction::Create);
316
+
assert!(record.cid.is_some());
317
+
assert!(record.record.is_some());
318
+
319
+
// Test lazy parsing
320
+
#[derive(Deserialize)]
321
+
struct Post {
322
+
text: String,
323
+
}
324
+
let post: Post = record.parse_record().expect("Failed to parse record");
325
+
assert_eq!(post.text, "Hello world!");
326
+
}
327
+
_ => panic!("Expected Record event"),
328
+
}
329
+
}
330
+
331
+
#[test]
332
+
fn test_parse_delete_event() {
333
+
let json = r#"{
334
+
"id": 12346,
335
+
"type": "record",
336
+
"record": {
337
+
"live": true,
338
+
"rev": "3lyileto4q52k",
339
+
"did": "did:plc:z72i7hdynmk6r22z27h6tvur",
340
+
"collection": "app.bsky.feed.post",
341
+
"rkey": "3lyiletddxt2c",
342
+
"action": "delete"
343
+
}
344
+
}"#;
345
+
346
+
let event: TapEvent = serde_json::from_str(json).expect("Failed to parse");
347
+
348
+
match event {
349
+
TapEvent::Record { id, record } => {
350
+
assert_eq!(id, 12346);
351
+
assert_eq!(record.action, RecordAction::Delete);
352
+
assert!(record.is_delete());
353
+
assert!(record.cid.is_none());
354
+
assert!(record.record.is_none());
355
+
}
356
+
_ => panic!("Expected Record event"),
357
+
}
358
+
}
359
+
360
+
#[test]
361
+
fn test_parse_identity_event() {
362
+
let json = r#"{
363
+
"id": 12347,
364
+
"type": "identity",
365
+
"identity": {
366
+
"did": "did:plc:z72i7hdynmk6r22z27h6tvur",
367
+
"handle": "user.bsky.social",
368
+
"is_active": true,
369
+
"status": "active"
370
+
}
371
+
}"#;
372
+
373
+
let event: TapEvent = serde_json::from_str(json).expect("Failed to parse");
374
+
375
+
match event {
376
+
TapEvent::Identity { id, identity } => {
377
+
assert_eq!(id, 12347);
378
+
assert_eq!(&*identity.did, "did:plc:z72i7hdynmk6r22z27h6tvur");
379
+
assert_eq!(&*identity.handle, "user.bsky.social");
380
+
assert!(identity.is_active);
381
+
assert_eq!(identity.status, IdentityStatus::Active);
382
+
}
383
+
_ => panic!("Expected Identity event"),
384
+
}
385
+
}
386
+
387
+
#[test]
388
+
fn test_record_action_display() {
389
+
assert_eq!(RecordAction::Create.to_string(), "create");
390
+
assert_eq!(RecordAction::Update.to_string(), "update");
391
+
assert_eq!(RecordAction::Delete.to_string(), "delete");
392
+
}
393
+
394
+
#[test]
395
+
fn test_identity_status_display() {
396
+
assert_eq!(IdentityStatus::Active.to_string(), "active");
397
+
assert_eq!(IdentityStatus::Deactivated.to_string(), "deactivated");
398
+
assert_eq!(IdentityStatus::Suspended.to_string(), "suspended");
399
+
assert_eq!(IdentityStatus::Deleted.to_string(), "deleted");
400
+
assert_eq!(IdentityStatus::Takendown.to_string(), "takendown");
401
+
}
402
+
403
+
#[test]
404
+
fn test_at_uri() {
405
+
let record = RecordEvent {
406
+
live: true,
407
+
rev: "3lyileto4q52k".into(),
408
+
did: "did:plc:xyz".into(),
409
+
collection: "app.bsky.feed.post".into(),
410
+
rkey: "abc123".into(),
411
+
action: RecordAction::Create,
412
+
cid: None,
413
+
record: None,
414
+
};
415
+
416
+
assert_eq!(record.at_uri(), "at://did:plc:xyz/app.bsky.feed.post/abc123");
417
+
}
418
+
419
+
#[test]
420
+
fn test_event_id() {
421
+
let record_event = TapEvent::Record {
422
+
id: 100,
423
+
record: RecordEvent {
424
+
live: true,
425
+
rev: "rev".into(),
426
+
did: "did".into(),
427
+
collection: "col".into(),
428
+
rkey: "rkey".into(),
429
+
action: RecordAction::Create,
430
+
cid: None,
431
+
record: None,
432
+
},
433
+
};
434
+
assert_eq!(record_event.id(), 100);
435
+
436
+
let identity_event = TapEvent::Identity {
437
+
id: 200,
438
+
identity: IdentityEvent {
439
+
did: "did".into(),
440
+
handle: "handle".into(),
441
+
is_active: true,
442
+
status: IdentityStatus::Active,
443
+
},
444
+
};
445
+
assert_eq!(identity_event.id(), 200);
446
+
}
447
+
448
+
#[test]
449
+
fn test_extract_event_id_simple() {
450
+
let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#;
451
+
assert_eq!(extract_event_id(json), Some(12345));
452
+
}
453
+
454
+
#[test]
455
+
fn test_extract_event_id_at_end() {
456
+
let json = r#"{"type":"record","record":{"deeply":"nested"},"id":99999}"#;
457
+
assert_eq!(extract_event_id(json), Some(99999));
458
+
}
459
+
460
+
#[test]
461
+
fn test_extract_event_id_missing() {
462
+
let json = r#"{"type":"record","record":{"deeply":"nested"}}"#;
463
+
assert_eq!(extract_event_id(json), None);
464
+
}
465
+
466
+
#[test]
467
+
fn test_extract_event_id_invalid_json() {
468
+
let json = r#"{"type":"record","id":123"#; // Truncated JSON
469
+
assert_eq!(extract_event_id(json), None);
470
+
}
471
+
472
+
#[test]
473
+
fn test_extract_event_id_deeply_nested() {
474
+
// Create a deeply nested JSON that would exceed serde_json's default recursion limit
475
+
let mut json = String::from(r#"{"id":42,"record":{"nested":"#);
476
+
for _ in 0..200 {
477
+
json.push_str("[");
478
+
}
479
+
json.push_str("1");
480
+
for _ in 0..200 {
481
+
json.push_str("]");
482
+
}
483
+
json.push_str("}}");
484
+
485
+
// extract_event_id should still work because it uses IgnoredAny with disabled recursion limit
486
+
assert_eq!(extract_event_id(&json), Some(42));
487
+
}
488
+
}
+119
crates/atproto-tap/src/lib.rs
+119
crates/atproto-tap/src/lib.rs
···
1
+
//! TAP (Trusted Attestation Protocol) service consumer for AT Protocol.
2
+
//!
3
+
//! This crate provides a client for consuming events from a TAP service,
4
+
//! which delivers filtered, verified AT Protocol repository events.
5
+
//!
6
+
//! # Overview
7
+
//!
8
+
//! TAP is a single-tenant service that subscribes to an AT Protocol Relay and
9
+
//! outputs filtered, verified events. Key features include:
10
+
//!
11
+
//! - **Verified Events**: MST integrity checks and signature verification
12
+
//! - **Automatic Backfill**: Historical events delivered with `live: false`
13
+
//! - **Repository Filtering**: Track specific DIDs or collections
14
+
//! - **Acknowledgment Protocol**: At-least-once delivery semantics
15
+
//!
16
+
//! # Quick Start
17
+
//!
18
+
//! ```ignore
19
+
//! use atproto_tap::{connect_to, TapEvent};
20
+
//! use tokio_stream::StreamExt;
21
+
//!
22
+
//! #[tokio::main]
23
+
//! async fn main() {
24
+
//! let mut stream = connect_to("localhost:2480");
25
+
//!
26
+
//! while let Some(result) = stream.next().await {
27
+
//! match result {
28
+
//! Ok(event) => match event.as_ref() {
29
+
//! TapEvent::Record { record, .. } => {
30
+
//! println!("{} {} {}", record.action, record.collection, record.did);
31
+
//! }
32
+
//! TapEvent::Identity { identity, .. } => {
33
+
//! println!("Identity: {} = {}", identity.did, identity.handle);
34
+
//! }
35
+
//! },
36
+
//! Err(e) => eprintln!("Error: {}", e),
37
+
//! }
38
+
//! }
39
+
//! }
40
+
//! ```
41
+
//!
42
+
//! # Using with `tokio::select!`
43
+
//!
44
+
//! The stream integrates naturally with Tokio's select macro:
45
+
//!
46
+
//! ```ignore
47
+
//! use atproto_tap::{connect, TapConfig};
48
+
//! use tokio_stream::StreamExt;
49
+
//! use tokio::signal;
50
+
//!
51
+
//! #[tokio::main]
52
+
//! async fn main() {
53
+
//! let config = TapConfig::builder()
54
+
//! .hostname("localhost:2480")
55
+
//! .admin_password("secret")
56
+
//! .build();
57
+
//!
58
+
//! let mut stream = connect(config);
59
+
//!
60
+
//! loop {
61
+
//! tokio::select! {
62
+
//! Some(result) = stream.next() => {
63
+
//! // Process event
64
+
//! }
65
+
//! _ = signal::ctrl_c() => {
66
+
//! break;
67
+
//! }
68
+
//! }
69
+
//! }
70
+
//! }
71
+
//! ```
72
+
//!
73
+
//! # Management API
74
+
//!
75
+
//! Use [`TapClient`] to manage tracked repositories:
76
+
//!
77
+
//! ```ignore
78
+
//! use atproto_tap::TapClient;
79
+
//!
80
+
//! let client = TapClient::new("localhost:2480", Some("password".to_string()));
81
+
//!
82
+
//! // Add repositories to track
83
+
//! client.add_repos(&["did:plc:xyz123"]).await?;
84
+
//!
85
+
//! // Check service health
86
+
//! if client.health().await? {
87
+
//! println!("TAP service is healthy");
88
+
//! }
89
+
//! ```
90
+
//!
91
+
//! # Memory Efficiency
92
+
//!
93
+
//! This crate is optimized for high-throughput event processing:
94
+
//!
95
+
//! - **Arc-wrapped events**: Events are shared via `Arc` for zero-cost sharing
96
+
//! - **CompactString**: Small strings use inline storage (no heap allocation)
97
+
//! - **Box<str>**: Immutable strings without capacity overhead
98
+
//! - **RawValue**: Record payloads are lazily parsed on demand
99
+
//! - **Pre-allocated buffers**: Ack messages avoid per-message allocations
100
+
101
+
#![forbid(unsafe_code)]
102
+
#![warn(missing_docs)]
103
+
104
+
mod client;
105
+
mod config;
106
+
mod connection;
107
+
mod errors;
108
+
mod events;
109
+
mod stream;
110
+
111
+
// Re-export public types
112
+
pub use atproto_identity::model::{Document, Service, VerificationMethod};
113
+
pub use client::{RepoInfo, RepoState, TapClient};
114
+
#[allow(deprecated)]
115
+
pub use client::RepoStatus;
116
+
pub use config::{TapConfig, TapConfigBuilder};
117
+
pub use errors::TapError;
118
+
pub use events::{IdentityEvent, IdentityStatus, RecordAction, RecordEvent, TapEvent, extract_event_id};
119
+
pub use stream::{TapStream, connect, connect_to};
+330
crates/atproto-tap/src/stream.rs
+330
crates/atproto-tap/src/stream.rs
···
1
+
//! TAP event stream implementation.
2
+
//!
3
+
//! This module provides [`TapStream`], an async stream that yields TAP events
4
+
//! with automatic connection management and reconnection handling.
5
+
//!
6
+
//! # Design
7
+
//!
8
+
//! The stream encapsulates all connection logic, allowing consumers to simply
9
+
//! iterate over events using standard stream combinators or `tokio::select!`.
10
+
//!
11
+
//! Reconnection is handled automatically with exponential backoff. Parse errors
12
+
//! are yielded as `Err` items but don't affect connection state - only connection
13
+
//! errors trigger reconnection attempts.
14
+
15
+
use crate::config::TapConfig;
16
+
use crate::connection::TapConnection;
17
+
use crate::errors::TapError;
18
+
use crate::events::{TapEvent, extract_event_id};
19
+
use futures::Stream;
20
+
use std::pin::Pin;
21
+
use std::sync::Arc;
22
+
use std::task::{Context, Poll};
23
+
use std::time::Duration;
24
+
use tokio::sync::mpsc;
25
+
26
+
/// An async stream of TAP events with automatic reconnection.
27
+
///
28
+
/// `TapStream` implements [`Stream`] and yields `Result<Arc<TapEvent>, TapError>`.
29
+
/// Events are wrapped in `Arc` for efficient zero-cost sharing across consumers.
30
+
///
31
+
/// # Connection Management
32
+
///
33
+
/// The stream automatically:
34
+
/// - Connects on first poll
35
+
/// - Reconnects with exponential backoff on connection errors
36
+
/// - Sends acknowledgments after parsing each message (if enabled)
37
+
/// - Yields parse errors without affecting connection state
38
+
///
39
+
/// # Example
40
+
///
41
+
/// ```ignore
42
+
/// use atproto_tap::{TapConfig, TapStream};
43
+
/// use tokio_stream::StreamExt;
44
+
///
45
+
/// let config = TapConfig::builder()
46
+
/// .hostname("localhost:2480")
47
+
/// .build();
48
+
///
49
+
/// let mut stream = TapStream::new(config);
50
+
///
51
+
/// while let Some(result) = stream.next().await {
52
+
/// match result {
53
+
/// Ok(event) => println!("Event: {:?}", event),
54
+
/// Err(e) => eprintln!("Error: {}", e),
55
+
/// }
56
+
/// }
57
+
/// ```
58
+
pub struct TapStream {
59
+
/// Receiver for events from the background task.
60
+
receiver: mpsc::Receiver<Result<Arc<TapEvent>, TapError>>,
61
+
/// Handle to request stream closure.
62
+
close_sender: Option<mpsc::Sender<()>>,
63
+
/// Whether the stream has been closed.
64
+
closed: bool,
65
+
}
66
+
67
+
impl TapStream {
68
+
/// Create a new TAP stream with the given configuration.
69
+
///
70
+
/// The stream will start connecting immediately in a background task.
71
+
pub fn new(config: TapConfig) -> Self {
72
+
// Channel for events - buffer a few to handle bursts
73
+
let (event_tx, event_rx) = mpsc::channel(32);
74
+
// Channel for close signal
75
+
let (close_tx, close_rx) = mpsc::channel(1);
76
+
77
+
// Spawn background task to manage connection
78
+
tokio::spawn(connection_task(config, event_tx, close_rx));
79
+
80
+
Self {
81
+
receiver: event_rx,
82
+
close_sender: Some(close_tx),
83
+
closed: false,
84
+
}
85
+
}
86
+
87
+
/// Close the stream and release resources.
88
+
///
89
+
/// After calling this, the stream will yield `None` on the next poll.
90
+
pub async fn close(&mut self) {
91
+
if let Some(sender) = self.close_sender.take() {
92
+
// Signal the background task to close
93
+
let _ = sender.send(()).await;
94
+
}
95
+
self.closed = true;
96
+
}
97
+
98
+
/// Returns true if the stream is closed.
99
+
pub fn is_closed(&self) -> bool {
100
+
self.closed
101
+
}
102
+
}
103
+
104
+
impl Stream for TapStream {
105
+
type Item = Result<Arc<TapEvent>, TapError>;
106
+
107
+
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
108
+
if self.closed {
109
+
return Poll::Ready(None);
110
+
}
111
+
112
+
self.receiver.poll_recv(cx)
113
+
}
114
+
}
115
+
116
+
impl Drop for TapStream {
117
+
fn drop(&mut self) {
118
+
// Drop the close_sender to signal the background task
119
+
self.close_sender.take();
120
+
tracing::debug!("TapStream dropped");
121
+
}
122
+
}
123
+
124
+
/// Background task that manages the WebSocket connection.
125
+
async fn connection_task(
126
+
config: TapConfig,
127
+
event_tx: mpsc::Sender<Result<Arc<TapEvent>, TapError>>,
128
+
mut close_rx: mpsc::Receiver<()>,
129
+
) {
130
+
let mut current_reconnect_delay = config.initial_reconnect_delay;
131
+
let mut attempt: u32 = 0;
132
+
133
+
loop {
134
+
// Check for close signal
135
+
if close_rx.try_recv().is_ok() {
136
+
tracing::debug!("Connection task received close signal");
137
+
break;
138
+
}
139
+
140
+
// Try to connect
141
+
tracing::debug!(attempt, hostname = %config.hostname, "Connecting to TAP service");
142
+
let conn_result = TapConnection::connect(&config).await;
143
+
144
+
match conn_result {
145
+
Ok(mut conn) => {
146
+
tracing::info!(hostname = %config.hostname, "TAP stream connected");
147
+
// Reset reconnection state on successful connect
148
+
current_reconnect_delay = config.initial_reconnect_delay;
149
+
attempt = 0;
150
+
151
+
// Event loop for this connection
152
+
loop {
153
+
tokio::select! {
154
+
biased;
155
+
156
+
_ = close_rx.recv() => {
157
+
tracing::debug!("Connection task received close signal during receive");
158
+
let _ = conn.close().await;
159
+
return;
160
+
}
161
+
162
+
recv_result = conn.recv() => {
163
+
match recv_result {
164
+
Ok(Some(msg)) => {
165
+
// Parse the message
166
+
match serde_json::from_str::<TapEvent>(&msg) {
167
+
Ok(event) => {
168
+
let event_id = event.id();
169
+
170
+
// Send ack if enabled (before sending event to channel)
171
+
if config.send_acks
172
+
&& let Err(err) = conn.send_ack(event_id).await
173
+
{
174
+
tracing::warn!(error = %err, "Failed to send ack");
175
+
// Don't break connection for ack errors
176
+
}
177
+
178
+
// Send event to channel
179
+
let event = Arc::new(event);
180
+
if event_tx.send(Ok(event)).await.is_err() {
181
+
// Receiver dropped, exit task
182
+
tracing::debug!("Event receiver dropped, closing connection");
183
+
let _ = conn.close().await;
184
+
return;
185
+
}
186
+
}
187
+
Err(err) => {
188
+
// Parse errors don't affect connection
189
+
tracing::warn!(error = %err, "Failed to parse TAP message");
190
+
191
+
// Try to extract just the ID using fallback parser
192
+
// so we can still ack the message even if full parsing fails
193
+
if config.send_acks {
194
+
if let Some(event_id) = extract_event_id(&msg) {
195
+
tracing::debug!(event_id, "Extracted event ID via fallback parser");
196
+
if let Err(ack_err) = conn.send_ack(event_id).await {
197
+
tracing::warn!(error = %ack_err, "Failed to send ack for unparseable message");
198
+
}
199
+
} else {
200
+
tracing::warn!("Could not extract event ID from unparseable message");
201
+
}
202
+
}
203
+
204
+
if event_tx.send(Err(TapError::ParseError(err.to_string()))).await.is_err() {
205
+
tracing::debug!("Event receiver dropped, closing connection");
206
+
let _ = conn.close().await;
207
+
return;
208
+
}
209
+
}
210
+
}
211
+
}
212
+
Ok(None) => {
213
+
// Connection closed by server
214
+
tracing::debug!("TAP connection closed by server");
215
+
break;
216
+
}
217
+
Err(err) => {
218
+
// Connection error
219
+
tracing::warn!(error = %err, "TAP connection error");
220
+
break;
221
+
}
222
+
}
223
+
}
224
+
}
225
+
}
226
+
}
227
+
Err(err) => {
228
+
tracing::warn!(error = %err, attempt, "Failed to connect to TAP service");
229
+
}
230
+
}
231
+
232
+
// Increment attempt counter
233
+
attempt += 1;
234
+
235
+
// Check if we've exceeded max attempts
236
+
if let Some(max) = config.max_reconnect_attempts
237
+
&& attempt >= max
238
+
{
239
+
tracing::error!(attempts = attempt, "Max reconnection attempts exceeded");
240
+
let _ = event_tx
241
+
.send(Err(TapError::MaxReconnectAttemptsExceeded(attempt)))
242
+
.await;
243
+
break;
244
+
}
245
+
246
+
// Wait before reconnecting with exponential backoff
247
+
tracing::debug!(
248
+
delay_ms = current_reconnect_delay.as_millis(),
249
+
attempt,
250
+
"Waiting before reconnection"
251
+
);
252
+
253
+
tokio::select! {
254
+
_ = close_rx.recv() => {
255
+
tracing::debug!("Connection task received close signal during backoff");
256
+
return;
257
+
}
258
+
_ = tokio::time::sleep(current_reconnect_delay) => {
259
+
// Update delay for next attempt
260
+
current_reconnect_delay = Duration::from_secs_f64(
261
+
(current_reconnect_delay.as_secs_f64() * config.reconnect_backoff_multiplier)
262
+
.min(config.max_reconnect_delay.as_secs_f64()),
263
+
);
264
+
}
265
+
}
266
+
}
267
+
268
+
tracing::debug!("Connection task exiting");
269
+
}
270
+
271
+
/// Create a new TAP stream with the given configuration.
272
+
pub fn connect(config: TapConfig) -> TapStream {
273
+
TapStream::new(config)
274
+
}
275
+
276
+
/// Create a new TAP stream connected to the given hostname.
277
+
///
278
+
/// Uses default configuration values.
279
+
pub fn connect_to(hostname: &str) -> TapStream {
280
+
TapStream::new(TapConfig::new(hostname))
281
+
}
282
+
283
+
#[cfg(test)]
284
+
mod tests {
285
+
use super::*;
286
+
287
+
#[test]
288
+
fn test_stream_initial_state() {
289
+
// Note: This test doesn't actually poll the stream, just checks initial state
290
+
// Creating a TapStream requires a tokio runtime for the spawn
291
+
}
292
+
293
+
#[tokio::test]
294
+
async fn test_stream_close() {
295
+
let mut stream = TapStream::new(TapConfig::new("localhost:9999"));
296
+
assert!(!stream.is_closed());
297
+
stream.close().await;
298
+
assert!(stream.is_closed());
299
+
}
300
+
301
+
#[test]
302
+
fn test_connect_functions() {
303
+
// These just create configs, actual connection happens in background task
304
+
// We can't test without a runtime, so just verify the types compile
305
+
let _ = TapConfig::new("localhost:2480");
306
+
}
307
+
308
+
#[test]
309
+
fn test_reconnect_delay_calculation() {
310
+
// Test the delay calculation logic
311
+
let initial = Duration::from_secs(1);
312
+
let max = Duration::from_secs(10);
313
+
let multiplier = 2.0;
314
+
315
+
let mut delay = initial;
316
+
assert_eq!(delay, Duration::from_secs(1));
317
+
318
+
delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64()));
319
+
assert_eq!(delay, Duration::from_secs(2));
320
+
321
+
delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64()));
322
+
assert_eq!(delay, Duration::from_secs(4));
323
+
324
+
delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64()));
325
+
assert_eq!(delay, Duration::from_secs(8));
326
+
327
+
delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64()));
328
+
assert_eq!(delay, Duration::from_secs(10)); // Capped at max
329
+
}
330
+
}
+13
-13
crates/atproto-xrpcs/README.md
+13
-13
crates/atproto-xrpcs/README.md
···
23
23
### Basic XRPC Service
24
24
25
25
```rust
26
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
26
+
use atproto_xrpcs::authorization::Authorization;
27
27
use axum::{Json, Router, extract::Query, routing::get};
28
28
use serde::Deserialize;
29
29
use serde_json::json;
···
35
35
36
36
async fn handle_hello(
37
37
params: Query<HelloParams>,
38
-
authorization: Option<ResolvingAuthorization>,
38
+
authorization: Option<Authorization>,
39
39
) -> Json<serde_json::Value> {
40
40
let name = params.name.as_deref().unwrap_or("World");
41
-
41
+
42
42
let message = if authorization.is_some() {
43
43
format!("Hello, authenticated {}!", name)
44
44
} else {
45
45
format!("Hello, {}!", name)
46
46
};
47
-
47
+
48
48
Json(json!({ "message": message }))
49
49
}
50
50
···
56
56
### JWT Authorization
57
57
58
58
```rust
59
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
59
+
use atproto_xrpcs::authorization::Authorization;
60
60
61
61
async fn handle_secure_endpoint(
62
-
authorization: ResolvingAuthorization, // Required authorization
62
+
authorization: Authorization, // Required authorization
63
63
) -> Json<serde_json::Value> {
64
-
// The ResolvingAuthorization extractor automatically:
64
+
// The Authorization extractor automatically:
65
65
// 1. Validates the JWT token
66
-
// 2. Resolves the caller's DID document
66
+
// 2. Resolves the caller's DID document
67
67
// 3. Verifies the signature against the DID document
68
68
// 4. Provides access to caller identity information
69
-
69
+
70
70
let caller_did = authorization.subject();
71
71
Json(json!({"caller": caller_did, "status": "authenticated"}))
72
72
}
···
79
79
use axum::{response::IntoResponse, http::StatusCode};
80
80
81
81
async fn protected_handler(
82
-
authorization: Result<ResolvingAuthorization, AuthorizationError>,
82
+
authorization: Result<Authorization, AuthorizationError>,
83
83
) -> impl IntoResponse {
84
84
match authorization {
85
85
Ok(auth) => (StatusCode::OK, "Access granted").into_response(),
86
-
Err(AuthorizationError::InvalidJWTToken { .. }) => {
86
+
Err(AuthorizationError::InvalidJWTFormat) => {
87
87
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
88
88
}
89
-
Err(AuthorizationError::DIDDocumentResolutionFailed { .. }) => {
89
+
Err(AuthorizationError::SubjectResolutionFailed { .. }) => {
90
90
(StatusCode::FORBIDDEN, "Identity verification failed").into_response()
91
91
}
92
92
Err(_) => {
···
98
98
99
99
## Authorization Flow
100
100
101
-
The `ResolvingAuthorization` extractor implements:
101
+
The `Authorization` extractor implements:
102
102
103
103
1. JWT extraction from HTTP Authorization headers
104
104
2. Token validation (signature and claims structure)
+5
-49
crates/atproto-xrpcs/src/errors.rs
+5
-49
crates/atproto-xrpcs/src/errors.rs
···
42
42
#[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")]
43
43
NoIssuerInClaims,
44
44
45
-
/// Occurs when DID document is not found for the issuer
46
-
#[error("error-atproto-xrpcs-authorization-5 DID document not found for issuer: {issuer}")]
47
-
DIDDocumentNotFound {
48
-
/// The issuer DID that was not found
49
-
issuer: String,
50
-
},
51
-
52
45
/// Occurs when no verification keys are found in DID document
53
-
#[error("error-atproto-xrpcs-authorization-6 No verification keys found in DID document")]
46
+
#[error("error-atproto-xrpcs-authorization-5 No verification keys found in DID document")]
54
47
NoVerificationKeys,
55
48
56
49
/// Occurs when JWT header cannot be base64 decoded
57
-
#[error("error-atproto-xrpcs-authorization-7 Failed to decode JWT header: {error}")]
50
+
#[error("error-atproto-xrpcs-authorization-6 Failed to decode JWT header: {error}")]
58
51
HeaderDecodeError {
59
52
/// The underlying base64 decode error
60
53
error: base64::DecodeError,
61
54
},
62
55
63
56
/// Occurs when JWT header cannot be parsed as JSON
64
-
#[error("error-atproto-xrpcs-authorization-8 Failed to parse JWT header: {error}")]
57
+
#[error("error-atproto-xrpcs-authorization-7 Failed to parse JWT header: {error}")]
65
58
HeaderParseError {
66
59
/// The underlying JSON parse error
67
60
error: serde_json::Error,
68
61
},
69
62
70
63
/// Occurs when JWT validation fails with all available keys
71
-
#[error("error-atproto-xrpcs-authorization-9 JWT validation failed with all available keys")]
64
+
#[error("error-atproto-xrpcs-authorization-8 JWT validation failed with all available keys")]
72
65
ValidationFailedAllKeys,
73
66
74
67
/// Occurs when subject resolution fails during DID document lookup
75
-
#[error("error-atproto-xrpcs-authorization-10 Subject resolution failed: {issuer} {error}")]
68
+
#[error("error-atproto-xrpcs-authorization-9 Subject resolution failed: {issuer} {error}")]
76
69
SubjectResolutionFailed {
77
70
/// The issuer that failed to resolve
78
71
issuer: String,
79
72
/// The underlying resolution error
80
-
error: anyhow::Error,
81
-
},
82
-
83
-
/// Occurs when DID document lookup fails after successful resolution
84
-
#[error(
85
-
"error-atproto-xrpcs-authorization-11 DID document not found for resolved issuer: {resolved_did}"
86
-
)]
87
-
ResolvedDIDDocumentNotFound {
88
-
/// The resolved DID that was not found in storage
89
-
resolved_did: String,
90
-
},
91
-
92
-
/// Occurs when PLC directory query fails
93
-
#[error("error-atproto-xrpcs-authorization-12 PLC directory query failed: {error}")]
94
-
PLCQueryFailed {
95
-
/// The underlying PLC query error
96
-
error: anyhow::Error,
97
-
},
98
-
99
-
/// Occurs when web DID query fails
100
-
#[error("error-atproto-xrpcs-authorization-13 Web DID query failed: {error}")]
101
-
WebDIDQueryFailed {
102
-
/// The underlying web DID query error
103
-
error: anyhow::Error,
104
-
},
105
-
106
-
/// Occurs when DID document storage operation fails
107
-
#[error("error-atproto-xrpcs-authorization-14 DID document storage failed: {error}")]
108
-
DocumentStorageFailed {
109
-
/// The underlying storage error
110
-
error: anyhow::Error,
111
-
},
112
-
113
-
/// Occurs when input parsing fails for resolved DID
114
-
#[error("error-atproto-xrpcs-authorization-15 Input parsing failed for resolved DID: {error}")]
115
-
InputParsingFailed {
116
-
/// The underlying parsing error
117
73
error: anyhow::Error,
118
74
},
119
75
}
+3
-13
crates/atproto-xrpcs-helloworld/src/main.rs
+3
-13
crates/atproto-xrpcs-helloworld/src/main.rs
···
7
7
config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version},
8
8
key::{KeyData, KeyResolver, identify_key, to_public},
9
9
resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver},
10
-
storage_lru::LruDidDocumentStorage,
11
-
traits::DidDocumentStorage,
12
10
};
13
-
use atproto_xrpcs::authorization::ResolvingAuthorization;
11
+
use atproto_xrpcs::authorization::Authorization;
14
12
use axum::{
15
13
Json, Router,
16
14
extract::{FromRef, Query, State},
···
21
19
use http::{HeaderMap, StatusCode};
22
20
use serde::Deserialize;
23
21
use serde_json::json;
24
-
use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc};
22
+
use std::{collections::HashMap, ops::Deref, sync::Arc};
25
23
26
24
#[derive(Clone)]
27
25
pub struct SimpleKeyResolver {
···
61
59
62
60
pub struct InnerWebContext {
63
61
pub http_client: reqwest::Client,
64
-
pub document_storage: Arc<dyn DidDocumentStorage>,
65
62
pub key_resolver: Arc<dyn KeyResolver>,
66
63
pub service_document: ServiceDocument,
67
64
pub service_did: ServiceDID,
···
97
94
}
98
95
}
99
96
100
-
impl FromRef<WebContext> for Arc<dyn DidDocumentStorage> {
101
-
fn from_ref(context: &WebContext) -> Self {
102
-
context.0.document_storage.clone()
103
-
}
104
-
}
105
-
106
97
impl FromRef<WebContext> for Arc<dyn KeyResolver> {
107
98
fn from_ref(context: &WebContext) -> Self {
108
99
context.0.key_resolver.clone()
···
216
207
217
208
let web_context = WebContext(Arc::new(InnerWebContext {
218
209
http_client: http_client.clone(),
219
-
document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())),
220
210
key_resolver: Arc::new(SimpleKeyResolver {
221
211
keys: signing_key_storage,
222
212
}),
···
284
274
async fn handle_xrpc_hello_world(
285
275
parameters: Query<HelloParameters>,
286
276
headers: HeaderMap,
287
-
authorization: Option<ResolvingAuthorization>,
277
+
authorization: Option<Authorization>,
288
278
) -> Json<serde_json::Value> {
289
279
println!("headers {headers:?}");
290
280
let subject = parameters.subject.as_deref().unwrap_or("World");