+1
.gitignore
+1
.gitignore
···
1
+
aip/
+3
-1
api/.env
+3
-1
api/.env
+851
-11
api/Cargo.lock
+851
-11
api/Cargo.lock
···
59
59
]
60
60
61
61
[[package]]
62
+
name = "anyhow"
63
+
version = "1.0.99"
64
+
source = "registry+https://github.com/rust-lang/crates.io-index"
65
+
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
66
+
67
+
[[package]]
62
68
name = "arbitrary"
63
69
version = "1.4.2"
64
70
source = "registry+https://github.com/rust-lang/crates.io-index"
···
94
100
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
95
101
96
102
[[package]]
103
+
name = "atproto-client"
104
+
version = "0.11.2"
105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
106
+
checksum = "188c4bae6a3260c4d57149e7061415d440422ef11d68a16f581422ff181a66d9"
107
+
dependencies = [
108
+
"anyhow",
109
+
"atproto-identity",
110
+
"atproto-oauth",
111
+
"atproto-record",
112
+
"bytes",
113
+
"reqwest",
114
+
"reqwest-chain",
115
+
"reqwest-middleware",
116
+
"serde",
117
+
"serde_json",
118
+
"thiserror 2.0.14",
119
+
"tokio",
120
+
"tracing",
121
+
"urlencoding",
122
+
]
123
+
124
+
[[package]]
125
+
name = "atproto-identity"
126
+
version = "0.11.2"
127
+
source = "registry+https://github.com/rust-lang/crates.io-index"
128
+
checksum = "f4bf47131d663bcb76feaeb9403c09e12f00e5a2e0a7d805bd8caf4c6fdf01fa"
129
+
dependencies = [
130
+
"anyhow",
131
+
"async-trait",
132
+
"ecdsa",
133
+
"elliptic-curve",
134
+
"hickory-resolver",
135
+
"k256",
136
+
"lru",
137
+
"multibase",
138
+
"p256",
139
+
"p384",
140
+
"rand 0.8.5",
141
+
"reqwest",
142
+
"serde",
143
+
"serde_ipld_dagcbor",
144
+
"serde_json",
145
+
"thiserror 2.0.14",
146
+
"tokio",
147
+
"tracing",
148
+
]
149
+
150
+
[[package]]
151
+
name = "atproto-oauth"
152
+
version = "0.11.2"
153
+
source = "registry+https://github.com/rust-lang/crates.io-index"
154
+
checksum = "919d64f13696fb700ed604b09c526b223f6bf063eb35f46d691198079cfbc789"
155
+
dependencies = [
156
+
"anyhow",
157
+
"async-trait",
158
+
"atproto-identity",
159
+
"base64",
160
+
"chrono",
161
+
"ecdsa",
162
+
"elliptic-curve",
163
+
"k256",
164
+
"lru",
165
+
"multibase",
166
+
"p256",
167
+
"p384",
168
+
"rand 0.8.5",
169
+
"reqwest",
170
+
"reqwest-chain",
171
+
"reqwest-middleware",
172
+
"serde",
173
+
"serde_ipld_dagcbor",
174
+
"serde_json",
175
+
"sha2",
176
+
"thiserror 2.0.14",
177
+
"tokio",
178
+
"tracing",
179
+
"ulid",
180
+
]
181
+
182
+
[[package]]
183
+
name = "atproto-record"
184
+
version = "0.11.2"
185
+
source = "registry+https://github.com/rust-lang/crates.io-index"
186
+
checksum = "623f3eb1ba1e7b99903dc525f8b115ea8b6439b0d598507201f1f88aa6a37646"
187
+
dependencies = [
188
+
"anyhow",
189
+
"atproto-identity",
190
+
"base64",
191
+
"chrono",
192
+
"serde",
193
+
"serde_ipld_dagcbor",
194
+
"serde_json",
195
+
"thiserror 2.0.14",
196
+
]
197
+
198
+
[[package]]
97
199
name = "autocfg"
98
200
version = "1.5.0"
99
201
source = "registry+https://github.com/rust-lang/crates.io-index"
···
209
311
]
210
312
211
313
[[package]]
314
+
name = "base-x"
315
+
version = "0.2.11"
316
+
source = "registry+https://github.com/rust-lang/crates.io-index"
317
+
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
318
+
319
+
[[package]]
320
+
name = "base16ct"
321
+
version = "0.2.0"
322
+
source = "registry+https://github.com/rust-lang/crates.io-index"
323
+
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
324
+
325
+
[[package]]
212
326
name = "base64"
213
327
version = "0.22.1"
214
328
source = "registry+https://github.com/rust-lang/crates.io-index"
···
266
380
]
267
381
268
382
[[package]]
383
+
name = "cbor4ii"
384
+
version = "0.2.14"
385
+
source = "registry+https://github.com/rust-lang/crates.io-index"
386
+
checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4"
387
+
dependencies = [
388
+
"serde",
389
+
]
390
+
391
+
[[package]]
269
392
name = "cc"
270
393
version = "1.2.33"
271
394
source = "registry+https://github.com/rust-lang/crates.io-index"
···
281
404
version = "1.0.1"
282
405
source = "registry+https://github.com/rust-lang/crates.io-index"
283
406
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
407
+
408
+
[[package]]
409
+
name = "cfg_aliases"
410
+
version = "0.2.1"
411
+
source = "registry+https://github.com/rust-lang/crates.io-index"
412
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
284
413
285
414
[[package]]
286
415
name = "chrono"
···
298
427
]
299
428
300
429
[[package]]
430
+
name = "cid"
431
+
version = "0.11.1"
432
+
source = "registry+https://github.com/rust-lang/crates.io-index"
433
+
checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a"
434
+
dependencies = [
435
+
"core2",
436
+
"multibase",
437
+
"multihash",
438
+
"serde",
439
+
"serde_bytes",
440
+
"unsigned-varint",
441
+
]
442
+
443
+
[[package]]
301
444
name = "cipher"
302
445
version = "0.4.4"
303
446
source = "registry+https://github.com/rust-lang/crates.io-index"
···
345
488
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
346
489
347
490
[[package]]
491
+
name = "core2"
492
+
version = "0.4.0"
493
+
source = "registry+https://github.com/rust-lang/crates.io-index"
494
+
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
495
+
dependencies = [
496
+
"memchr",
497
+
]
498
+
499
+
[[package]]
348
500
name = "cpufeatures"
349
501
version = "0.2.17"
350
502
source = "registry+https://github.com/rust-lang/crates.io-index"
···
378
530
]
379
531
380
532
[[package]]
533
+
name = "critical-section"
534
+
version = "1.2.0"
535
+
source = "registry+https://github.com/rust-lang/crates.io-index"
536
+
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
537
+
538
+
[[package]]
539
+
name = "crossbeam-channel"
540
+
version = "0.5.15"
541
+
source = "registry+https://github.com/rust-lang/crates.io-index"
542
+
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
543
+
dependencies = [
544
+
"crossbeam-utils",
545
+
]
546
+
547
+
[[package]]
548
+
name = "crossbeam-epoch"
549
+
version = "0.9.18"
550
+
source = "registry+https://github.com/rust-lang/crates.io-index"
551
+
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
552
+
dependencies = [
553
+
"crossbeam-utils",
554
+
]
555
+
556
+
[[package]]
381
557
name = "crossbeam-queue"
382
558
version = "0.3.12"
383
559
source = "registry+https://github.com/rust-lang/crates.io-index"
···
393
569
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
394
570
395
571
[[package]]
572
+
name = "crypto-bigint"
573
+
version = "0.5.5"
574
+
source = "registry+https://github.com/rust-lang/crates.io-index"
575
+
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
576
+
dependencies = [
577
+
"generic-array",
578
+
"rand_core 0.6.4",
579
+
"subtle",
580
+
"zeroize",
581
+
]
582
+
583
+
[[package]]
396
584
name = "crypto-common"
397
585
version = "0.1.6"
398
586
source = "registry+https://github.com/rust-lang/crates.io-index"
···
409
597
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
410
598
411
599
[[package]]
600
+
name = "data-encoding-macro"
601
+
version = "0.1.18"
602
+
source = "registry+https://github.com/rust-lang/crates.io-index"
603
+
checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d"
604
+
dependencies = [
605
+
"data-encoding",
606
+
"data-encoding-macro-internal",
607
+
]
608
+
609
+
[[package]]
610
+
name = "data-encoding-macro-internal"
611
+
version = "0.1.16"
612
+
source = "registry+https://github.com/rust-lang/crates.io-index"
613
+
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
614
+
dependencies = [
615
+
"data-encoding",
616
+
"syn",
617
+
]
618
+
619
+
[[package]]
412
620
name = "deflate64"
413
621
version = "0.1.9"
414
622
source = "registry+https://github.com/rust-lang/crates.io-index"
···
475
683
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
476
684
477
685
[[package]]
686
+
name = "ecdsa"
687
+
version = "0.16.9"
688
+
source = "registry+https://github.com/rust-lang/crates.io-index"
689
+
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
690
+
dependencies = [
691
+
"der",
692
+
"digest",
693
+
"elliptic-curve",
694
+
"rfc6979",
695
+
"serdect",
696
+
"signature",
697
+
"spki",
698
+
]
699
+
700
+
[[package]]
478
701
name = "either"
479
702
version = "1.15.0"
480
703
source = "registry+https://github.com/rust-lang/crates.io-index"
···
484
707
]
485
708
486
709
[[package]]
710
+
name = "elliptic-curve"
711
+
version = "0.13.8"
712
+
source = "registry+https://github.com/rust-lang/crates.io-index"
713
+
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
714
+
dependencies = [
715
+
"base16ct",
716
+
"base64ct",
717
+
"crypto-bigint",
718
+
"digest",
719
+
"ff",
720
+
"generic-array",
721
+
"group",
722
+
"hkdf",
723
+
"pem-rfc7468",
724
+
"pkcs8",
725
+
"rand_core 0.6.4",
726
+
"sec1",
727
+
"serde_json",
728
+
"serdect",
729
+
"subtle",
730
+
"zeroize",
731
+
]
732
+
733
+
[[package]]
487
734
name = "encoding_rs"
488
735
version = "0.8.35"
489
736
source = "registry+https://github.com/rust-lang/crates.io-index"
490
737
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
491
738
dependencies = [
492
739
"cfg-if",
740
+
]
741
+
742
+
[[package]]
743
+
name = "enum-as-inner"
744
+
version = "0.6.1"
745
+
source = "registry+https://github.com/rust-lang/crates.io-index"
746
+
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
747
+
dependencies = [
748
+
"heck",
749
+
"proc-macro2",
750
+
"quote",
751
+
"syn",
493
752
]
494
753
495
754
[[package]]
···
535
794
version = "2.3.0"
536
795
source = "registry+https://github.com/rust-lang/crates.io-index"
537
796
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
797
+
798
+
[[package]]
799
+
name = "ff"
800
+
version = "0.13.1"
801
+
source = "registry+https://github.com/rust-lang/crates.io-index"
802
+
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
803
+
dependencies = [
804
+
"rand_core 0.6.4",
805
+
"subtle",
806
+
]
538
807
539
808
[[package]]
540
809
name = "flate2"
···
679
948
]
680
949
681
950
[[package]]
951
+
name = "generator"
952
+
version = "0.8.5"
953
+
source = "registry+https://github.com/rust-lang/crates.io-index"
954
+
checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827"
955
+
dependencies = [
956
+
"cc",
957
+
"cfg-if",
958
+
"libc",
959
+
"log",
960
+
"rustversion",
961
+
"windows",
962
+
]
963
+
964
+
[[package]]
682
965
name = "generic-array"
683
966
version = "0.14.7"
684
967
source = "registry+https://github.com/rust-lang/crates.io-index"
···
686
969
dependencies = [
687
970
"typenum",
688
971
"version_check",
972
+
"zeroize",
689
973
]
690
974
691
975
[[package]]
···
695
979
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
696
980
dependencies = [
697
981
"cfg-if",
982
+
"js-sys",
698
983
"libc",
699
984
"wasi 0.11.1+wasi-snapshot-preview1",
985
+
"wasm-bindgen",
700
986
]
701
987
702
988
[[package]]
···
706
992
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
707
993
dependencies = [
708
994
"cfg-if",
995
+
"js-sys",
709
996
"libc",
710
997
"r-efi",
711
998
"wasi 0.14.2+wasi-0.2.4",
999
+
"wasm-bindgen",
712
1000
]
713
1001
714
1002
[[package]]
···
718
1006
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
719
1007
720
1008
[[package]]
1009
+
name = "group"
1010
+
version = "0.13.0"
1011
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1012
+
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1013
+
dependencies = [
1014
+
"ff",
1015
+
"rand_core 0.6.4",
1016
+
"subtle",
1017
+
]
1018
+
1019
+
[[package]]
721
1020
name = "h2"
722
1021
version = "0.4.12"
723
1022
source = "registry+https://github.com/rust-lang/crates.io-index"
···
769
1068
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
770
1069
771
1070
[[package]]
1071
+
name = "hickory-proto"
1072
+
version = "0.25.2"
1073
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1074
+
checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
1075
+
dependencies = [
1076
+
"async-trait",
1077
+
"cfg-if",
1078
+
"data-encoding",
1079
+
"enum-as-inner",
1080
+
"futures-channel",
1081
+
"futures-io",
1082
+
"futures-util",
1083
+
"idna",
1084
+
"ipnet",
1085
+
"once_cell",
1086
+
"rand 0.9.2",
1087
+
"ring",
1088
+
"thiserror 2.0.14",
1089
+
"tinyvec",
1090
+
"tokio",
1091
+
"tracing",
1092
+
"url",
1093
+
]
1094
+
1095
+
[[package]]
1096
+
name = "hickory-resolver"
1097
+
version = "0.25.2"
1098
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1099
+
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
1100
+
dependencies = [
1101
+
"cfg-if",
1102
+
"futures-util",
1103
+
"hickory-proto",
1104
+
"ipconfig",
1105
+
"moka",
1106
+
"once_cell",
1107
+
"parking_lot",
1108
+
"rand 0.9.2",
1109
+
"resolv-conf",
1110
+
"smallvec",
1111
+
"thiserror 2.0.14",
1112
+
"tokio",
1113
+
"tracing",
1114
+
]
1115
+
1116
+
[[package]]
772
1117
name = "hkdf"
773
1118
version = "0.12.4"
774
1119
source = "registry+https://github.com/rust-lang/crates.io-index"
···
876
1221
"tokio",
877
1222
"tokio-rustls",
878
1223
"tower-service",
1224
+
"webpki-roots 1.0.2",
879
1225
]
880
1226
881
1227
[[package]]
···
912
1258
"libc",
913
1259
"percent-encoding",
914
1260
"pin-project-lite",
915
-
"socket2",
1261
+
"socket2 0.6.0",
916
1262
"system-configuration",
917
1263
"tokio",
918
1264
"tower-service",
···
1082
1428
]
1083
1429
1084
1430
[[package]]
1431
+
name = "ipconfig"
1432
+
version = "0.3.2"
1433
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1434
+
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
1435
+
dependencies = [
1436
+
"socket2 0.5.10",
1437
+
"widestring",
1438
+
"windows-sys 0.48.0",
1439
+
"winreg",
1440
+
]
1441
+
1442
+
[[package]]
1443
+
name = "ipld-core"
1444
+
version = "0.4.2"
1445
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1446
+
checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db"
1447
+
dependencies = [
1448
+
"cid",
1449
+
"serde",
1450
+
"serde_bytes",
1451
+
]
1452
+
1453
+
[[package]]
1085
1454
name = "ipnet"
1086
1455
version = "2.11.0"
1087
1456
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1124
1493
]
1125
1494
1126
1495
[[package]]
1496
+
name = "k256"
1497
+
version = "0.13.4"
1498
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1499
+
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
1500
+
dependencies = [
1501
+
"cfg-if",
1502
+
"ecdsa",
1503
+
"elliptic-curve",
1504
+
"once_cell",
1505
+
"sha2",
1506
+
"signature",
1507
+
]
1508
+
1509
+
[[package]]
1127
1510
name = "lazy_static"
1128
1511
version = "1.5.0"
1129
1512
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1229
1612
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
1230
1613
1231
1614
[[package]]
1615
+
name = "loom"
1616
+
version = "0.7.2"
1617
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1618
+
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1619
+
dependencies = [
1620
+
"cfg-if",
1621
+
"generator",
1622
+
"scoped-tls",
1623
+
"tracing",
1624
+
"tracing-subscriber",
1625
+
]
1626
+
1627
+
[[package]]
1628
+
name = "lru"
1629
+
version = "0.12.5"
1630
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1631
+
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1632
+
dependencies = [
1633
+
"hashbrown",
1634
+
]
1635
+
1636
+
[[package]]
1637
+
name = "lru-slab"
1638
+
version = "0.1.2"
1639
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1640
+
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1641
+
1642
+
[[package]]
1232
1643
name = "matchers"
1233
1644
version = "0.1.0"
1234
1645
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1272
1683
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1273
1684
1274
1685
[[package]]
1686
+
name = "mime_guess"
1687
+
version = "2.0.5"
1688
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1689
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
1690
+
dependencies = [
1691
+
"mime",
1692
+
"unicase",
1693
+
]
1694
+
1695
+
[[package]]
1275
1696
name = "minijinja"
1276
1697
version = "2.11.0"
1277
1698
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1303
1724
]
1304
1725
1305
1726
[[package]]
1727
+
name = "moka"
1728
+
version = "0.12.10"
1729
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1730
+
checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926"
1731
+
dependencies = [
1732
+
"crossbeam-channel",
1733
+
"crossbeam-epoch",
1734
+
"crossbeam-utils",
1735
+
"loom",
1736
+
"parking_lot",
1737
+
"portable-atomic",
1738
+
"rustc_version",
1739
+
"smallvec",
1740
+
"tagptr",
1741
+
"thiserror 1.0.69",
1742
+
"uuid",
1743
+
]
1744
+
1745
+
[[package]]
1306
1746
name = "multer"
1307
1747
version = "3.1.0"
1308
1748
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1320
1760
]
1321
1761
1322
1762
[[package]]
1763
+
name = "multibase"
1764
+
version = "0.9.1"
1765
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1766
+
checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404"
1767
+
dependencies = [
1768
+
"base-x",
1769
+
"data-encoding",
1770
+
"data-encoding-macro",
1771
+
]
1772
+
1773
+
[[package]]
1774
+
name = "multihash"
1775
+
version = "0.19.3"
1776
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1777
+
checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d"
1778
+
dependencies = [
1779
+
"core2",
1780
+
"serde",
1781
+
"unsigned-varint",
1782
+
]
1783
+
1784
+
[[package]]
1323
1785
name = "native-tls"
1324
1786
version = "0.2.14"
1325
1787
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1358
1820
"num-integer",
1359
1821
"num-iter",
1360
1822
"num-traits",
1361
-
"rand",
1823
+
"rand 0.8.5",
1362
1824
"smallvec",
1363
1825
"zeroize",
1364
1826
]
···
1413
1875
version = "1.21.3"
1414
1876
source = "registry+https://github.com/rust-lang/crates.io-index"
1415
1877
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1878
+
dependencies = [
1879
+
"critical-section",
1880
+
"portable-atomic",
1881
+
]
1416
1882
1417
1883
[[package]]
1418
1884
name = "openssl"
···
1465
1931
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
1466
1932
1467
1933
[[package]]
1934
+
name = "p256"
1935
+
version = "0.13.2"
1936
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1937
+
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1938
+
dependencies = [
1939
+
"ecdsa",
1940
+
"elliptic-curve",
1941
+
"primeorder",
1942
+
"serdect",
1943
+
"sha2",
1944
+
]
1945
+
1946
+
[[package]]
1947
+
name = "p384"
1948
+
version = "0.13.1"
1949
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1950
+
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
1951
+
dependencies = [
1952
+
"ecdsa",
1953
+
"elliptic-curve",
1954
+
"primeorder",
1955
+
"serdect",
1956
+
"sha2",
1957
+
]
1958
+
1959
+
[[package]]
1468
1960
name = "parking"
1469
1961
version = "2.2.1"
1470
1962
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1558
2050
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1559
2051
1560
2052
[[package]]
2053
+
name = "portable-atomic"
2054
+
version = "1.11.1"
2055
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2056
+
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
2057
+
2058
+
[[package]]
1561
2059
name = "potential_utf"
1562
2060
version = "0.1.2"
1563
2061
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1588
2086
]
1589
2087
1590
2088
[[package]]
2089
+
name = "primeorder"
2090
+
version = "0.13.6"
2091
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2092
+
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
2093
+
dependencies = [
2094
+
"elliptic-curve",
2095
+
"serdect",
2096
+
]
2097
+
2098
+
[[package]]
1591
2099
name = "proc-macro2"
1592
2100
version = "1.0.97"
1593
2101
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1597
2105
]
1598
2106
1599
2107
[[package]]
2108
+
name = "quinn"
2109
+
version = "0.11.8"
2110
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2111
+
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
2112
+
dependencies = [
2113
+
"bytes",
2114
+
"cfg_aliases",
2115
+
"pin-project-lite",
2116
+
"quinn-proto",
2117
+
"quinn-udp",
2118
+
"rustc-hash",
2119
+
"rustls",
2120
+
"socket2 0.5.10",
2121
+
"thiserror 2.0.14",
2122
+
"tokio",
2123
+
"tracing",
2124
+
"web-time",
2125
+
]
2126
+
2127
+
[[package]]
2128
+
name = "quinn-proto"
2129
+
version = "0.11.12"
2130
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2131
+
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
2132
+
dependencies = [
2133
+
"bytes",
2134
+
"getrandom 0.3.3",
2135
+
"lru-slab",
2136
+
"rand 0.9.2",
2137
+
"ring",
2138
+
"rustc-hash",
2139
+
"rustls",
2140
+
"rustls-pki-types",
2141
+
"slab",
2142
+
"thiserror 2.0.14",
2143
+
"tinyvec",
2144
+
"tracing",
2145
+
"web-time",
2146
+
]
2147
+
2148
+
[[package]]
2149
+
name = "quinn-udp"
2150
+
version = "0.5.13"
2151
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2152
+
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
2153
+
dependencies = [
2154
+
"cfg_aliases",
2155
+
"libc",
2156
+
"once_cell",
2157
+
"socket2 0.5.10",
2158
+
"tracing",
2159
+
"windows-sys 0.59.0",
2160
+
]
2161
+
2162
+
[[package]]
1600
2163
name = "quote"
1601
2164
version = "1.0.40"
1602
2165
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1618
2181
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1619
2182
dependencies = [
1620
2183
"libc",
1621
-
"rand_chacha",
1622
-
"rand_core",
2184
+
"rand_chacha 0.3.1",
2185
+
"rand_core 0.6.4",
2186
+
]
2187
+
2188
+
[[package]]
2189
+
name = "rand"
2190
+
version = "0.9.2"
2191
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2192
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
2193
+
dependencies = [
2194
+
"rand_chacha 0.9.0",
2195
+
"rand_core 0.9.3",
1623
2196
]
1624
2197
1625
2198
[[package]]
···
1629
2202
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1630
2203
dependencies = [
1631
2204
"ppv-lite86",
1632
-
"rand_core",
2205
+
"rand_core 0.6.4",
2206
+
]
2207
+
2208
+
[[package]]
2209
+
name = "rand_chacha"
2210
+
version = "0.9.0"
2211
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2212
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2213
+
dependencies = [
2214
+
"ppv-lite86",
2215
+
"rand_core 0.9.3",
1633
2216
]
1634
2217
1635
2218
[[package]]
···
1642
2225
]
1643
2226
1644
2227
[[package]]
2228
+
name = "rand_core"
2229
+
version = "0.9.3"
2230
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2231
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
2232
+
dependencies = [
2233
+
"getrandom 0.3.3",
2234
+
]
2235
+
2236
+
[[package]]
1645
2237
name = "redox_syscall"
1646
2238
version = "0.5.17"
1647
2239
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1716
2308
"js-sys",
1717
2309
"log",
1718
2310
"mime",
2311
+
"mime_guess",
1719
2312
"native-tls",
1720
2313
"percent-encoding",
1721
2314
"pin-project-lite",
2315
+
"quinn",
2316
+
"rustls",
1722
2317
"rustls-pki-types",
1723
2318
"serde",
1724
2319
"serde_json",
···
1726
2321
"sync_wrapper",
1727
2322
"tokio",
1728
2323
"tokio-native-tls",
2324
+
"tokio-rustls",
1729
2325
"tokio-util",
1730
2326
"tower",
1731
2327
"tower-http",
···
1735
2331
"wasm-bindgen-futures",
1736
2332
"wasm-streams",
1737
2333
"web-sys",
2334
+
"webpki-roots 1.0.2",
2335
+
]
2336
+
2337
+
[[package]]
2338
+
name = "reqwest-chain"
2339
+
version = "1.0.0"
2340
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2341
+
checksum = "da5c014fb79a8227db44a0433d748107750d2550b7fca55c59a3d7ee7d2ee2b2"
2342
+
dependencies = [
2343
+
"anyhow",
2344
+
"async-trait",
2345
+
"http",
2346
+
"reqwest-middleware",
2347
+
]
2348
+
2349
+
[[package]]
2350
+
name = "reqwest-middleware"
2351
+
version = "0.4.2"
2352
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2353
+
checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
2354
+
dependencies = [
2355
+
"anyhow",
2356
+
"async-trait",
2357
+
"http",
2358
+
"reqwest",
2359
+
"serde",
2360
+
"thiserror 1.0.69",
2361
+
"tower-service",
2362
+
]
2363
+
2364
+
[[package]]
2365
+
name = "resolv-conf"
2366
+
version = "0.7.4"
2367
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2368
+
checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3"
2369
+
2370
+
[[package]]
2371
+
name = "rfc6979"
2372
+
version = "0.4.0"
2373
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2374
+
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
2375
+
dependencies = [
2376
+
"hmac",
2377
+
"subtle",
1738
2378
]
1739
2379
1740
2380
[[package]]
···
1764
2404
"num-traits",
1765
2405
"pkcs1",
1766
2406
"pkcs8",
1767
-
"rand_core",
2407
+
"rand_core 0.6.4",
1768
2408
"signature",
1769
2409
"spki",
1770
2410
"subtle",
···
1778
2418
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
1779
2419
1780
2420
[[package]]
2421
+
name = "rustc-hash"
2422
+
version = "2.1.1"
2423
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2424
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
2425
+
2426
+
[[package]]
2427
+
name = "rustc_version"
2428
+
version = "0.4.1"
2429
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2430
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2431
+
dependencies = [
2432
+
"semver",
2433
+
]
2434
+
2435
+
[[package]]
1781
2436
name = "rustix"
1782
2437
version = "1.0.8"
1783
2438
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1810
2465
source = "registry+https://github.com/rust-lang/crates.io-index"
1811
2466
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
1812
2467
dependencies = [
2468
+
"web-time",
1813
2469
"zeroize",
1814
2470
]
1815
2471
···
1844
2500
dependencies = [
1845
2501
"windows-sys 0.59.0",
1846
2502
]
2503
+
2504
+
[[package]]
2505
+
name = "scoped-tls"
2506
+
version = "1.0.1"
2507
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2508
+
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
1847
2509
1848
2510
[[package]]
1849
2511
name = "scopeguard"
···
1852
2514
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1853
2515
1854
2516
[[package]]
2517
+
name = "sec1"
2518
+
version = "0.7.3"
2519
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2520
+
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2521
+
dependencies = [
2522
+
"base16ct",
2523
+
"der",
2524
+
"generic-array",
2525
+
"pkcs8",
2526
+
"serdect",
2527
+
"subtle",
2528
+
"zeroize",
2529
+
]
2530
+
2531
+
[[package]]
1855
2532
name = "security-framework"
1856
2533
version = "2.11.1"
1857
2534
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1881
2558
checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
1882
2559
1883
2560
[[package]]
2561
+
name = "semver"
2562
+
version = "1.0.26"
2563
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2564
+
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
2565
+
2566
+
[[package]]
1884
2567
name = "serde"
1885
2568
version = "1.0.219"
1886
2569
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1890
2573
]
1891
2574
1892
2575
[[package]]
2576
+
name = "serde_bytes"
2577
+
version = "0.11.17"
2578
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2579
+
checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96"
2580
+
dependencies = [
2581
+
"serde",
2582
+
]
2583
+
2584
+
[[package]]
1893
2585
name = "serde_derive"
1894
2586
version = "1.0.219"
1895
2587
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1914
2606
]
1915
2607
1916
2608
[[package]]
2609
+
name = "serde_ipld_dagcbor"
2610
+
version = "0.6.3"
2611
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2612
+
checksum = "99600723cf53fb000a66175555098db7e75217c415bdd9a16a65d52a19dcc4fc"
2613
+
dependencies = [
2614
+
"cbor4ii",
2615
+
"ipld-core",
2616
+
"scopeguard",
2617
+
"serde",
2618
+
]
2619
+
2620
+
[[package]]
1917
2621
name = "serde_json"
1918
2622
version = "1.0.142"
1919
2623
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1944
2648
"form_urlencoded",
1945
2649
"itoa",
1946
2650
"ryu",
2651
+
"serde",
2652
+
]
2653
+
2654
+
[[package]]
2655
+
name = "serdect"
2656
+
version = "0.2.0"
2657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2658
+
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
2659
+
dependencies = [
2660
+
"base16ct",
1947
2661
"serde",
1948
2662
]
1949
2663
···
2000
2714
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2001
2715
dependencies = [
2002
2716
"digest",
2003
-
"rand_core",
2717
+
"rand_core 0.6.4",
2004
2718
]
2005
2719
2006
2720
[[package]]
···
2019
2733
name = "slice"
2020
2734
version = "0.1.0"
2021
2735
dependencies = [
2736
+
"atproto-client",
2737
+
"atproto-identity",
2738
+
"atproto-oauth",
2022
2739
"axum",
2023
2740
"axum-extra",
2024
2741
"chrono",
···
2037
2754
"tower-http",
2038
2755
"tracing",
2039
2756
"tracing-subscriber",
2757
+
"urlencoding",
2040
2758
"uuid",
2041
2759
"zip",
2042
2760
]
···
2048
2766
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
2049
2767
dependencies = [
2050
2768
"serde",
2769
+
]
2770
+
2771
+
[[package]]
2772
+
name = "socket2"
2773
+
version = "0.5.10"
2774
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2775
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
2776
+
dependencies = [
2777
+
"libc",
2778
+
"windows-sys 0.52.0",
2051
2779
]
2052
2780
2053
2781
[[package]]
···
2197
2925
"memchr",
2198
2926
"once_cell",
2199
2927
"percent-encoding",
2200
-
"rand",
2928
+
"rand 0.8.5",
2201
2929
"rsa",
2202
2930
"serde",
2203
2931
"sha1",
···
2236
2964
"md-5",
2237
2965
"memchr",
2238
2966
"once_cell",
2239
-
"rand",
2967
+
"rand 0.8.5",
2240
2968
"serde",
2241
2969
"serde_json",
2242
2970
"sha2",
···
2349
3077
]
2350
3078
2351
3079
[[package]]
3080
+
name = "tagptr"
3081
+
version = "0.2.0"
3082
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3083
+
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
3084
+
3085
+
[[package]]
2352
3086
name = "tempfile"
2353
3087
version = "3.20.0"
2354
3088
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2469
3203
"pin-project-lite",
2470
3204
"signal-hook-registry",
2471
3205
"slab",
2472
-
"socket2",
3206
+
"socket2 0.6.0",
2473
3207
"tokio-macros",
2474
3208
"windows-sys 0.59.0",
2475
3209
]
···
2668
3402
"http",
2669
3403
"httparse",
2670
3404
"log",
2671
-
"rand",
3405
+
"rand 0.8.5",
2672
3406
"sha1",
2673
3407
"thiserror 1.0.69",
2674
3408
"utf-8",
···
2681
3415
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
2682
3416
2683
3417
[[package]]
3418
+
name = "ulid"
3419
+
version = "1.2.1"
3420
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3421
+
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
3422
+
dependencies = [
3423
+
"rand 0.9.2",
3424
+
"web-time",
3425
+
]
3426
+
3427
+
[[package]]
3428
+
name = "unicase"
3429
+
version = "2.8.1"
3430
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3431
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
3432
+
3433
+
[[package]]
2684
3434
name = "unicode-bidi"
2685
3435
version = "0.3.18"
2686
3436
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2708
3458
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
2709
3459
2710
3460
[[package]]
3461
+
name = "unsigned-varint"
3462
+
version = "0.8.0"
3463
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3464
+
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
3465
+
3466
+
[[package]]
2711
3467
name = "untrusted"
2712
3468
version = "0.9.0"
2713
3469
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2723
3479
"idna",
2724
3480
"percent-encoding",
2725
3481
]
3482
+
3483
+
[[package]]
3484
+
name = "urlencoding"
3485
+
version = "2.1.3"
3486
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3487
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
2726
3488
2727
3489
[[package]]
2728
3490
name = "utf-8"
···
2890
3652
]
2891
3653
2892
3654
[[package]]
3655
+
name = "web-time"
3656
+
version = "1.1.0"
3657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3658
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
3659
+
dependencies = [
3660
+
"js-sys",
3661
+
"wasm-bindgen",
3662
+
]
3663
+
3664
+
[[package]]
2893
3665
name = "webpki-roots"
2894
3666
version = "0.26.11"
2895
3667
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2918
3690
]
2919
3691
2920
3692
[[package]]
3693
+
name = "widestring"
3694
+
version = "1.2.0"
3695
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3696
+
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
3697
+
3698
+
[[package]]
2921
3699
name = "winapi"
2922
3700
version = "0.3.9"
2923
3701
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2940
3718
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2941
3719
2942
3720
[[package]]
3721
+
name = "windows"
3722
+
version = "0.61.3"
3723
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3724
+
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
3725
+
dependencies = [
3726
+
"windows-collections",
3727
+
"windows-core",
3728
+
"windows-future",
3729
+
"windows-link",
3730
+
"windows-numerics",
3731
+
]
3732
+
3733
+
[[package]]
3734
+
name = "windows-collections"
3735
+
version = "0.2.0"
3736
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3737
+
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
3738
+
dependencies = [
3739
+
"windows-core",
3740
+
]
3741
+
3742
+
[[package]]
2943
3743
name = "windows-core"
2944
3744
version = "0.61.2"
2945
3745
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2953
3753
]
2954
3754
2955
3755
[[package]]
3756
+
name = "windows-future"
3757
+
version = "0.2.1"
3758
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3759
+
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
3760
+
dependencies = [
3761
+
"windows-core",
3762
+
"windows-link",
3763
+
"windows-threading",
3764
+
]
3765
+
3766
+
[[package]]
2956
3767
name = "windows-implement"
2957
3768
version = "0.60.0"
2958
3769
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2981
3792
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
2982
3793
2983
3794
[[package]]
3795
+
name = "windows-numerics"
3796
+
version = "0.2.0"
3797
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3798
+
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
3799
+
dependencies = [
3800
+
"windows-core",
3801
+
"windows-link",
3802
+
]
3803
+
3804
+
[[package]]
2984
3805
name = "windows-registry"
2985
3806
version = "0.5.3"
2986
3807
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3091
3912
"windows_x86_64_gnu 0.53.0",
3092
3913
"windows_x86_64_gnullvm 0.53.0",
3093
3914
"windows_x86_64_msvc 0.53.0",
3915
+
]
3916
+
3917
+
[[package]]
3918
+
name = "windows-threading"
3919
+
version = "0.1.0"
3920
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3921
+
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
3922
+
dependencies = [
3923
+
"windows-link",
3094
3924
]
3095
3925
3096
3926
[[package]]
···
3230
4060
version = "0.53.0"
3231
4061
source = "registry+https://github.com/rust-lang/crates.io-index"
3232
4062
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
4063
+
4064
+
[[package]]
4065
+
name = "winreg"
4066
+
version = "0.50.0"
4067
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4068
+
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
4069
+
dependencies = [
4070
+
"cfg-if",
4071
+
"windows-sys 0.48.0",
4072
+
]
3233
4073
3234
4074
[[package]]
3235
4075
name = "wit-bindgen-rt"
+8
api/Cargo.toml
+8
api/Cargo.toml
+562
-21
api/scripts/generate-typescript.ts
+562
-21
api/scripts/generate-typescript.ts
···
48
48
49
49
// Add base interfaces
50
50
function addBaseInterfaces(): void {
51
+
// OAuth interfaces
52
+
sourceFile.addInterface({
53
+
name: "OAuthAuthorizeParams",
54
+
isExported: true,
55
+
properties: [
56
+
{ name: "loginHint", type: "string" },
57
+
{ name: "redirectUri", type: "string" },
58
+
{ name: "scope", type: "string[]", hasQuestionToken: true },
59
+
{ name: "state", type: "string", hasQuestionToken: true },
60
+
],
61
+
});
62
+
63
+
sourceFile.addInterface({
64
+
name: "OAuthAuthorizeResponse",
65
+
isExported: true,
66
+
properties: [
67
+
{ name: "authorizationUrl", type: "string" },
68
+
{ name: "codeVerifier", type: "string" },
69
+
{ name: "state", type: "string" },
70
+
],
71
+
});
72
+
73
+
sourceFile.addInterface({
74
+
name: "OAuthCallbackParams",
75
+
isExported: true,
76
+
properties: [
77
+
{ name: "code", type: "string" },
78
+
{ name: "state", type: "string" },
79
+
{ name: "codeVerifier", type: "string" },
80
+
{ name: "redirectUri", type: "string" },
81
+
],
82
+
});
83
+
84
+
sourceFile.addInterface({
85
+
name: "OAuthTokenResponse",
86
+
isExported: true,
87
+
properties: [
88
+
{ name: "access_token", type: "string" },
89
+
{ name: "token_type", type: "string" },
90
+
{ name: "expires_in", type: "number", hasQuestionToken: true },
91
+
{ name: "refresh_token", type: "string", hasQuestionToken: true },
92
+
{ name: "scope", type: "string", hasQuestionToken: true },
93
+
],
94
+
});
95
+
96
+
sourceFile.addInterface({
97
+
name: "PKCEChallenge",
98
+
isExported: true,
99
+
properties: [
100
+
{ name: "codeVerifier", type: "string" },
101
+
{ name: "codeChallenge", type: "string" },
102
+
{ name: "codeChallengeMethod", type: "'S256'" },
103
+
],
104
+
});
105
+
106
+
sourceFile.addInterface({
107
+
name: "TokenStorage",
108
+
isExported: true,
109
+
properties: [
110
+
{ name: "accessToken", type: "string", hasQuestionToken: true },
111
+
{ name: "refreshToken", type: "string", hasQuestionToken: true },
112
+
{ name: "expiresAt", type: "number", hasQuestionToken: true },
113
+
{ name: "tokenType", type: "string", hasQuestionToken: true },
114
+
{ name: "scope", type: "string", hasQuestionToken: true },
115
+
],
116
+
});
117
+
51
118
// RecordResponse interface
52
119
sourceFile.addInterface({
53
120
name: "RecordResponse",
···
207
274
}
208
275
}
209
276
210
-
// Add base client class with shared request logic
277
+
// Add PKCE utility class
278
+
function addPKCEUtilsClass(): void {
279
+
sourceFile.addClass({
280
+
name: "PKCEUtils",
281
+
isExported: true,
282
+
methods: [
283
+
{
284
+
name: "generateCodeVerifier",
285
+
isStatic: true,
286
+
returnType: "string",
287
+
statements: [
288
+
`const array = new Uint8Array(32);`,
289
+
`crypto.getRandomValues(array);`,
290
+
`return btoa(String.fromCharCode.apply(null, Array.from(array)))`,
291
+
` .replace(/\\+/g, '-')`,
292
+
` .replace(/\\//g, '_')`,
293
+
` .replace(/=/g, '');`,
294
+
],
295
+
},
296
+
{
297
+
name: "generateCodeChallenge",
298
+
isStatic: true,
299
+
isAsync: true,
300
+
parameters: [{ name: "verifier", type: "string" }],
301
+
returnType: "Promise<string>",
302
+
statements: [
303
+
`const encoder = new TextEncoder();`,
304
+
`const data = encoder.encode(verifier);`,
305
+
`const digest = await crypto.subtle.digest('SHA-256', data);`,
306
+
`return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(digest))))`,
307
+
` .replace(/\\+/g, '-')`,
308
+
` .replace(/\\//g, '_')`,
309
+
` .replace(/=/g, '');`,
310
+
],
311
+
},
312
+
{
313
+
name: "generatePKCEChallenge",
314
+
isStatic: true,
315
+
isAsync: true,
316
+
returnType: "Promise<PKCEChallenge>",
317
+
statements: [
318
+
`const codeVerifier = this.generateCodeVerifier();`,
319
+
`const codeChallenge = await this.generateCodeChallenge(codeVerifier);`,
320
+
`return {`,
321
+
` codeVerifier,`,
322
+
` codeChallenge,`,
323
+
` codeChallengeMethod: 'S256'`,
324
+
`};`,
325
+
],
326
+
},
327
+
],
328
+
});
329
+
}
330
+
331
+
// Add base client class with OAuth token management
211
332
function addBaseClientClass(): void {
212
333
sourceFile.addClass({
213
334
name: "BaseClient",
214
335
properties: [
215
336
{ name: "baseUrl", type: "string", scope: "protected", isReadonly: true },
337
+
{
338
+
name: "authBaseUrl",
339
+
type: "string",
340
+
scope: "protected",
341
+
isReadonly: true,
342
+
},
343
+
{
344
+
name: "clientId",
345
+
type: "string",
346
+
scope: "protected",
347
+
isReadonly: true,
348
+
},
349
+
{
350
+
name: "clientSecret",
351
+
type: "string",
352
+
scope: "protected",
353
+
isReadonly: true,
354
+
},
355
+
{
356
+
name: "tokenStorage",
357
+
type: "TokenStorage",
358
+
scope: "private",
359
+
isStatic: true,
360
+
initializer: "{}",
361
+
},
362
+
{
363
+
name: "refreshPromise",
364
+
type: "Promise<void>",
365
+
scope: "private",
366
+
hasQuestionToken: true,
367
+
},
216
368
],
217
369
ctors: [
218
370
{
219
-
parameters: [{ name: "baseUrl", type: "string" }],
220
-
statements: ["this.baseUrl = baseUrl;"],
371
+
parameters: [
372
+
{ name: "baseUrl", type: "string" },
373
+
{ name: "authBaseUrl", type: "string" },
374
+
{ name: "clientId", type: "string" },
375
+
{ name: "clientSecret", type: "string" },
376
+
],
377
+
statements: [
378
+
"this.baseUrl = baseUrl;",
379
+
"this.authBaseUrl = authBaseUrl;",
380
+
"this.clientId = clientId;",
381
+
"this.clientSecret = clientSecret;",
382
+
],
221
383
},
222
384
],
223
385
methods: [
224
386
{
387
+
name: "setTokens",
388
+
scope: "protected",
389
+
parameters: [{ name: "tokenResponse", type: "OAuthTokenResponse" }],
390
+
returnType: "void",
391
+
statements: [
392
+
`// Ensure token type is properly capitalized`,
393
+
`const tokenType = tokenResponse.token_type `,
394
+
` ? tokenResponse.token_type.charAt(0).toUpperCase() + tokenResponse.token_type.slice(1).toLowerCase()`,
395
+
` : "Bearer";`,
396
+
``,
397
+
`BaseClient.tokenStorage = {`,
398
+
` accessToken: tokenResponse.access_token,`,
399
+
` refreshToken: tokenResponse.refresh_token,`,
400
+
` tokenType: tokenType,`,
401
+
` scope: tokenResponse.scope,`,
402
+
` expiresAt: tokenResponse.expires_in`,
403
+
` ? Date.now() + (tokenResponse.expires_in * 1000)`,
404
+
` : undefined`,
405
+
`};`,
406
+
],
407
+
},
408
+
{
409
+
name: "isTokenExpired",
410
+
scope: "private",
411
+
returnType: "boolean",
412
+
statements: [
413
+
`if (!BaseClient.tokenStorage.expiresAt) return false;`,
414
+
`return Date.now() >= (BaseClient.tokenStorage.expiresAt - 30000);`,
415
+
],
416
+
},
417
+
{
418
+
name: "ensureValidToken",
419
+
scope: "private",
420
+
isAsync: true,
421
+
returnType: "Promise<void>",
422
+
statements: [
423
+
`if (!BaseClient.tokenStorage.accessToken) {`,
424
+
` throw new Error('No access token available. Please authenticate first.');`,
425
+
`}`,
426
+
``,
427
+
`if (!this.isTokenExpired()) {`,
428
+
` return;`,
429
+
`}`,
430
+
``,
431
+
`if (!BaseClient.tokenStorage.refreshToken) {`,
432
+
` throw new Error('Access token expired and no refresh token available. Please re-authenticate.');`,
433
+
`}`,
434
+
``,
435
+
`if (this.refreshPromise) {`,
436
+
` return this.refreshPromise;`,
437
+
`}`,
438
+
``,
439
+
`this.refreshPromise = this.refreshAccessToken();`,
440
+
`try {`,
441
+
` await this.refreshPromise;`,
442
+
`} finally {`,
443
+
` this.refreshPromise = undefined;`,
444
+
`}`,
445
+
],
446
+
},
447
+
{
448
+
name: "refreshAccessToken",
449
+
scope: "private",
450
+
isAsync: true,
451
+
returnType: "Promise<void>",
452
+
statements: [
453
+
`if (!BaseClient.tokenStorage.refreshToken) {`,
454
+
` throw new Error('No refresh token available');`,
455
+
`}`,
456
+
``,
457
+
`try {`,
458
+
` const response = await fetch(\`\${this.authBaseUrl}/oauth/token\`, {`,
459
+
` method: 'POST',`,
460
+
` headers: {`,
461
+
` 'Content-Type': 'application/x-www-form-urlencoded',`,
462
+
` },`,
463
+
` body: new URLSearchParams({`,
464
+
` grant_type: 'refresh_token',`,
465
+
` refresh_token: BaseClient.tokenStorage.refreshToken,`,
466
+
` client_id: this.clientId,`,
467
+
` client_secret: this.clientSecret,`,
468
+
` }),`,
469
+
` });`,
470
+
``,
471
+
` if (!response.ok) {`,
472
+
` throw new Error(\`Token refresh failed: \${response.status} \${response.statusText}\`);`,
473
+
` }`,
474
+
``,
475
+
` const tokenResponse: OAuthTokenResponse = await response.json();`,
476
+
` this.setTokens(tokenResponse);`,
477
+
`} catch (error) {`,
478
+
` BaseClient.tokenStorage = {};`,
479
+
` throw new Error(\`Failed to refresh token: \${error}\`);`,
480
+
`}`,
481
+
],
482
+
},
483
+
{
484
+
name: "getTokenInfo",
485
+
scope: "protected",
486
+
returnType: "{ hasToken: boolean; expiresAt?: number; scope?: string }",
487
+
statements: [
488
+
`return {`,
489
+
` hasToken: !!BaseClient.tokenStorage.accessToken,`,
490
+
` expiresAt: BaseClient.tokenStorage.expiresAt,`,
491
+
` scope: BaseClient.tokenStorage.scope,`,
492
+
`};`,
493
+
],
494
+
},
495
+
{
496
+
name: "clearTokens",
497
+
scope: "protected",
498
+
returnType: "void",
499
+
statements: [`BaseClient.tokenStorage = {};`],
500
+
},
501
+
{
502
+
name: "setTokensFromSession",
503
+
scope: "public",
504
+
parameters: [{ name: "tokens", type: "TokenStorage" }],
505
+
returnType: "void",
506
+
statements: [`BaseClient.tokenStorage = tokens;`],
507
+
},
508
+
{
509
+
name: "getTokenStorage",
510
+
scope: "public",
511
+
returnType: "TokenStorage",
512
+
statements: [`return BaseClient.tokenStorage;`],
513
+
},
514
+
{
225
515
name: "makeRequest",
226
516
scope: "protected",
227
517
isAsync: true,
···
237
527
returnType: "Promise<any>",
238
528
statements: [
239
529
`const httpMethod = method || 'GET';`,
240
-
`let url = \`\${this.baseUrl}/xrpc/\${endpoint}\`;`,
241
-
`let requestInit: RequestInit = {`,
242
-
` method: httpMethod`,
530
+
`let url = endpoint.startsWith('oauth/')`,
531
+
` ? \`\${this.authBaseUrl}/\${endpoint}\``,
532
+
` : \`\${this.baseUrl}/xrpc/\${endpoint}\`;`,
533
+
``,
534
+
`const requestInit: RequestInit = {`,
535
+
` method: httpMethod,`,
536
+
` headers: {}`,
243
537
`};`,
244
538
``,
539
+
`// Add authorization header for protected endpoints`,
540
+
`const needsAuth = !endpoint.startsWith('oauth/') || endpoint === 'oauth/userinfo';`,
541
+
`const needsClientAuth = endpoint === 'oauth/par' || endpoint === 'oauth/token';`,
542
+
``,
543
+
`if (needsAuth) {`,
544
+
` await this.ensureValidToken();`,
545
+
``,
546
+
` if (BaseClient.tokenStorage.accessToken) {`,
547
+
` (requestInit.headers as any)['Authorization'] =`,
548
+
` \`\${BaseClient.tokenStorage.tokenType} \${BaseClient.tokenStorage.accessToken}\`;`,
549
+
` }`,
550
+
`} else if (needsClientAuth) {`,
551
+
` // Use HTTP Basic Auth for client authentication`,
552
+
` const credentials = btoa(\`\${this.clientId}:\${this.clientSecret}\`);`,
553
+
` (requestInit.headers as any)['Authorization'] = \`Basic \${credentials}\`;`,
554
+
`}`,
555
+
``,
245
556
`if (httpMethod === 'GET' && params) {`,
246
-
` const searchParams = new URLSearchParams();`,
247
-
` Object.entries(params).forEach(([key, value]) => {`,
248
-
` if (value !== undefined && value !== null) {`,
249
-
` searchParams.append(key, String(value));`,
250
-
` }`,
251
-
` });`,
252
-
` const queryString = searchParams.toString();`,
253
-
` if (queryString) {`,
254
-
` url += '?' + queryString;`,
557
+
` const searchParams = new URLSearchParams();`,
558
+
` Object.entries(params).forEach(([key, value]) => {`,
559
+
` if (value !== undefined && value !== null) {`,
560
+
` searchParams.append(key, String(value));`,
255
561
` }`,
562
+
` });`,
563
+
` const queryString = searchParams.toString();`,
564
+
` if (queryString) {`,
565
+
` url += '?' + queryString;`,
566
+
` }`,
256
567
`} else if (httpMethod !== 'GET' && params) {`,
257
-
` requestInit.headers = { 'Content-Type': 'application/json' };`,
568
+
` if (endpoint.startsWith('oauth/') && endpoint !== 'oauth/userinfo') {`,
569
+
` // OAuth token endpoints expect form data`,
570
+
` (requestInit.headers as any)['Content-Type'] = 'application/x-www-form-urlencoded';`,
571
+
` requestInit.body = new URLSearchParams(params);`,
572
+
` } else {`,
573
+
` // Regular API endpoints and userinfo expect JSON`,
574
+
` (requestInit.headers as any)['Content-Type'] = 'application/json';`,
258
575
` requestInit.body = JSON.stringify(params);`,
576
+
` }`,
259
577
`}`,
260
578
``,
261
579
`const response = await fetch(url, requestInit);`,
262
580
`if (!response.ok) {`,
263
-
` throw new Error(\`Request failed: \${response.status} \${response.statusText}\`);`,
581
+
` throw new Error(\`Request failed: \${response.status} \${response.statusText}\`);`,
264
582
`}`,
265
583
`return await response.json();`,
266
584
],
···
344
662
parameters: [{ name: "params", type: "GetRecordParams" }],
345
663
returnType: `Promise<RecordResponse<${value}>>`,
346
664
});
665
+
// Add create, update, delete methods
666
+
methods.push({
667
+
name: "createRecord",
668
+
parameters: [{ name: "record", type: value as string }],
669
+
returnType: `Promise<{ uri: string; cid: string }>`,
670
+
});
671
+
methods.push({
672
+
name: "updateRecord",
673
+
parameters: [
674
+
{ name: "rkey", type: "string" },
675
+
{ name: "record", type: value as string }
676
+
],
677
+
returnType: `Promise<{ uri: string; cid: string }>`,
678
+
});
679
+
methods.push({
680
+
name: "deleteRecord",
681
+
parameters: [{ name: "rkey", type: "string" }],
682
+
returnType: `Promise<void>`,
683
+
});
347
684
} else if (key === "_collectionPath") {
348
685
collectionPath = value as string;
349
686
} else if (typeof value === "object" && Object.keys(value).length > 0) {
···
375
712
type: p.type,
376
713
isReadonly: true,
377
714
})),
715
+
// Add OAuth client to the main AtProtoClient
716
+
...(className === "Client"
717
+
? [{ name: "oauth", type: "OAuthClient", isReadonly: true }]
718
+
: []),
378
719
],
379
720
});
380
721
381
722
// Add constructor
382
723
const ctor = classDeclaration.addConstructor({
383
-
parameters: [{ name: "baseUrl", type: "string" }],
724
+
parameters: [
725
+
{ name: "baseUrl", type: "string" },
726
+
{ name: "authBaseUrl", type: "string" },
727
+
{ name: "clientId", type: "string" },
728
+
{ name: "clientSecret", type: "string" },
729
+
],
384
730
});
385
731
ctor.addStatements([
386
-
"super(baseUrl);",
387
-
...properties.map((p) => `this.${p.name} = new ${p.type}(baseUrl);`),
732
+
"super(baseUrl, authBaseUrl, clientId, clientSecret);",
733
+
...properties.map(
734
+
(p) => `this.${p.name} = new ${p.type}(baseUrl, authBaseUrl, clientId, clientSecret);`
735
+
),
736
+
// Add OAuth client initialization for main AtProtoClient
737
+
...(className === "Client"
738
+
? ["this.oauth = new OAuthClient(baseUrl, authBaseUrl, clientId, clientSecret);"]
739
+
: []),
388
740
]);
389
741
390
742
// Add methods with implementations
···
405
757
methodDecl.addStatements([
406
758
`return await this.makeRequest('${collectionPath}.get', 'GET', params);`,
407
759
]);
760
+
} else if (method.name === "createRecord") {
761
+
methodDecl.addStatements([
762
+
`const recordWithType = { $type: '${collectionPath}', ...record };`,
763
+
`return await this.makeRequest('${collectionPath}.create', 'POST', recordWithType);`,
764
+
]);
765
+
} else if (method.name === "updateRecord") {
766
+
methodDecl.addStatements([
767
+
`const recordWithType = { $type: '${collectionPath}', ...record };`,
768
+
`return await this.makeRequest('${collectionPath}.update', 'POST', { rkey, record: recordWithType });`,
769
+
]);
770
+
} else if (method.name === "deleteRecord") {
771
+
methodDecl.addStatements([
772
+
`return await this.makeRequest('${collectionPath}.delete', 'POST', { rkey });`,
773
+
]);
408
774
}
409
775
}
410
776
}
···
416
782
}
417
783
}
418
784
785
+
// Add OAuth client class
786
+
function addOAuthClientClass(): void {
787
+
sourceFile.addClass({
788
+
name: "OAuthClient",
789
+
extends: "BaseClient",
790
+
ctors: [
791
+
{
792
+
parameters: [
793
+
{ name: "baseUrl", type: "string" },
794
+
{ name: "authBaseUrl", type: "string" },
795
+
{ name: "clientId", type: "string" },
796
+
{ name: "clientSecret", type: "string" },
797
+
],
798
+
statements: ["super(baseUrl, authBaseUrl, clientId, clientSecret);"],
799
+
},
800
+
],
801
+
methods: [
802
+
{
803
+
name: "authorize",
804
+
isAsync: true,
805
+
parameters: [{ name: "params", type: "OAuthAuthorizeParams" }],
806
+
returnType: "Promise<OAuthAuthorizeResponse>",
807
+
statements: [
808
+
`const pkce = await PKCEUtils.generatePKCEChallenge();`,
809
+
`const state = params.state || this.generateState();`,
810
+
``,
811
+
`// Step 1: Push authorization request (PAR)`,
812
+
`const parParams = {`,
813
+
` client_id: this.clientId,`,
814
+
` response_type: 'code',`,
815
+
` redirect_uri: params.redirectUri,`,
816
+
` state,`,
817
+
` code_challenge: pkce.codeChallenge,`,
818
+
` code_challenge_method: pkce.codeChallengeMethod,`,
819
+
` scope: params.scope?.join(' ') || 'atproto:atproto atproto:transition:generic',`,
820
+
` login_hint: params.loginHint`,
821
+
`};`,
822
+
``,
823
+
`// POST to PAR endpoint`,
824
+
`const parResponse = await this.makeRequest('oauth/par', 'POST', parParams);`,
825
+
``,
826
+
`// Step 2: Build authorization URL with request_uri`,
827
+
`const authParams = new URLSearchParams({`,
828
+
` client_id: this.clientId,`,
829
+
` request_uri: parResponse.request_uri`,
830
+
`});`,
831
+
``,
832
+
`const authorizationUrl = \`\${this.authBaseUrl}/oauth/authorize?\${authParams.toString()}\`;`,
833
+
``,
834
+
`return {`,
835
+
` authorizationUrl,`,
836
+
` codeVerifier: pkce.codeVerifier,`,
837
+
` state`,
838
+
`};`,
839
+
],
840
+
},
841
+
{
842
+
name: "handleCallback",
843
+
isAsync: true,
844
+
parameters: [{ name: "params", type: "OAuthCallbackParams" }],
845
+
returnType: "Promise<void>",
846
+
statements: [
847
+
`const tokenResponse: OAuthTokenResponse = await this.makeRequest('oauth/token', 'POST', {`,
848
+
` grant_type: 'authorization_code',`,
849
+
` code: params.code,`,
850
+
` redirect_uri: params.redirectUri,`,
851
+
` client_id: this.clientId,`,
852
+
` client_secret: this.clientSecret,`,
853
+
` code_verifier: params.codeVerifier`,
854
+
`});`,
855
+
``,
856
+
`this.setTokens(tokenResponse);`,
857
+
],
858
+
},
859
+
{
860
+
name: "isAuthenticated",
861
+
returnType: "boolean",
862
+
statements: [`return this.getTokenInfo().hasToken;`],
863
+
},
864
+
{
865
+
name: "logout",
866
+
returnType: "void",
867
+
statements: [`this.clearTokens();`],
868
+
},
869
+
{
870
+
name: "getAuthenticationInfo",
871
+
returnType: "{ isAuthenticated: boolean; expiresAt?: number; scope?: string }",
872
+
statements: [
873
+
`const tokenInfo = this.getTokenInfo();`,
874
+
`return {`,
875
+
` isAuthenticated: tokenInfo.hasToken,`,
876
+
` expiresAt: tokenInfo.expiresAt,`,
877
+
` scope: tokenInfo.scope`,
878
+
`};`,
879
+
],
880
+
},
881
+
{
882
+
name: "getUserInfo",
883
+
isAsync: true,
884
+
returnType: "Promise<{ sub: string; did?: string } | null>",
885
+
statements: [
886
+
`if (!this.isAuthenticated()) {`,
887
+
` return null;`,
888
+
`}`,
889
+
``,
890
+
`try {`,
891
+
` const userInfo = await this.makeRequest('oauth/userinfo', 'GET');`,
892
+
` return userInfo;`,
893
+
`} catch (error) {`,
894
+
` console.error('Failed to fetch user info:', error);`,
895
+
` return null;`,
896
+
`}`,
897
+
],
898
+
},
899
+
{
900
+
name: "generateState",
901
+
scope: "private",
902
+
returnType: "string",
903
+
statements: [
904
+
`const array = new Uint8Array(16);`,
905
+
`crypto.getRandomValues(array);`,
906
+
`return btoa(String.fromCharCode.apply(null, Array.from(array)))`,
907
+
` .replace(/\\+/g, '-')`,
908
+
` .replace(/\\//g, '_')`,
909
+
` .replace(/=/g, '');`,
910
+
],
911
+
},
912
+
],
913
+
});
914
+
}
915
+
419
916
// Generate the TypeScript
420
917
addBaseInterfaces();
421
918
addLexiconInterfaces();
919
+
addPKCEUtilsClass();
422
920
addBaseClientClass();
921
+
addOAuthClientClass();
423
922
addClientClass();
424
923
425
924
// Get the generated code and add header
426
925
const generatedCode = sourceFile.getFullText();
427
-
const finalCode = headerComment + generatedCode;
926
+
const unformattedCode = headerComment + generatedCode;
927
+
928
+
// Format the code using deno fmt via temp file
929
+
async function formatCode(code: string): Promise<string> {
930
+
try {
931
+
// @ts-ignore
932
+
const tempFile = await Deno.makeTempFile({ suffix: ".ts" });
933
+
934
+
// Write unformatted code to temp file
935
+
// @ts-ignore
936
+
await Deno.writeTextFile(tempFile, code);
937
+
938
+
// Format the temp file
939
+
// @ts-ignore
940
+
const process = new Deno.Command("deno", {
941
+
args: ["fmt", tempFile],
942
+
stdout: "piped",
943
+
stderr: "piped",
944
+
});
945
+
946
+
const output = await process.output();
947
+
948
+
if (output.success) {
949
+
// Read the formatted code back
950
+
// @ts-ignore
951
+
const formattedCode = await Deno.readTextFile(tempFile);
952
+
// @ts-ignore
953
+
await Deno.remove(tempFile);
954
+
return formattedCode;
955
+
} else {
956
+
const error = new TextDecoder().decode(output.stderr);
957
+
console.warn("deno fmt failed, using unformatted code:", error);
958
+
// @ts-ignore
959
+
await Deno.remove(tempFile);
960
+
return code;
961
+
}
962
+
} catch (error) {
963
+
console.warn("deno fmt not available, using unformatted code:", error);
964
+
return code;
965
+
}
966
+
}
967
+
968
+
const finalCode = await formatCode(unformattedCode);
428
969
429
970
// Output to stdout for the Rust handler to capture
430
971
// @ts-ignore
+1
-154
api/scripts/generated_client.ts
+1
-154
api/scripts/generated_client.ts
···
1
-
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-08-18 03:54:49 UTC
3
-
// Lexicons: 2
4
-
5
-
export interface RecordResponse<T extends any> {
6
-
uri: string;
7
-
cid: string;
8
-
did: string;
9
-
collection: string;
10
-
value: T;
11
-
indexed_at: string;
12
-
}
13
-
14
-
export interface ListRecordsResponse<T extends any> {
15
-
records: RecordResponse<T>[];
16
-
cursor?: string;
17
-
}
18
-
19
-
export interface ListRecordsParams {
20
-
author?: string;
21
-
limit?: number;
22
-
cursor?: string;
23
-
}
24
-
25
-
export interface GetRecordParams {
26
-
uri: string;
27
-
}
28
-
29
-
export interface CollectionOperations<T> {
30
-
listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<T>>;
31
-
getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
32
-
}
33
-
34
-
export interface SocialGrainGalleryRecord {
35
-
createdAt: string;
36
-
description?: string;
37
-
/** Annotations of description text (mentions, URLs, hashtags, etc) */
38
-
facets?: any[];
39
-
/** Self-label values for this post. Effectively content warnings. */
40
-
labels?: any;
41
-
title: string;
42
-
updatedAt?: string;
43
-
}
44
-
45
-
export interface SocialGrainCommentRecord {
46
-
createdAt: string;
47
-
/** Annotations of description text (mentions and URLs, hashtags, etc) */
48
-
facets?: any[];
49
-
focus?: string;
50
-
replyTo?: string;
51
-
subject: string;
52
-
text: string;
53
-
}
54
-
55
-
class BaseClient {
56
-
protected readonly baseUrl: string;
57
-
58
-
constructor(baseUrl: string) {
59
-
this.baseUrl = baseUrl;
60
-
}
61
-
62
-
protected async makeRequest(endpoint: string, method?: "GET" | "POST" | "PUT" | "DELETE", params?: any): Promise<any> {
63
-
const httpMethod = method || 'GET';
64
-
let url = `${this.baseUrl}/xrpc/${endpoint}`;
65
-
let requestInit: RequestInit = {
66
-
method: httpMethod
67
-
};
68
-
69
-
if (httpMethod === 'GET' && params) {
70
-
const searchParams = new URLSearchParams();
71
-
Object.entries(params).forEach(([key, value]) => {
72
-
if (value !== undefined && value !== null) {
73
-
searchParams.append(key, String(value));
74
-
}
75
-
76
-
});
77
-
const queryString = searchParams.toString();
78
-
if (queryString) {
79
-
url += '?' + queryString;
80
-
}
81
-
82
-
} else if (httpMethod !== 'GET' && params) {
83
-
requestInit.headers = { 'Content-Type': 'application/json' };
84
-
requestInit.body = JSON.stringify(params);
85
-
}
86
-
87
-
88
-
89
-
const response = await fetch(url, requestInit);
90
-
if (!response.ok) {
91
-
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
92
-
}
93
-
94
-
return await response.json();
95
-
}
96
-
}
97
-
98
-
class GalleryGrainSocialClient extends BaseClient {
99
-
constructor(baseUrl: string) {
100
-
super(baseUrl);
101
-
}
102
-
103
-
async listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<SocialGrainGalleryRecord>> {
104
-
return await this.makeRequest('social.grain.gallery.list', 'GET', params);
105
-
}
106
-
107
-
async getRecord(params: GetRecordParams): Promise<RecordResponse<SocialGrainGalleryRecord>> {
108
-
return await this.makeRequest('social.grain.gallery.get', 'GET', params);
109
-
}
110
-
}
111
-
112
-
class CommentGrainSocialClient extends BaseClient {
113
-
constructor(baseUrl: string) {
114
-
super(baseUrl);
115
-
}
116
-
117
-
async listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<SocialGrainCommentRecord>> {
118
-
return await this.makeRequest('social.grain.comment.list', 'GET', params);
119
-
}
120
-
121
-
async getRecord(params: GetRecordParams): Promise<RecordResponse<SocialGrainCommentRecord>> {
122
-
return await this.makeRequest('social.grain.comment.get', 'GET', params);
123
-
}
124
-
}
125
-
126
-
class GrainSocialClient extends BaseClient {
127
-
readonly gallery: GalleryGrainSocialClient;
128
-
readonly comment: CommentGrainSocialClient;
129
-
130
-
constructor(baseUrl: string) {
131
-
super(baseUrl);
132
-
this.gallery = new GalleryGrainSocialClient(baseUrl);
133
-
this.comment = new CommentGrainSocialClient(baseUrl);
134
-
}
135
-
}
136
-
137
-
class SocialClient extends BaseClient {
138
-
readonly grain: GrainSocialClient;
139
-
140
-
constructor(baseUrl: string) {
141
-
super(baseUrl);
142
-
this.grain = new GrainSocialClient(baseUrl);
143
-
}
144
-
}
145
-
146
-
export class AtProtoClient extends BaseClient {
147
-
readonly social: SocialClient;
148
-
149
-
constructor(baseUrl: string) {
150
-
super(baseUrl);
151
-
this.social = new SocialClient(baseUrl);
152
-
}
153
-
}
154
-
1
+
null
+11
-3
api/src/database.rs
+11
-3
api/src/database.rs
···
63
63
Ok(())
64
64
}
65
65
66
-
#[allow(dead_code)]
67
-
pub async fn get_record(&self, uri: &str) -> Result<Option<Record>, DatabaseError> {
66
+
pub async fn get_record(&self, uri: &str) -> Result<Option<IndexedRecord>, DatabaseError> {
68
67
let record = sqlx::query_as::<_, Record>(
69
68
r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt"
70
69
FROM "record"
···
74
73
.fetch_optional(&self.pool)
75
74
.await?;
76
75
77
-
Ok(record)
76
+
let indexed_record = record.map(|record| IndexedRecord {
77
+
uri: record.uri,
78
+
cid: record.cid,
79
+
did: record.did,
80
+
collection: record.collection,
81
+
value: record.json,
82
+
indexed_at: record.indexed_at.to_rfc3339(),
83
+
});
84
+
85
+
Ok(indexed_record)
78
86
}
79
87
80
88
pub async fn list_records(&self, params: ListRecordsParams) -> Result<Vec<IndexedRecord>, DatabaseError> {
+316
-94
api/src/handler_dynamic_xrpc.rs
+316
-94
api/src/handler_dynamic_xrpc.rs
···
1
1
use axum::{
2
2
extract::{Path, Query, State},
3
-
http::StatusCode,
3
+
http::{HeaderMap, StatusCode},
4
4
response::Json,
5
5
};
6
6
use serde::{Deserialize, Serialize};
7
7
use chrono::Utc;
8
+
use atproto_client::{client::DPoPAuth, com::atproto::repo::{CreateRecordRequest, PutRecordRequest, DeleteRecordRequest, create_record, put_record, delete_record, CreateRecordResponse, PutRecordResponse}};
9
+
use atproto_identity::key::KeyData;
10
+
use atproto_oauth::jwk::WrappedJsonWebKey;
8
11
9
12
use crate::models::{ListRecordsParams, ListRecordsOutput, Record};
10
13
use crate::AppState;
···
50
53
pub cid: String,
51
54
}
52
55
56
+
#[derive(Serialize, Deserialize, Debug)]
57
+
pub struct UserInfoResponse {
58
+
sub: String,
59
+
did: Option<String>,
60
+
}
61
+
62
+
// Extract bearer token from Authorization header
63
+
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, StatusCode> {
64
+
let auth_header = headers
65
+
.get("authorization")
66
+
.and_then(|h| h.to_str().ok())
67
+
.ok_or(StatusCode::UNAUTHORIZED)?;
68
+
69
+
if !auth_header.starts_with("Bearer ") {
70
+
println!("Auth header does not start with 'Bearer ': {}", auth_header);
71
+
return Err(StatusCode::UNAUTHORIZED);
72
+
}
73
+
74
+
let token = auth_header.strip_prefix("Bearer ").unwrap().to_string();
75
+
println!("Extracted token: {}...", &token[..20.min(token.len())]);
76
+
Ok(token)
77
+
}
78
+
79
+
// Verify OAuth token with auth server
80
+
async fn verify_oauth_token(token: &str, auth_base_url: &str) -> Result<UserInfoResponse, StatusCode> {
81
+
let client = reqwest::Client::new();
82
+
let userinfo_url = format!("{}/oauth/userinfo", auth_base_url);
83
+
println!("Verifying token with URL: {}", userinfo_url);
84
+
println!("Token prefix: {}...", &token[..20.min(token.len())]);
85
+
86
+
let response = client
87
+
.get(&userinfo_url)
88
+
.header("Authorization", format!("Bearer {}", token))
89
+
.send()
90
+
.await
91
+
.map_err(|e| {
92
+
println!("Failed to send request to auth server: {}", e);
93
+
StatusCode::INTERNAL_SERVER_ERROR
94
+
})?;
95
+
96
+
println!("Auth server response status: {}", response.status());
97
+
98
+
if !response.status().is_success() {
99
+
let error_text = response.text().await.unwrap_or_else(|_| "unknown".to_string());
100
+
println!("Auth server error response: {}", error_text);
101
+
return Err(StatusCode::UNAUTHORIZED);
102
+
}
103
+
104
+
let user_info: UserInfoResponse = response
105
+
.json()
106
+
.await
107
+
.map_err(|e| {
108
+
println!("Failed to parse user info JSON: {}", e);
109
+
StatusCode::INTERNAL_SERVER_ERROR
110
+
})?;
111
+
112
+
println!("Successfully verified token for user: {:?}", user_info);
113
+
Ok(user_info)
114
+
}
115
+
116
+
// Get AT Protocol DPoP auth and PDS URL for the user
117
+
async fn get_atproto_auth_for_user(
118
+
token: &str,
119
+
auth_base_url: &str,
120
+
) -> Result<(DPoPAuth, String), StatusCode> {
121
+
// First get session info from auth server
122
+
let client = reqwest::Client::new();
123
+
let session_url = format!("{}/api/atprotocol/session", auth_base_url);
124
+
println!("Getting session info from: {}", session_url);
125
+
126
+
let session_response = client
127
+
.get(&session_url)
128
+
.header("Authorization", format!("Bearer {}", token))
129
+
.send()
130
+
.await
131
+
.map_err(|e| {
132
+
println!("Failed to get session info: {}", e);
133
+
StatusCode::INTERNAL_SERVER_ERROR
134
+
})?;
135
+
136
+
println!("Session response status: {}", session_response.status());
137
+
138
+
if !session_response.status().is_success() {
139
+
let error_text = session_response.text().await.unwrap_or_else(|_| "unknown".to_string());
140
+
println!("Session error response: {}", error_text);
141
+
return Err(StatusCode::UNAUTHORIZED);
142
+
}
143
+
144
+
let session_data: serde_json::Value = session_response
145
+
.json()
146
+
.await
147
+
.map_err(|e| {
148
+
println!("Failed to parse session JSON: {}", e);
149
+
StatusCode::INTERNAL_SERVER_ERROR
150
+
})?;
151
+
152
+
println!("Session data: {}", serde_json::to_string_pretty(&session_data).unwrap_or_else(|_| "invalid".to_string()));
153
+
154
+
// Extract PDS URL from session
155
+
let pds_url = session_data["pds_endpoint"]
156
+
.as_str()
157
+
.ok_or_else(|| {
158
+
println!("No pds_endpoint found in session data");
159
+
StatusCode::INTERNAL_SERVER_ERROR
160
+
})?
161
+
.to_string();
162
+
163
+
println!("Extracted PDS URL: {}", pds_url);
164
+
165
+
// Extract AT Protocol access token from session data
166
+
let atproto_access_token = session_data["access_token"]
167
+
.as_str()
168
+
.ok_or_else(|| {
169
+
println!("No access_token found in session data");
170
+
StatusCode::INTERNAL_SERVER_ERROR
171
+
})?
172
+
.to_string();
173
+
174
+
println!("Using AT Protocol access token: {}...", &atproto_access_token[..20.min(atproto_access_token.len())]);
175
+
176
+
// Extract DPoP private key from session data - convert JWK to KeyData
177
+
let dpop_jwk: WrappedJsonWebKey = serde_json::from_value(session_data["dpop_jwk"].clone())
178
+
.map_err(|e| {
179
+
println!("Failed to deserialize dpop_jwk: {}", e);
180
+
StatusCode::INTERNAL_SERVER_ERROR
181
+
})?;
182
+
183
+
println!("Parsed DPoP JWK successfully");
184
+
185
+
let dpop_private_key_data = KeyData::try_from(dpop_jwk)
186
+
.map_err(|e| {
187
+
println!("Failed to convert JWK to KeyData: {}", e);
188
+
StatusCode::INTERNAL_SERVER_ERROR
189
+
})?;
190
+
191
+
println!("Successfully created KeyData from DPoP JWK");
192
+
193
+
let dpop_auth = DPoPAuth {
194
+
dpop_private_key_data,
195
+
oauth_access_token: atproto_access_token,
196
+
};
197
+
198
+
Ok((dpop_auth, pds_url))
199
+
}
200
+
53
201
// Dynamic XRPC handler that routes based on method name (for GET requests)
54
202
pub async fn dynamic_xrpc_handler(
55
203
Path(method): Path<String>,
···
72
220
pub async fn dynamic_xrpc_post_handler(
73
221
Path(method): Path<String>,
74
222
State(state): State<AppState>,
223
+
headers: HeaderMap,
75
224
Json(body): Json<serde_json::Value>,
76
225
) -> Result<Json<serde_json::Value>, StatusCode> {
77
-
if method == "com.atproto.repo.createRecord" {
78
-
dynamic_create_record_impl(state, body).await
79
-
} else if method == "com.atproto.repo.putRecord" {
80
-
dynamic_update_record_impl(state, body).await
81
-
} else if method == "com.atproto.repo.deleteRecord" {
82
-
dynamic_delete_record_impl(state, body).await
226
+
println!("=== DYNAMIC POST HANDLER CALLED ===");
227
+
println!("Method: {}", method);
228
+
229
+
// Handle dynamic collection methods (e.g., social.grain.gallery.create)
230
+
if method.ends_with(".create") {
231
+
let collection = method.trim_end_matches(".create").to_string();
232
+
dynamic_collection_create_impl(state, headers, body, collection).await
233
+
} else if method.ends_with(".update") {
234
+
let collection = method.trim_end_matches(".update").to_string();
235
+
dynamic_collection_update_impl(state, headers, body, collection).await
236
+
} else if method.ends_with(".delete") {
237
+
let collection = method.trim_end_matches(".delete").to_string();
238
+
dynamic_collection_delete_impl(state, headers, body, collection).await
83
239
} else {
84
240
Err(StatusCode::NOT_FOUND)
85
241
}
···
117
273
118
274
// Implementation for get record
119
275
async fn dynamic_get_record_impl(
120
-
collection: String,
276
+
_collection: String,
121
277
state: AppState,
122
278
params: serde_json::Value,
123
279
) -> Result<Json<serde_json::Value>, StatusCode> {
124
280
let get_params: GetRecordParams = serde_json::from_value(params)
125
281
.map_err(|_| StatusCode::BAD_REQUEST)?;
126
282
127
-
// Extract the record key from the URI
128
-
// AT Protocol URIs are like: at://did:plc:example/collection/rkey
129
-
let uri_parts: Vec<&str> = get_params.uri.split('/').collect();
130
-
if uri_parts.len() < 3 {
131
-
return Err(StatusCode::BAD_REQUEST);
132
-
}
133
-
134
-
// For now, we'll use the existing list_records with a filter
135
-
// In a real implementation, you'd want a dedicated get_record method
136
-
let list_params = ListRecordsParams {
137
-
collection,
138
-
author: None,
139
-
limit: Some(1),
140
-
cursor: None,
141
-
};
142
-
143
-
match state.database.list_records(list_params).await {
144
-
Ok(records) => {
145
-
// Find the record with matching URI
146
-
if let Some(record) = records.into_iter().find(|r| r.uri == get_params.uri) {
147
-
let json_value = serde_json::to_value(record)
148
-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
149
-
Ok(Json(json_value))
150
-
} else {
151
-
Err(StatusCode::NOT_FOUND)
152
-
}
283
+
// Use direct database query by URI for efficiency
284
+
match state.database.get_record(&get_params.uri).await {
285
+
Ok(Some(record)) => {
286
+
let json_value = serde_json::to_value(record)
287
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
288
+
Ok(Json(json_value))
153
289
},
154
-
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
290
+
Ok(None) => {
291
+
Err(StatusCode::NOT_FOUND)
292
+
},
293
+
Err(e) => {
294
+
println!("Database error: {:?}", e);
295
+
Err(StatusCode::INTERNAL_SERVER_ERROR)
296
+
},
155
297
}
156
298
}
157
299
158
-
// Implementation for create record
159
-
async fn dynamic_create_record_impl(
300
+
// Dynamic collection create (e.g., social.grain.gallery.create)
301
+
async fn dynamic_collection_create_impl(
160
302
state: AppState,
303
+
headers: HeaderMap,
161
304
body: serde_json::Value,
305
+
collection: String,
162
306
) -> Result<Json<serde_json::Value>, StatusCode> {
163
-
let params: CreateRecordParams = serde_json::from_value(body)
164
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
307
+
// Debug logging removed - slice creation is working
308
+
309
+
// Extract and verify OAuth token
310
+
let token = extract_bearer_token(&headers)?;
311
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await?;
312
+
313
+
// Get AT Protocol DPoP auth and PDS URL
314
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?;
165
315
166
-
// Generate a record key if not provided (using timestamp)
167
-
let rkey = params.rkey.unwrap_or_else(|| {
168
-
// Simple TID-like generation using timestamp
169
-
let now = Utc::now();
170
-
now.format("%Y%m%dT%H%M%S").to_string()
171
-
});
316
+
// Extract the repo DID from user info
317
+
let repo = user_info.did.unwrap_or(user_info.sub);
318
+
319
+
// Create HTTP client
320
+
let http_client = reqwest::Client::new();
321
+
322
+
// Create record using AT Protocol functions with DPoP
323
+
let create_request = CreateRecordRequest {
324
+
repo: repo.clone(),
325
+
collection: collection.clone(),
326
+
record_key: None, // Let PDS generate
327
+
record: body.clone(),
328
+
swap_commit: None,
329
+
validate: false,
330
+
};
331
+
332
+
println!("About to create record with PDS: {}", pds_url);
333
+
println!("Create request: repo={}, collection={}", create_request.repo, create_request.collection);
334
+
println!("Record data: {}", serde_json::to_string_pretty(&create_request.record).unwrap_or_else(|_| "invalid".to_string()));
335
+
336
+
let result = create_record(&http_client, &dpop_auth, &pds_url, create_request)
337
+
.await
338
+
.map_err(|e| {
339
+
println!("Failed to create record: {}", e);
340
+
StatusCode::INTERNAL_SERVER_ERROR
341
+
})?;
342
+
343
+
println!("Create record result: {:?}", result);
172
344
173
-
// Construct the AT-URI
174
-
let uri = format!("at://{}/{}/{}", params.repo, params.collection, rkey);
175
-
176
-
// Generate a simple CID (in a real implementation, this would be a proper CID)
177
-
let cid = format!("baf{}", &uri.chars().take(50).collect::<String>().replace(":", "").replace("/", ""));
345
+
// Extract URI and CID from the response enum
346
+
let (uri, cid) = match result {
347
+
CreateRecordResponse::StrongRef { uri, cid, .. } => (uri, cid),
348
+
CreateRecordResponse::Error(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
349
+
};
178
350
351
+
// Also store in local database for indexing
179
352
let record = Record {
180
353
uri: uri.clone(),
181
354
cid: cid.clone(),
182
-
did: params.repo,
183
-
collection: params.collection,
184
-
json: params.record,
355
+
did: repo,
356
+
collection,
357
+
json: body,
185
358
indexed_at: Utc::now(),
186
359
};
187
360
188
-
match state.database.insert_record(&record).await {
189
-
Ok(_) => {
190
-
let output = CreateRecordOutput { uri, cid };
191
-
let json_value = serde_json::to_value(output)
192
-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
193
-
Ok(Json(json_value))
194
-
},
195
-
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
196
-
}
361
+
// Store in local database (ignore errors as AT Protocol operation succeeded)
362
+
let _ = state.database.insert_record(&record).await;
363
+
364
+
Ok(Json(serde_json::json!({
365
+
"uri": uri,
366
+
"cid": cid,
367
+
})))
197
368
}
198
369
199
-
// Implementation for update record
200
-
async fn dynamic_update_record_impl(
370
+
// Dynamic collection update (e.g., social.grain.gallery.update)
371
+
async fn dynamic_collection_update_impl(
201
372
state: AppState,
373
+
headers: HeaderMap,
202
374
body: serde_json::Value,
375
+
collection: String,
203
376
) -> Result<Json<serde_json::Value>, StatusCode> {
204
-
let params: UpdateRecordParams = serde_json::from_value(body)
205
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
377
+
// Extract and verify OAuth token
378
+
let token = extract_bearer_token(&headers)?;
379
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await?;
380
+
381
+
// Get AT Protocol DPoP auth and PDS URL
382
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?;
383
+
384
+
// Extract repo and rkey from body
385
+
let repo = user_info.did.unwrap_or(user_info.sub);
386
+
let rkey = body["rkey"].as_str().ok_or(StatusCode::BAD_REQUEST)?.to_string();
387
+
let record_data = body["record"].clone();
388
+
389
+
// Create HTTP client
390
+
let http_client = reqwest::Client::new();
206
391
207
-
let uri = format!("at://{}/{}/{}", params.repo, params.collection, params.rkey);
208
-
209
-
// Generate a new CID for the updated record
210
-
let cid = format!("baf{}", &uri.chars().take(50).collect::<String>().replace(":", "").replace("/", ""));
392
+
// Update record using AT Protocol functions with DPoP
393
+
let put_request = PutRecordRequest {
394
+
repo: repo.clone(),
395
+
collection: collection.clone(),
396
+
record_key: rkey,
397
+
record: record_data.clone(),
398
+
swap_record: None,
399
+
swap_commit: None,
400
+
validate: false,
401
+
};
402
+
403
+
let result = put_record(&http_client, &dpop_auth, &pds_url, put_request)
404
+
.await
405
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
406
+
407
+
// Extract URI and CID from the response enum
408
+
let (uri, cid) = match result {
409
+
PutRecordResponse::StrongRef { uri, cid, .. } => (uri, cid),
410
+
PutRecordResponse::Error(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
411
+
};
211
412
413
+
// Also update in local database for indexing
212
414
let record = Record {
213
415
uri: uri.clone(),
214
416
cid: cid.clone(),
215
-
did: params.repo,
216
-
collection: params.collection,
217
-
json: params.record,
417
+
did: repo,
418
+
collection,
419
+
json: record_data,
218
420
indexed_at: Utc::now(),
219
421
};
220
422
221
-
match state.database.update_record(&record).await {
222
-
Ok(_) => {
223
-
let output = CreateRecordOutput { uri, cid };
224
-
let json_value = serde_json::to_value(output)
225
-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
226
-
Ok(Json(json_value))
227
-
},
228
-
Err(_) => Err(StatusCode::NOT_FOUND),
229
-
}
423
+
// Update in local database (ignore errors as AT Protocol operation succeeded)
424
+
let _ = state.database.update_record(&record).await;
425
+
426
+
Ok(Json(serde_json::json!({
427
+
"uri": uri,
428
+
"cid": cid,
429
+
})))
230
430
}
231
431
232
-
// Implementation for delete record
233
-
async fn dynamic_delete_record_impl(
432
+
// Dynamic collection delete (e.g., social.grain.gallery.delete)
433
+
async fn dynamic_collection_delete_impl(
234
434
state: AppState,
435
+
headers: HeaderMap,
235
436
body: serde_json::Value,
437
+
collection: String,
236
438
) -> Result<Json<serde_json::Value>, StatusCode> {
237
-
let params: DeleteRecordParams = serde_json::from_value(body)
238
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
439
+
// Extract and verify OAuth token
440
+
let token = extract_bearer_token(&headers)?;
441
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await?;
442
+
443
+
// Get AT Protocol DPoP auth and PDS URL
444
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?;
445
+
446
+
// Extract repo and rkey from body
447
+
let repo = user_info.did.unwrap_or(user_info.sub);
448
+
let rkey = body["rkey"].as_str().ok_or(StatusCode::BAD_REQUEST)?.to_string();
449
+
450
+
// Create HTTP client
451
+
let http_client = reqwest::Client::new();
452
+
453
+
// Delete record using AT Protocol functions with DPoP
454
+
let delete_request = DeleteRecordRequest {
455
+
repo: repo.clone(),
456
+
collection: collection.clone(),
457
+
record_key: rkey.clone(),
458
+
swap_record: None,
459
+
swap_commit: None,
460
+
};
461
+
462
+
delete_record(&http_client, &dpop_auth, &pds_url, delete_request)
463
+
.await
464
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
239
465
240
-
let uri = format!("at://{}/{}/{}", params.repo, params.collection, params.rkey);
466
+
// Also delete from local database
467
+
let uri = format!("at://{}/{}/{}", repo, collection, rkey);
468
+
let _ = state.database.delete_record(&uri).await;
241
469
242
-
match state.database.delete_record(&uri).await {
243
-
Ok(_) => {
244
-
// Return empty success response
245
-
Ok(Json(serde_json::json!({})))
246
-
},
247
-
Err(_) => Err(StatusCode::NOT_FOUND),
248
-
}
470
+
Ok(Json(serde_json::json!({})))
249
471
}
-37
api/src/handler_oauth.rs
-37
api/src/handler_oauth.rs
···
1
-
use axum::{
2
-
extract::State,
3
-
http::StatusCode,
4
-
response::Json,
5
-
};
6
-
use serde::{Deserialize, Serialize};
7
-
8
-
use crate::AppState;
9
-
10
-
#[derive(Deserialize)]
11
-
pub struct OAuthAuthorizeParams {
12
-
pub handle: String,
13
-
}
14
-
15
-
#[derive(Serialize)]
16
-
pub struct OAuthAuthorizeResponse {
17
-
pub success: bool,
18
-
pub message: String,
19
-
}
20
-
21
-
pub async fn oauth_authorize(
22
-
State(_state): State<AppState>,
23
-
Json(params): Json<OAuthAuthorizeParams>,
24
-
) -> Result<Json<OAuthAuthorizeResponse>, StatusCode> {
25
-
// TODO: Implement OAuth authorize flow
26
-
// 1. Resolve handle to DID
27
-
// 2. Discover user's PDS
28
-
// 3. Initiate OAuth flow with user's PDS
29
-
// 4. Return authorization URL or handle the callback
30
-
31
-
let response = OAuthAuthorizeResponse {
32
-
success: true,
33
-
message: format!("OAuth authorize initiated for handle: {}", params.handle),
34
-
};
35
-
36
-
Ok(Json(response))
37
-
}
+59
-31
api/src/main.rs
+59
-31
api/src/main.rs
···
4
4
mod handler_codegen;
5
5
mod handler_dynamic_xrpc;
6
6
mod handler_lexicon;
7
-
mod handler_oauth;
8
7
mod handler_upload_lexicon;
9
8
mod handler_xrpc_codegen;
10
9
mod models;
···
13
12
mod web;
14
13
15
14
use axum::{
15
+
Router,
16
16
extract::{Query, State},
17
17
http::StatusCode,
18
18
response::Json,
19
19
routing::{get, post},
20
-
Router,
21
20
};
22
21
use sqlx::PgPool;
23
22
use std::env;
24
23
use tower_http::{cors::CorsLayer, trace::TraceLayer};
25
-
use tracing::{info, Level};
24
+
use tracing::info;
26
25
use tracing_subscriber;
27
26
28
27
use crate::database::Database;
29
28
use crate::errors::AppError;
30
-
use crate::models::{BulkSyncOutput, BulkSyncParams, ListRecordsOutput, ListRecordsParams, SmartSyncParams};
29
+
use crate::models::{
30
+
BulkSyncOutput, BulkSyncParams, ListRecordsOutput, ListRecordsParams, SmartSyncParams,
31
+
};
31
32
use crate::sync::SyncService;
32
33
use crate::web::WebService;
33
34
34
35
#[derive(Clone)]
36
+
pub struct Config {
37
+
pub auth_base_url: String,
38
+
}
39
+
40
+
#[derive(Clone)]
35
41
pub struct AppState {
36
42
database: Database,
37
43
sync_service: SyncService,
38
44
#[allow(dead_code)]
39
45
web_service: WebService,
46
+
config: Config,
40
47
}
41
48
42
49
#[tokio::main]
···
46
53
47
54
// Initialize tracing
48
55
tracing_subscriber::fmt()
49
-
.with_max_level(Level::INFO)
56
+
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
50
57
.init();
51
58
52
59
// Database connection
···
62
69
let sync_service = SyncService::new(database.clone());
63
70
let web_service = WebService::new();
64
71
72
+
let auth_base_url = env::var("AUTH_BASE_URL")
73
+
.unwrap_or_else(|_| "https://auth.grainsocial.network".to_string());
74
+
75
+
let config = Config {
76
+
auth_base_url,
77
+
};
78
+
65
79
let state = AppState {
66
80
database: database.clone(),
67
81
sync_service,
68
82
web_service,
83
+
config,
69
84
};
70
85
71
86
// Build application with routes
···
74
89
.route("/xrpc/com.indexer.records.list", get(list_records))
75
90
.route("/xrpc/com.indexer.collections.bulkSync", post(bulk_sync))
76
91
.route("/xrpc/com.indexer.repos.smartSync", post(smart_sync))
77
-
.route("/xrpc/com.indexer.codegen.generate", post(handler_xrpc_codegen::generate_client_xrpc))
92
+
.route(
93
+
"/xrpc/com.indexer.codegen.generate",
94
+
post(handler_xrpc_codegen::generate_client_xrpc),
95
+
)
78
96
// Dynamic collection-specific XRPC endpoints
79
-
.route("/xrpc/*method", get(handler_dynamic_xrpc::dynamic_xrpc_handler))
80
-
.route("/xrpc/*method", post(handler_dynamic_xrpc::dynamic_xrpc_post_handler))
81
-
// OAuth endpoints
82
-
.route("/oauth/authorize", post(handler_oauth::oauth_authorize))
97
+
.route(
98
+
"/xrpc/*method",
99
+
get(handler_dynamic_xrpc::dynamic_xrpc_handler),
100
+
)
101
+
.route(
102
+
"/xrpc/*method",
103
+
post(handler_dynamic_xrpc::dynamic_xrpc_post_handler),
104
+
)
83
105
// Web interface
84
106
.route("/", get(web::index))
85
107
.route("/records", get(web::records_page))
···
88
110
.route("/codegen", get(web::codegen_page))
89
111
.route("/codegen/generate", post(handler_codegen::generate_client))
90
112
.route("/lexicon", get(handler_lexicon::lexicon_page))
91
-
.route("/upload-lexicons", post(handler_upload_lexicon::upload_lexicons))
113
+
.route(
114
+
"/upload-lexicons",
115
+
post(handler_upload_lexicon::upload_lexicons),
116
+
)
92
117
.layer(TraceLayer::new_for_http())
93
118
.layer(CorsLayer::permissive())
94
119
.with_state(state);
···
117
142
State(state): State<AppState>,
118
143
axum::extract::Json(params): axum::extract::Json<BulkSyncParams>,
119
144
) -> Result<Json<BulkSyncOutput>, StatusCode> {
120
-
match state.sync_service.backfill_collections(¶ms.collections, params.repos.as_deref()).await {
145
+
match state
146
+
.sync_service
147
+
.backfill_collections(¶ms.collections, params.repos.as_deref())
148
+
.await
149
+
{
121
150
Ok(_) => {
122
151
let total_records = state.database.get_total_record_count().await.unwrap_or(0);
123
152
Ok(Json(BulkSyncOutput {
···
127
156
repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0),
128
157
message: "Bulk sync completed successfully".to_string(),
129
158
}))
130
-
},
131
-
Err(e) => {
132
-
Ok(Json(BulkSyncOutput {
133
-
success: false,
134
-
total_records: 0,
135
-
collections_synced: vec![],
136
-
repos_processed: 0,
137
-
message: format!("Bulk sync failed: {}", e),
138
-
}))
139
159
}
160
+
Err(e) => Ok(Json(BulkSyncOutput {
161
+
success: false,
162
+
total_records: 0,
163
+
collections_synced: vec![],
164
+
repos_processed: 0,
165
+
message: format!("Bulk sync failed: {}", e),
166
+
})),
140
167
}
141
168
}
142
169
···
154
181
total_records,
155
182
collections_synced: params.collections.unwrap_or_default(),
156
183
repos_processed: 1,
157
-
message: format!("Smart sync completed for {}: {} records", params.did, records_count),
158
-
}))
159
-
},
160
-
Err(e) => {
161
-
Ok(Json(BulkSyncOutput {
162
-
success: false,
163
-
total_records: 0,
164
-
collections_synced: vec![],
165
-
repos_processed: 0,
166
-
message: format!("Smart sync failed for {}: {}", params.did, e),
184
+
message: format!(
185
+
"Smart sync completed for {}: {} records",
186
+
params.did, records_count
187
+
),
167
188
}))
168
189
}
190
+
Err(e) => Ok(Json(BulkSyncOutput {
191
+
success: false,
192
+
total_records: 0,
193
+
collections_synced: vec![],
194
+
repos_processed: 0,
195
+
message: format!("Smart sync failed for {}: {}", params.did, e),
196
+
})),
169
197
}
170
198
}
+23
frontend/components/Layout.tsx
frontend/src/components/Layout.tsx
+23
frontend/components/Layout.tsx
frontend/src/components/Layout.tsx
···
3
3
interface LayoutProps {
4
4
title?: string;
5
5
children: JSX.Element | JSX.Element[];
6
+
currentUser?: { handle?: string; isAuthenticated: boolean };
6
7
}
7
8
8
9
export function Layout({
9
10
title = "Slice",
10
11
children,
12
+
currentUser,
11
13
}: LayoutProps) {
12
14
return (
13
15
<html lang="en">
···
67
69
<a href="/" className="text-xl font-bold hover:text-blue-200">
68
70
Slice
69
71
</a>
72
+
<div className="flex items-center space-x-4">
73
+
{currentUser?.isAuthenticated ? (
74
+
<div className="flex items-center space-x-3">
75
+
<span className="text-sm text-blue-100">
76
+
{currentUser.handle || "Authenticated User"}
77
+
</span>
78
+
<form method="post" action="/logout" className="inline">
79
+
<button
80
+
type="submit"
81
+
className="text-sm bg-blue-700 hover:bg-blue-800 px-3 py-1 rounded"
82
+
>
83
+
Logout
84
+
</button>
85
+
</form>
86
+
</div>
87
+
) : (
88
+
<a href="/login" className="text-sm bg-blue-700 hover:bg-blue-800 px-3 py-1 rounded">
89
+
Login
90
+
</a>
91
+
)}
92
+
</div>
70
93
</div>
71
94
</nav>
72
95
<main className="container mx-auto mt-8 px-4">{children}</main>
+4
-3
frontend/deno.json
+4
-3
frontend/deno.json
···
1
1
{
2
2
"tasks": {
3
-
"start": "deno run --allow-net main.tsx",
4
-
"dev": "deno run --allow-net --watch main.tsx"
3
+
"start": "deno run -A --env-file=.env src/main.tsx",
4
+
"dev": "deno run -A --env-file=.env --watch src/main.tsx"
5
5
},
6
6
"fmt": {
7
7
"useTabs": false,
···
20
20
"imports": {
21
21
"preact": "npm:preact@^10.27.1",
22
22
"preact-render-to-string": "npm:preact-render-to-string@^6.5.13",
23
-
"typed-htmx": "npm:typed-htmx@^0.3.1"
23
+
"typed-htmx": "npm:typed-htmx@^0.3.1",
24
+
"@std/http": "jsr:@std/http@^1.0.20"
24
25
},
25
26
"nodeModulesDir": "auto"
26
27
}
+7
frontend/deno.lock
+7
frontend/deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
+
"jsr:@std/http@^1.0.20": "1.0.20",
4
5
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1",
5
6
"npm:preact@^10.27.1": "10.27.1",
6
7
"npm:typed-htmx@~0.3.1": "0.3.1"
8
+
},
9
+
"jsr": {
10
+
"@std/http@1.0.20": {
11
+
"integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1"
12
+
}
7
13
},
8
14
"npm": {
9
15
"preact-render-to-string@6.5.13_preact@10.27.1": {
···
27
33
},
28
34
"workspace": {
29
35
"dependencies": [
36
+
"jsr:@std/http@^1.0.20",
30
37
"npm:preact-render-to-string@^6.5.13",
31
38
"npm:preact@^10.27.1",
32
39
"npm:typed-htmx@~0.3.1"
-131
frontend/main.tsx
-131
frontend/main.tsx
···
1
-
import { render } from "preact-render-to-string";
2
-
import { IndexPage } from "./pages/IndexPage.tsx";
3
-
import { LoginPage } from "./pages/LoginPage.tsx";
4
-
import { SlicePage } from "./pages/SlicePage.tsx";
5
-
import { SliceCodegenPage } from "./pages/SliceCodegenPage.tsx";
6
-
import { SliceLexiconPage } from "./pages/SliceLexiconPage.tsx";
7
-
import { SliceRecordsPage } from "./pages/SliceRecordsPage.tsx";
8
-
import { SliceSyncPage } from "./pages/SliceSyncPage.tsx";
9
-
10
-
const handler = (req: Request): Response => {
11
-
const url = new URL(req.url);
12
-
const pathname = url.pathname;
13
-
14
-
let html: string;
15
-
16
-
// Parse slice routes
17
-
const sliceMatch = pathname.match(/^\/slices\/([^\/]+)(.*)$/);
18
-
19
-
if (pathname === "/") {
20
-
// Slice list page
21
-
const indexData = {
22
-
slices: [
23
-
{ id: "example", name: "Example Slice", createdAt: "2024-01-15T10:00:00Z" },
24
-
{ id: "demo", name: "Demo Slice", createdAt: "2024-01-14T15:30:00Z" },
25
-
],
26
-
};
27
-
html = render(<IndexPage slices={indexData.slices} />);
28
-
} else if (pathname === "/login") {
29
-
// Login page
30
-
html = render(<LoginPage />);
31
-
} else if (sliceMatch) {
32
-
const sliceId = sliceMatch[1];
33
-
const subPath = sliceMatch[2] || "";
34
-
35
-
const mockSliceData = {
36
-
sliceId,
37
-
sliceName: sliceId === "example" ? "Example Slice" : "Demo Slice",
38
-
totalRecords: 1250,
39
-
collections: [
40
-
{ name: "com.chadtmiller.slice", count: 5 },
41
-
{ name: "social.grain.gallery", count: 850 },
42
-
{ name: "social.grain.comment", count: 400 },
43
-
],
44
-
};
45
-
46
-
switch (subPath) {
47
-
case "": {
48
-
// Slice overview page
49
-
html = render(<SlicePage {...mockSliceData} currentTab="overview" />);
50
-
break;
51
-
}
52
-
53
-
case "/records": {
54
-
// Slice records page
55
-
const recordsData = {
56
-
...mockSliceData,
57
-
records: [
58
-
{
59
-
uri: `at://did:plc:example/com.chadtmiller.slice/3k2a4b5c6d`,
60
-
indexed_at: "2024-01-15 12:45:00",
61
-
collection: "com.chadtmiller.slice",
62
-
did: "did:plc:example",
63
-
cid: "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua",
64
-
value: true,
65
-
pretty_value: `{\n "name": "${mockSliceData.sliceName}",\n "createdAt": "2024-01-15T12:45:00.000Z",\n "$type": "com.chadtmiller.slice"\n}`,
66
-
},
67
-
],
68
-
availableCollections: mockSliceData.collections,
69
-
};
70
-
html = render(<SliceRecordsPage {...recordsData} />);
71
-
break;
72
-
}
73
-
74
-
case "/sync": {
75
-
html = render(<SliceSyncPage {...mockSliceData} />);
76
-
break;
77
-
}
78
-
79
-
case "/lexicon": {
80
-
const lexiconData = {
81
-
...mockSliceData,
82
-
lexicons: [
83
-
{
84
-
nsid: "com.chadtmiller.slice",
85
-
updated_at: "2024-01-15 10:30:00",
86
-
pretty_definitions: `{\n "lexicon": 1,\n "id": "com.chadtmiller.slice",\n "defs": {\n "main": {\n "type": "record",\n "description": "Slice application record type"\n }\n }\n}`,
87
-
},
88
-
],
89
-
};
90
-
html = render(<SliceLexiconPage {...lexiconData} />);
91
-
break;
92
-
}
93
-
94
-
case "/codegen": {
95
-
const codegenData = {
96
-
...mockSliceData,
97
-
lexicons: [
98
-
{ nsid: "com.chadtmiller.slice" },
99
-
{ nsid: "social.grain.gallery" },
100
-
],
101
-
};
102
-
html = render(<SliceCodegenPage {...codegenData} />);
103
-
break;
104
-
}
105
-
106
-
default:
107
-
// 404 for unknown slice subpaths
108
-
return Response.redirect(new URL("/", req.url), 302);
109
-
}
110
-
} else {
111
-
// 404 page - redirect to home for now
112
-
return Response.redirect(new URL("/", req.url), 302);
113
-
}
114
-
115
-
return new Response(`<!DOCTYPE html>${html}`, {
116
-
status: 200,
117
-
headers: {
118
-
"content-type": "text/html",
119
-
},
120
-
});
121
-
};
122
-
123
-
Deno.serve(
124
-
{
125
-
port: 8000,
126
-
onListen: () => {
127
-
console.log("Frontend server running on http://localhost:8000");
128
-
},
129
-
},
130
-
handler
131
-
);
+15
-4
frontend/pages/IndexPage.tsx
frontend/src/pages/IndexPage.tsx
+15
-4
frontend/pages/IndexPage.tsx
frontend/src/pages/IndexPage.tsx
···
8
8
9
9
interface IndexPageProps {
10
10
slices?: Slice[];
11
+
currentUser?: { handle?: string; isAuthenticated: boolean };
11
12
}
12
13
13
-
export function IndexPage({ slices = [] }: IndexPageProps) {
14
+
export function IndexPage({ slices = [], currentUser }: IndexPageProps) {
14
15
return (
15
-
<Layout title="Slices">
16
+
<Layout title="Slices" currentUser={currentUser}>
16
17
<div className="max-w-4xl mx-auto">
17
18
<div className="flex justify-between items-center mb-8">
18
19
<h1 className="text-3xl font-bold text-gray-800">
19
20
Slices
20
21
</h1>
21
-
<button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
22
+
<button
23
+
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
24
+
hx-get="/dialogs/create-slice"
25
+
hx-target="body"
26
+
hx-swap="beforeend"
27
+
>
22
28
+ Create Slice
23
29
</button>
24
30
</div>
···
89
95
<p className="text-gray-500 mb-6">
90
96
Create your first slice to get started organizing your AT Protocol data.
91
97
</p>
92
-
<button className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded">
98
+
<button
99
+
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded"
100
+
hx-get="/dialogs/create-slice"
101
+
hx-target="body"
102
+
hx-swap="beforeend"
103
+
>
93
104
Create Your First Slice
94
105
</button>
95
106
</div>
+8
-7
frontend/pages/LoginPage.tsx
frontend/src/pages/LoginPage.tsx
+8
-7
frontend/pages/LoginPage.tsx
frontend/src/pages/LoginPage.tsx
···
2
2
3
3
interface LoginPageProps {
4
4
error?: string;
5
+
currentUser?: { handle?: string; isAuthenticated: boolean };
5
6
}
6
7
7
-
export function LoginPage({ error }: LoginPageProps) {
8
+
export function LoginPage({ error, currentUser }: LoginPageProps) {
8
9
return (
9
-
<Layout title="Login - Slice">
10
+
<Layout title="Login - Slice" currentUser={currentUser}>
10
11
<div className="max-w-md mx-auto mt-16">
11
12
<div className="bg-white rounded-lg shadow-md p-8">
12
13
<div className="text-center mb-8">
···
24
25
</div>
25
26
)}
26
27
27
-
<form method="post" action="/login" className="space-y-6">
28
+
<form method="post" action="/oauth/authorize" className="space-y-6">
28
29
<div>
29
30
<label
30
-
htmlFor="handle"
31
+
htmlFor="loginHint"
31
32
className="block text-sm font-medium text-gray-700 mb-2"
32
33
>
33
34
AT Protocol Handle
34
35
</label>
35
36
<input
36
37
type="text"
37
-
id="handle"
38
-
name="handle"
38
+
id="loginHint"
39
+
name="loginHint"
39
40
placeholder="alice.bsky.social"
40
41
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
41
42
required
···
49
50
type="submit"
50
51
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-md transition-colors"
51
52
>
52
-
Sign In
53
+
Sign In with OAuth
53
54
</button>
54
55
</form>
55
56
+4
-1
frontend/pages/SliceCodegenPage.tsx
frontend/src/pages/SliceCodegenPage.tsx
+4
-1
frontend/pages/SliceCodegenPage.tsx
frontend/src/pages/SliceCodegenPage.tsx
···
3
3
interface SliceCodegenPageProps {
4
4
sliceName?: string;
5
5
sliceId?: string;
6
+
currentUser?: { handle?: string; isAuthenticated: boolean };
6
7
}
7
8
8
9
export function SliceCodegenPage({
9
10
sliceName = "My Slice",
10
11
sliceId = "example",
12
+
currentUser,
11
13
}: SliceCodegenPageProps) {
12
14
const tabs = [
13
15
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
···
15
17
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
16
18
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
17
19
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
20
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
18
21
];
19
22
20
23
return (
21
-
<Layout title={`${sliceName} - Code Generation`}>
24
+
<Layout title={`${sliceName} - Code Generation`} currentUser={currentUser}>
22
25
<div className="max-w-4xl mx-auto">
23
26
<div className="flex items-center justify-between mb-8">
24
27
<div className="flex items-center">
+4
-1
frontend/pages/SliceLexiconPage.tsx
frontend/src/pages/SliceLexiconPage.tsx
+4
-1
frontend/pages/SliceLexiconPage.tsx
frontend/src/pages/SliceLexiconPage.tsx
···
3
3
interface SliceLexiconPageProps {
4
4
sliceName?: string;
5
5
sliceId?: string;
6
+
currentUser?: { handle?: string; isAuthenticated: boolean };
6
7
}
7
8
8
9
export function SliceLexiconPage({
9
10
sliceName = "My Slice",
10
11
sliceId = "example",
12
+
currentUser,
11
13
}: SliceLexiconPageProps) {
12
14
const tabs = [
13
15
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
···
15
17
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
16
18
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
17
19
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
20
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
18
21
];
19
22
20
23
return (
21
-
<Layout title={`${sliceName} - Lexicons`}>
24
+
<Layout title={`${sliceName} - Lexicons`} currentUser={currentUser}>
22
25
<div className="max-w-4xl mx-auto">
23
26
<div className="flex items-center justify-between mb-8">
24
27
<div className="flex items-center">
+4
-1
frontend/pages/SlicePage.tsx
frontend/src/pages/SlicePage.tsx
+4
-1
frontend/pages/SlicePage.tsx
frontend/src/pages/SlicePage.tsx
···
11
11
sliceName?: string;
12
12
sliceId?: string;
13
13
currentTab?: string;
14
+
currentUser?: { handle?: string; isAuthenticated: boolean };
14
15
}
15
16
16
17
export function SlicePage({
···
19
20
sliceName = "My Slice",
20
21
sliceId = "example",
21
22
currentTab = "overview",
23
+
currentUser,
22
24
}: SlicePageProps) {
23
25
const tabs = [
24
26
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
···
26
28
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
27
29
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
28
30
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
31
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
29
32
];
30
33
31
34
return (
32
-
<Layout title={sliceName}>
35
+
<Layout title={sliceName} currentUser={currentUser}>
33
36
<div className="max-w-4xl mx-auto">
34
37
<div className="flex items-center justify-between mb-8">
35
38
<div className="flex items-center">
+4
-1
frontend/pages/SliceRecordsPage.tsx
frontend/src/pages/SliceRecordsPage.tsx
+4
-1
frontend/pages/SliceRecordsPage.tsx
frontend/src/pages/SliceRecordsPage.tsx
···
22
22
author?: string;
23
23
sliceName?: string;
24
24
sliceId?: string;
25
+
currentUser?: { handle?: string; isAuthenticated: boolean };
25
26
}
26
27
27
28
export function SliceRecordsPage({
···
31
32
author = "",
32
33
sliceName = "My Slice",
33
34
sliceId = "example",
35
+
currentUser,
34
36
}: SliceRecordsPageProps) {
35
37
const tabs = [
36
38
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
···
38
40
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
39
41
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
40
42
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
43
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
41
44
];
42
45
43
46
return (
44
-
<Layout title={`${sliceName} - Records`}>
47
+
<Layout title={`${sliceName} - Records`} currentUser={currentUser}>
45
48
<div className="max-w-4xl mx-auto">
46
49
<div className="flex items-center justify-between mb-8">
47
50
<div className="flex items-center">
+4
-1
frontend/pages/SliceSyncPage.tsx
frontend/src/pages/SliceSyncPage.tsx
+4
-1
frontend/pages/SliceSyncPage.tsx
frontend/src/pages/SliceSyncPage.tsx
···
3
3
interface SliceSyncPageProps {
4
4
sliceName?: string;
5
5
sliceId?: string;
6
+
currentUser?: { handle?: string; isAuthenticated: boolean };
6
7
}
7
8
8
9
export function SliceSyncPage({
9
10
sliceName = "My Slice",
10
11
sliceId = "example",
12
+
currentUser,
11
13
}: SliceSyncPageProps) {
12
14
const tabs = [
13
15
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
···
15
17
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
16
18
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
17
19
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
20
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
18
21
];
19
22
20
23
return (
21
-
<Layout title={`${sliceName} - Sync`}>
24
+
<Layout title={`${sliceName} - Sync`} currentUser={currentUser}>
22
25
<div className="max-w-4xl mx-auto">
23
26
<div className="flex items-center justify-between mb-8">
24
27
<div className="flex items-center">
+123
frontend/scripts/register-oauth-client.sh
+123
frontend/scripts/register-oauth-client.sh
···
1
+
#!/bin/bash
2
+
3
+
# OAuth Dynamic Client Registration Script for AT Protocol
4
+
# Registers a new OAuth client with the AIP server per RFC 7591
5
+
# Usage: bash scripts/register-oauth-client.sh
6
+
7
+
set -e # Exit on any error
8
+
9
+
# Configuration
10
+
AIP_BASE="${AIP_BASE_URL:-http://localhost:8081}"
11
+
CLIENT_BASE_URL="${CLIENT_BASE_URL:-http://localhost:8080}"
12
+
CLIENT_NAME="${CLIENT_NAME:-Slice AT Proto Client}"
13
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
15
+
CONFIG_FILE="$ROOT_DIR/.env"
16
+
17
+
echo "🚀 OAuth Dynamic Client Registration for Slice"
18
+
echo "AIP Server: $AIP_BASE"
19
+
echo "Client Base URL: $CLIENT_BASE_URL"
20
+
echo "Client Name: $CLIENT_NAME"
21
+
echo
22
+
23
+
# Check if client is already registered
24
+
if [ -f "$CONFIG_FILE" ]; then
25
+
echo "⚠️ Existing OAuth client configuration found at $CONFIG_FILE"
26
+
echo -n "Do you want to register a new client? This will overwrite the existing config. (y/N): "
27
+
read -r OVERWRITE
28
+
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
29
+
echo "❌ Registration cancelled"
30
+
exit 1
31
+
fi
32
+
fi
33
+
34
+
echo "🔍 Using OAuth registration endpoint..."
35
+
REGISTRATION_ENDPOINT="$AIP_BASE/oauth/clients/register"
36
+
37
+
echo "✅ Registration endpoint: $REGISTRATION_ENDPOINT"
38
+
echo
39
+
40
+
# Create client registration request
41
+
echo "📝 Creating client registration request..."
42
+
REDIRECT_URI="$CLIENT_BASE_URL/oauth/callback"
43
+
44
+
REGISTRATION_REQUEST=$(cat <<EOF
45
+
{
46
+
"client_name": "$CLIENT_NAME",
47
+
"redirect_uris": ["$REDIRECT_URI"],
48
+
"scope": "atproto:atproto atproto:transition:generic",
49
+
"grant_types": ["authorization_code", "refresh_token"],
50
+
"response_types": ["code"],
51
+
"token_endpoint_auth_method": "client_secret_basic"
52
+
}
53
+
EOF
54
+
)
55
+
56
+
echo "Registration request:"
57
+
echo "$REGISTRATION_REQUEST" | jq '.' 2>/dev/null || echo "$REGISTRATION_REQUEST"
58
+
echo
59
+
60
+
# Register the client
61
+
echo "🔄 Registering client with AIP server..."
62
+
REGISTRATION_RESPONSE=$(curl -s -X POST "$REGISTRATION_ENDPOINT" \
63
+
-H "Content-Type: application/json" \
64
+
-d "$REGISTRATION_REQUEST" || {
65
+
echo "❌ Failed to register client with AIP server"
66
+
echo "Make sure the AIP server is running at $AIP_BASE"
67
+
exit 1
68
+
})
69
+
70
+
echo "Registration response:"
71
+
echo "$REGISTRATION_RESPONSE" | jq '.' 2>/dev/null || echo "$REGISTRATION_RESPONSE"
72
+
echo
73
+
74
+
# Extract client credentials
75
+
CLIENT_ID=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_id":"[^"]*' | cut -d'"' -f4)
76
+
CLIENT_SECRET=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_secret":"[^"]*' | cut -d'"' -f4)
77
+
78
+
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then
79
+
echo "❌ Failed to extract client credentials from registration response"
80
+
echo "Expected client_id and client_secret in response"
81
+
echo "Response was: $REGISTRATION_RESPONSE"
82
+
exit 1
83
+
fi
84
+
85
+
echo "✅ Client registered successfully!"
86
+
echo "Client ID: $CLIENT_ID"
87
+
echo "Client Secret: [REDACTED]"
88
+
echo
89
+
90
+
# Save credentials to .env.oauth file
91
+
echo "💾 Saving client credentials to $CONFIG_FILE..."
92
+
cat > "$CONFIG_FILE" <<EOF
93
+
# OAuth Client Credentials for Slice AT Proto Client
94
+
# Generated on $(date)
95
+
# AIP Server: $AIP_BASE
96
+
97
+
OAUTH_CLIENT_ID="$CLIENT_ID"
98
+
OAUTH_CLIENT_SECRET="$CLIENT_SECRET"
99
+
OAUTH_REDIRECT_URI="$REDIRECT_URI"
100
+
OAUTH_AIP_BASE_URL="$AIP_BASE"
101
+
EOF
102
+
103
+
echo "✅ Client registration complete!"
104
+
echo
105
+
echo "📋 Summary:"
106
+
echo " - Client ID: $CLIENT_ID"
107
+
echo " - Client Name: $CLIENT_NAME"
108
+
echo " - Redirect URI: $REDIRECT_URI"
109
+
echo " - Scopes: atproto:atproto atproto:transition:generic"
110
+
echo " - Config saved to: $CONFIG_FILE"
111
+
echo
112
+
echo "🔧 Environment variables saved to $CONFIG_FILE:"
113
+
echo " OAUTH_CLIENT_ID"
114
+
echo " OAUTH_CLIENT_SECRET"
115
+
echo " OAUTH_REDIRECT_URI"
116
+
echo " OAUTH_AIP_BASE_URL"
117
+
echo
118
+
echo "💡 To use these credentials in your application:"
119
+
echo " source $CONFIG_FILE"
120
+
echo " # Or load them in your .env file"
121
+
echo
122
+
echo "🧪 To test the OAuth flow, you can now use the registered credentials"
123
+
echo " with your AtProtoClient in TypeScript/Deno."
+527
frontend/src/client.ts
+527
frontend/src/client.ts
···
1
+
// Generated TypeScript client for AT Protocol records
2
+
// Generated at: 2025-08-21 16:58:09 UTC
3
+
// Lexicons: 1
4
+
5
+
export interface OAuthAuthorizeParams {
6
+
loginHint: string;
7
+
redirectUri: string;
8
+
scope?: string[];
9
+
state?: string;
10
+
}
11
+
12
+
export interface OAuthAuthorizeResponse {
13
+
authorizationUrl: string;
14
+
codeVerifier: string;
15
+
state: string;
16
+
}
17
+
18
+
export interface OAuthCallbackParams {
19
+
code: string;
20
+
state: string;
21
+
codeVerifier: string;
22
+
redirectUri: string;
23
+
}
24
+
25
+
export interface OAuthTokenResponse {
26
+
access_token: string;
27
+
token_type: string;
28
+
expires_in?: number;
29
+
refresh_token?: string;
30
+
scope?: string;
31
+
}
32
+
33
+
export interface PKCEChallenge {
34
+
codeVerifier: string;
35
+
codeChallenge: string;
36
+
codeChallengeMethod: "S256";
37
+
}
38
+
39
+
export interface TokenStorage {
40
+
accessToken?: string;
41
+
refreshToken?: string;
42
+
expiresAt?: number;
43
+
tokenType?: string;
44
+
scope?: string;
45
+
}
46
+
47
+
export interface RecordResponse<T extends any> {
48
+
uri: string;
49
+
cid: string;
50
+
did: string;
51
+
collection: string;
52
+
value: T;
53
+
indexed_at: string;
54
+
}
55
+
56
+
export interface ListRecordsResponse<T extends any> {
57
+
records: RecordResponse<T>[];
58
+
cursor?: string;
59
+
}
60
+
61
+
export interface ListRecordsParams {
62
+
author?: string;
63
+
limit?: number;
64
+
cursor?: string;
65
+
}
66
+
67
+
export interface GetRecordParams {
68
+
uri: string;
69
+
}
70
+
71
+
export interface CollectionOperations<T> {
72
+
listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<T>>;
73
+
getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
74
+
}
75
+
76
+
export interface XyzSliceatSliceRecord {
77
+
/** When the slice was created */
78
+
createdAt: string;
79
+
/** Name of the slice */
80
+
name: string;
81
+
}
82
+
83
+
export class PKCEUtils {
84
+
static generateCodeVerifier(): string {
85
+
const array = new Uint8Array(32);
86
+
crypto.getRandomValues(array);
87
+
return btoa(String.fromCharCode.apply(null, Array.from(array)))
88
+
.replace(/\+/g, "-")
89
+
.replace(/\//g, "_")
90
+
.replace(/=/g, "");
91
+
}
92
+
93
+
static async generateCodeChallenge(verifier: string): Promise<string> {
94
+
const encoder = new TextEncoder();
95
+
const data = encoder.encode(verifier);
96
+
const digest = await crypto.subtle.digest("SHA-256", data);
97
+
return btoa(
98
+
String.fromCharCode.apply(null, Array.from(new Uint8Array(digest)))
99
+
)
100
+
.replace(/\+/g, "-")
101
+
.replace(/\//g, "_")
102
+
.replace(/=/g, "");
103
+
}
104
+
105
+
static async generatePKCEChallenge(): Promise<PKCEChallenge> {
106
+
const codeVerifier = this.generateCodeVerifier();
107
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
108
+
return {
109
+
codeVerifier,
110
+
codeChallenge,
111
+
codeChallengeMethod: "S256",
112
+
};
113
+
}
114
+
}
115
+
116
+
class BaseClient {
117
+
protected readonly baseUrl: string;
118
+
protected readonly authBaseUrl: string;
119
+
protected readonly clientId: string;
120
+
protected readonly clientSecret: string;
121
+
private static tokenStorage: TokenStorage = {};
122
+
private refreshPromise?: Promise<void>;
123
+
124
+
constructor(
125
+
baseUrl: string,
126
+
authBaseUrl: string,
127
+
clientId: string,
128
+
clientSecret: string
129
+
) {
130
+
this.baseUrl = baseUrl;
131
+
this.authBaseUrl = authBaseUrl;
132
+
this.clientId = clientId;
133
+
this.clientSecret = clientSecret;
134
+
}
135
+
136
+
protected setTokens(tokenResponse: OAuthTokenResponse): void {
137
+
// Ensure token type is properly capitalized
138
+
const tokenType = tokenResponse.token_type
139
+
? tokenResponse.token_type.charAt(0).toUpperCase() +
140
+
tokenResponse.token_type.slice(1).toLowerCase()
141
+
: "Bearer";
142
+
143
+
BaseClient.tokenStorage = {
144
+
accessToken: tokenResponse.access_token,
145
+
refreshToken: tokenResponse.refresh_token,
146
+
tokenType: tokenType,
147
+
scope: tokenResponse.scope,
148
+
expiresAt: tokenResponse.expires_in
149
+
? Date.now() + tokenResponse.expires_in * 1000
150
+
: undefined,
151
+
};
152
+
}
153
+
154
+
private isTokenExpired(): boolean {
155
+
if (!BaseClient.tokenStorage.expiresAt) return false;
156
+
return Date.now() >= BaseClient.tokenStorage.expiresAt - 30000;
157
+
}
158
+
159
+
private async ensureValidToken(): Promise<void> {
160
+
if (!BaseClient.tokenStorage.accessToken) {
161
+
throw new Error("No access token available. Please authenticate first.");
162
+
}
163
+
164
+
if (!this.isTokenExpired()) {
165
+
return;
166
+
}
167
+
168
+
if (!BaseClient.tokenStorage.refreshToken) {
169
+
throw new Error(
170
+
"Access token expired and no refresh token available. Please re-authenticate."
171
+
);
172
+
}
173
+
174
+
if (this.refreshPromise) {
175
+
return this.refreshPromise;
176
+
}
177
+
178
+
this.refreshPromise = this.refreshAccessToken();
179
+
try {
180
+
await this.refreshPromise;
181
+
} finally {
182
+
this.refreshPromise = undefined;
183
+
}
184
+
}
185
+
186
+
private async refreshAccessToken(): Promise<void> {
187
+
if (!BaseClient.tokenStorage.refreshToken) {
188
+
throw new Error("No refresh token available");
189
+
}
190
+
191
+
try {
192
+
const response = await fetch(`${this.authBaseUrl}/oauth/token`, {
193
+
method: "POST",
194
+
headers: {
195
+
"Content-Type": "application/x-www-form-urlencoded",
196
+
},
197
+
body: new URLSearchParams({
198
+
grant_type: "refresh_token",
199
+
refresh_token: BaseClient.tokenStorage.refreshToken,
200
+
client_id: this.clientId,
201
+
client_secret: this.clientSecret,
202
+
}),
203
+
});
204
+
205
+
if (!response.ok) {
206
+
throw new Error(
207
+
`Token refresh failed: ${response.status} ${response.statusText}`
208
+
);
209
+
}
210
+
211
+
const tokenResponse: OAuthTokenResponse = await response.json();
212
+
this.setTokens(tokenResponse);
213
+
} catch (error) {
214
+
BaseClient.tokenStorage = {};
215
+
throw new Error(`Failed to refresh token: ${error}`);
216
+
}
217
+
}
218
+
219
+
protected getTokenInfo(): {
220
+
hasToken: boolean;
221
+
expiresAt?: number;
222
+
scope?: string;
223
+
} {
224
+
return {
225
+
hasToken: !!BaseClient.tokenStorage.accessToken,
226
+
expiresAt: BaseClient.tokenStorage.expiresAt,
227
+
scope: BaseClient.tokenStorage.scope,
228
+
};
229
+
}
230
+
231
+
protected clearTokens(): void {
232
+
BaseClient.tokenStorage = {};
233
+
}
234
+
235
+
public setTokensFromSession(tokens: TokenStorage): void {
236
+
BaseClient.tokenStorage = tokens;
237
+
}
238
+
239
+
public getTokenStorage(): TokenStorage {
240
+
return BaseClient.tokenStorage;
241
+
}
242
+
243
+
protected async makeRequest(
244
+
endpoint: string,
245
+
method?: "GET" | "POST" | "PUT" | "DELETE",
246
+
params?: any
247
+
): Promise<any> {
248
+
const httpMethod = method || "GET";
249
+
let url = endpoint.startsWith("oauth/")
250
+
? `${this.authBaseUrl}/${endpoint}`
251
+
: `${this.baseUrl}/xrpc/${endpoint}`;
252
+
253
+
const requestInit: RequestInit = {
254
+
method: httpMethod,
255
+
headers: {},
256
+
};
257
+
258
+
// Add authorization header for protected endpoints
259
+
const needsAuth =
260
+
!endpoint.startsWith("oauth/") || endpoint === "oauth/userinfo";
261
+
const needsClientAuth =
262
+
endpoint === "oauth/par" || endpoint === "oauth/token";
263
+
264
+
if (needsAuth) {
265
+
await this.ensureValidToken();
266
+
267
+
if (BaseClient.tokenStorage.accessToken) {
268
+
(requestInit.headers as any)[
269
+
"Authorization"
270
+
] = `${BaseClient.tokenStorage.tokenType} ${BaseClient.tokenStorage.accessToken}`;
271
+
}
272
+
} else if (needsClientAuth) {
273
+
// Use HTTP Basic Auth for client authentication
274
+
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
275
+
(requestInit.headers as any)["Authorization"] = `Basic ${credentials}`;
276
+
}
277
+
278
+
if (httpMethod === "GET" && params) {
279
+
const searchParams = new URLSearchParams();
280
+
Object.entries(params).forEach(([key, value]) => {
281
+
if (value !== undefined && value !== null) {
282
+
searchParams.append(key, String(value));
283
+
}
284
+
});
285
+
const queryString = searchParams.toString();
286
+
if (queryString) {
287
+
url += "?" + queryString;
288
+
}
289
+
} else if (httpMethod !== "GET" && params) {
290
+
if (endpoint.startsWith("oauth/") && endpoint !== "oauth/userinfo") {
291
+
// OAuth token endpoints expect form data
292
+
(requestInit.headers as any)["Content-Type"] =
293
+
"application/x-www-form-urlencoded";
294
+
requestInit.body = new URLSearchParams(params);
295
+
} else {
296
+
// Regular API endpoints and userinfo expect JSON
297
+
(requestInit.headers as any)["Content-Type"] = "application/json";
298
+
requestInit.body = JSON.stringify(params);
299
+
}
300
+
}
301
+
302
+
const response = await fetch(url, requestInit);
303
+
if (!response.ok) {
304
+
throw new Error(
305
+
`Request failed: ${response.status} ${response.statusText}`
306
+
);
307
+
}
308
+
309
+
return await response.json();
310
+
}
311
+
}
312
+
313
+
class OAuthClient extends BaseClient {
314
+
constructor(
315
+
baseUrl: string,
316
+
authBaseUrl: string,
317
+
clientId: string,
318
+
clientSecret: string
319
+
) {
320
+
super(baseUrl, authBaseUrl, clientId, clientSecret);
321
+
}
322
+
323
+
async authorize(
324
+
params: OAuthAuthorizeParams
325
+
): Promise<OAuthAuthorizeResponse> {
326
+
const pkce = await PKCEUtils.generatePKCEChallenge();
327
+
const state = params.state || this.generateState();
328
+
329
+
// Step 1: Push authorization request (PAR)
330
+
const parParams = {
331
+
client_id: this.clientId,
332
+
response_type: "code",
333
+
redirect_uri: params.redirectUri,
334
+
state,
335
+
code_challenge: pkce.codeChallenge,
336
+
code_challenge_method: pkce.codeChallengeMethod,
337
+
scope:
338
+
params.scope?.join(" ") || "atproto:atproto atproto:transition:generic",
339
+
login_hint: params.loginHint,
340
+
};
341
+
342
+
// POST to PAR endpoint
343
+
const parResponse = await this.makeRequest("oauth/par", "POST", parParams);
344
+
345
+
// Step 2: Build authorization URL with request_uri
346
+
const authParams = new URLSearchParams({
347
+
client_id: this.clientId,
348
+
request_uri: parResponse.request_uri,
349
+
});
350
+
351
+
const authorizationUrl = `${
352
+
this.authBaseUrl
353
+
}/oauth/authorize?${authParams.toString()}`;
354
+
355
+
return {
356
+
authorizationUrl,
357
+
codeVerifier: pkce.codeVerifier,
358
+
state,
359
+
};
360
+
}
361
+
362
+
async handleCallback(params: OAuthCallbackParams): Promise<void> {
363
+
const tokenResponse: OAuthTokenResponse = await this.makeRequest(
364
+
"oauth/token",
365
+
"POST",
366
+
{
367
+
grant_type: "authorization_code",
368
+
code: params.code,
369
+
redirect_uri: params.redirectUri,
370
+
client_id: this.clientId,
371
+
client_secret: this.clientSecret,
372
+
code_verifier: params.codeVerifier,
373
+
}
374
+
);
375
+
376
+
this.setTokens(tokenResponse);
377
+
}
378
+
379
+
isAuthenticated(): boolean {
380
+
return this.getTokenInfo().hasToken;
381
+
}
382
+
383
+
logout(): void {
384
+
this.clearTokens();
385
+
}
386
+
387
+
getAuthenticationInfo(): {
388
+
isAuthenticated: boolean;
389
+
expiresAt?: number;
390
+
scope?: string;
391
+
} {
392
+
const tokenInfo = this.getTokenInfo();
393
+
return {
394
+
isAuthenticated: tokenInfo.hasToken,
395
+
expiresAt: tokenInfo.expiresAt,
396
+
scope: tokenInfo.scope,
397
+
};
398
+
}
399
+
400
+
async getUserInfo(): Promise<{ sub: string; did?: string } | null> {
401
+
if (!this.isAuthenticated()) {
402
+
return null;
403
+
}
404
+
405
+
try {
406
+
const userInfo = await this.makeRequest("oauth/userinfo", "GET");
407
+
return userInfo;
408
+
} catch (error) {
409
+
console.error("Failed to fetch user info:", error);
410
+
return null;
411
+
}
412
+
}
413
+
414
+
private generateState(): string {
415
+
const array = new Uint8Array(16);
416
+
crypto.getRandomValues(array);
417
+
return btoa(String.fromCharCode.apply(null, Array.from(array)))
418
+
.replace(/\+/g, "-")
419
+
.replace(/\//g, "_")
420
+
.replace(/=/g, "");
421
+
}
422
+
}
423
+
424
+
class SliceSliceatXyzClient extends BaseClient {
425
+
constructor(
426
+
baseUrl: string,
427
+
authBaseUrl: string,
428
+
clientId: string,
429
+
clientSecret: string
430
+
) {
431
+
super(baseUrl, authBaseUrl, clientId, clientSecret);
432
+
}
433
+
434
+
async listRecords(
435
+
params?: ListRecordsParams
436
+
): Promise<ListRecordsResponse<XyzSliceatSliceRecord>> {
437
+
return await this.makeRequest("xyz.sliceat.slice.list", "GET", params);
438
+
}
439
+
440
+
async getRecord(
441
+
params: GetRecordParams
442
+
): Promise<RecordResponse<XyzSliceatSliceRecord>> {
443
+
return await this.makeRequest("xyz.sliceat.slice.get", "GET", params);
444
+
}
445
+
446
+
async createRecord(
447
+
record: XyzSliceatSliceRecord
448
+
): Promise<{ uri: string; cid: string }> {
449
+
const recordWithType = {
450
+
$type: "xyz.sliceat.slice",
451
+
...record,
452
+
};
453
+
return await this.makeRequest("xyz.sliceat.slice.create", "POST", recordWithType);
454
+
}
455
+
456
+
async updateRecord(
457
+
rkey: string,
458
+
record: XyzSliceatSliceRecord
459
+
): Promise<{ uri: string; cid: string }> {
460
+
const recordWithType = {
461
+
$type: "xyz.sliceat.slice",
462
+
...record,
463
+
};
464
+
return await this.makeRequest("xyz.sliceat.slice.update", "POST", {
465
+
rkey,
466
+
record: recordWithType,
467
+
});
468
+
}
469
+
470
+
async deleteRecord(rkey: string): Promise<void> {
471
+
return await this.makeRequest("xyz.sliceat.slice.delete", "POST", { rkey });
472
+
}
473
+
}
474
+
475
+
class SliceatXyzClient extends BaseClient {
476
+
readonly slice: SliceSliceatXyzClient;
477
+
478
+
constructor(
479
+
baseUrl: string,
480
+
authBaseUrl: string,
481
+
clientId: string,
482
+
clientSecret: string
483
+
) {
484
+
super(baseUrl, authBaseUrl, clientId, clientSecret);
485
+
this.slice = new SliceSliceatXyzClient(
486
+
baseUrl,
487
+
authBaseUrl,
488
+
clientId,
489
+
clientSecret
490
+
);
491
+
}
492
+
}
493
+
494
+
class XyzClient extends BaseClient {
495
+
readonly sliceat: SliceatXyzClient;
496
+
497
+
constructor(
498
+
baseUrl: string,
499
+
authBaseUrl: string,
500
+
clientId: string,
501
+
clientSecret: string
502
+
) {
503
+
super(baseUrl, authBaseUrl, clientId, clientSecret);
504
+
this.sliceat = new SliceatXyzClient(
505
+
baseUrl,
506
+
authBaseUrl,
507
+
clientId,
508
+
clientSecret
509
+
);
510
+
}
511
+
}
512
+
513
+
export class AtProtoClient extends BaseClient {
514
+
readonly xyz: XyzClient;
515
+
readonly oauth: OAuthClient;
516
+
517
+
constructor(
518
+
baseUrl: string,
519
+
authBaseUrl: string,
520
+
clientId: string,
521
+
clientSecret: string
522
+
) {
523
+
super(baseUrl, authBaseUrl, clientId, clientSecret);
524
+
this.xyz = new XyzClient(baseUrl, authBaseUrl, clientId, clientSecret);
525
+
this.oauth = new OAuthClient(baseUrl, authBaseUrl, clientId, clientSecret);
526
+
}
527
+
}
+77
frontend/src/components/CreateSliceDialog.tsx
+77
frontend/src/components/CreateSliceDialog.tsx
···
1
+
interface CreateSliceDialogProps {
2
+
error?: string;
3
+
name?: string;
4
+
}
5
+
6
+
export function CreateSliceDialog({ error, name = "" }: CreateSliceDialogProps) {
7
+
return (
8
+
<div
9
+
id="create-slice-modal"
10
+
className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
11
+
hx-on:click="if (event.target === this) this.remove()"
12
+
>
13
+
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
14
+
<div className="mt-3">
15
+
<div className="flex justify-between items-center mb-4">
16
+
<h3 className="text-lg font-medium text-gray-900">
17
+
Create New Slice
18
+
</h3>
19
+
<button
20
+
type="button"
21
+
className="text-gray-400 hover:text-gray-600"
22
+
onclick="document.getElementById('create-slice-modal').remove()"
23
+
>
24
+
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
25
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
26
+
</svg>
27
+
</button>
28
+
</div>
29
+
30
+
{error && (
31
+
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
32
+
{error}
33
+
</div>
34
+
)}
35
+
36
+
<form
37
+
hx-post="/slices"
38
+
hx-target="#create-slice-modal"
39
+
hx-swap="outerHTML"
40
+
className="space-y-4"
41
+
>
42
+
<div>
43
+
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
44
+
Slice Name
45
+
</label>
46
+
<input
47
+
type="text"
48
+
id="name"
49
+
name="name"
50
+
value={name}
51
+
required
52
+
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
53
+
placeholder="Enter slice name"
54
+
/>
55
+
</div>
56
+
57
+
<div className="flex justify-end space-x-3 pt-4">
58
+
<button
59
+
type="button"
60
+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md"
61
+
onclick="document.getElementById('create-slice-modal').remove()"
62
+
>
63
+
Cancel
64
+
</button>
65
+
<button
66
+
type="submit"
67
+
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
68
+
>
69
+
Create Slice
70
+
</button>
71
+
</div>
72
+
</form>
73
+
</div>
74
+
</div>
75
+
</div>
76
+
);
77
+
}
+28
frontend/src/components/UpdateResult.tsx
+28
frontend/src/components/UpdateResult.tsx
···
1
+
interface UpdateResultProps {
2
+
type: "success" | "error";
3
+
message: string;
4
+
showRefresh?: boolean;
5
+
}
6
+
7
+
export function UpdateResult({ type, message, showRefresh = false }: UpdateResultProps) {
8
+
const colorClass = type === "success" ? "text-green-600" : "text-red-600";
9
+
10
+
return (
11
+
<div className={`${colorClass} text-sm`}>
12
+
{message}
13
+
{showRefresh && (
14
+
<>
15
+
{" "}
16
+
<a
17
+
href="#"
18
+
onclick="window.location.reload()"
19
+
className="underline"
20
+
>
21
+
Refresh page
22
+
</a>{" "}
23
+
to see changes.
24
+
</>
25
+
)}
26
+
</div>
27
+
);
28
+
}
+306
frontend/src/config.ts
+306
frontend/src/config.ts
···
1
+
// OAuth configuration for Slice frontend
2
+
import { AtProtoClient } from "./client.ts";
3
+
4
+
// Load environment variables
5
+
const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID");
6
+
const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET");
7
+
const OAUTH_REDIRECT_URI = Deno.env.get("OAUTH_REDIRECT_URI");
8
+
const OAUTH_AIP_BASE_URL = Deno.env.get("OAUTH_AIP_BASE_URL");
9
+
const SESSION_ENCRYPTION_KEY = Deno.env.get("SESSION_ENCRYPTION_KEY");
10
+
11
+
if (
12
+
!OAUTH_CLIENT_ID ||
13
+
!OAUTH_CLIENT_SECRET ||
14
+
!OAUTH_REDIRECT_URI ||
15
+
!OAUTH_AIP_BASE_URL ||
16
+
!SESSION_ENCRYPTION_KEY
17
+
) {
18
+
throw new Error(
19
+
"Missing OAuth configuration. Please ensure .env file contains:\n" +
20
+
"OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, SESSION_ENCRYPTION_KEY"
21
+
);
22
+
}
23
+
24
+
// Create configured client instance
25
+
export const atprotoClient = new AtProtoClient(
26
+
"http://localhost:3000", // Your API base URL
27
+
OAUTH_AIP_BASE_URL, // AIP OAuth service URL
28
+
OAUTH_CLIENT_ID, // OAuth client ID
29
+
OAUTH_CLIENT_SECRET // OAuth client secret
30
+
);
31
+
32
+
export const oauthConfig = {
33
+
redirectUri: OAUTH_REDIRECT_URI,
34
+
scopes: ["atproto:atproto", "atproto:transition:generic"],
35
+
};
36
+
37
+
// Simple in-memory storage for OAuth state
38
+
export class OAuthStateManager {
39
+
private static states = new Map<
40
+
string,
41
+
{ codeVerifier: string; timestamp: number }
42
+
>();
43
+
44
+
static store(state: string, codeVerifier: string): void {
45
+
this.states.set(state, {
46
+
codeVerifier,
47
+
timestamp: Date.now(),
48
+
});
49
+
50
+
// Auto-cleanup expired states (older than 10 minutes)
51
+
this.cleanup();
52
+
}
53
+
54
+
static retrieve(state: string): string | null {
55
+
const stored = this.states.get(state);
56
+
if (!stored) return null;
57
+
58
+
this.states.delete(state); // Use once and delete
59
+
return stored.codeVerifier;
60
+
}
61
+
62
+
private static cleanup(): void {
63
+
const now = Date.now();
64
+
for (const [key, value] of this.states.entries()) {
65
+
if (now - value.timestamp > 10 * 60 * 1000) {
66
+
// 10 minutes
67
+
this.states.delete(key);
68
+
}
69
+
}
70
+
}
71
+
}
72
+
73
+
class SecureCookies {
74
+
private static get key(): string {
75
+
return SESSION_ENCRYPTION_KEY!;
76
+
}
77
+
78
+
// Encrypt data using AES-GCM
79
+
static async encrypt(plaintext: string): Promise<string> {
80
+
const encoder = new TextEncoder();
81
+
const data = encoder.encode(plaintext);
82
+
83
+
// Generate a random IV
84
+
const iv = crypto.getRandomValues(new Uint8Array(12));
85
+
86
+
// Import key for AES-GCM
87
+
const keyData = encoder.encode(this.key.padEnd(32, "0").slice(0, 32)); // Ensure 32 bytes
88
+
const cryptoKey = await crypto.subtle.importKey(
89
+
"raw",
90
+
keyData,
91
+
{ name: "AES-GCM" },
92
+
false,
93
+
["encrypt"]
94
+
);
95
+
96
+
// Encrypt the data
97
+
const encrypted = await crypto.subtle.encrypt(
98
+
{ name: "AES-GCM", iv: iv },
99
+
cryptoKey,
100
+
data
101
+
);
102
+
103
+
// Combine IV and encrypted data
104
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
105
+
combined.set(iv);
106
+
combined.set(new Uint8Array(encrypted), iv.length);
107
+
108
+
return btoa(String.fromCharCode(...combined));
109
+
}
110
+
111
+
// Decrypt data using AES-GCM
112
+
static async decrypt(encryptedData: string): Promise<string | null> {
113
+
try {
114
+
const encoder = new TextEncoder();
115
+
const decoder = new TextDecoder();
116
+
const combined = new Uint8Array(
117
+
atob(encryptedData)
118
+
.split("")
119
+
.map((c) => c.charCodeAt(0))
120
+
);
121
+
122
+
// Extract IV and encrypted data
123
+
const iv = combined.slice(0, 12);
124
+
const encrypted = combined.slice(12);
125
+
126
+
// Import key for AES-GCM
127
+
const keyData = encoder.encode(this.key.padEnd(32, "0").slice(0, 32));
128
+
const cryptoKey = await crypto.subtle.importKey(
129
+
"raw",
130
+
keyData,
131
+
{ name: "AES-GCM" },
132
+
false,
133
+
["decrypt"]
134
+
);
135
+
136
+
// Decrypt the data
137
+
const decrypted = await crypto.subtle.decrypt(
138
+
{ name: "AES-GCM", iv: iv },
139
+
cryptoKey,
140
+
encrypted
141
+
);
142
+
143
+
return decoder.decode(decrypted);
144
+
} catch (_e) {
145
+
return null;
146
+
}
147
+
}
148
+
149
+
// Create HMAC signature for cookie integrity
150
+
static async sign(value: string): Promise<string> {
151
+
const encoder = new TextEncoder();
152
+
const keyData = encoder.encode(this.key);
153
+
const valueData = encoder.encode(value);
154
+
155
+
const cryptoKey = await crypto.subtle.importKey(
156
+
"raw",
157
+
keyData,
158
+
{ name: "HMAC", hash: "SHA-256" },
159
+
false,
160
+
["sign"]
161
+
);
162
+
163
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, valueData);
164
+
const signatureBase64 = btoa(
165
+
String.fromCharCode(...new Uint8Array(signature))
166
+
);
167
+
168
+
return `${value}.${signatureBase64}`;
169
+
}
170
+
171
+
// Verify HMAC signature and extract value
172
+
static async verify(signedValue: string): Promise<string | null> {
173
+
const parts = signedValue.split(".");
174
+
if (parts.length !== 2) return null;
175
+
176
+
const [value, _signature] = parts;
177
+
const expectedSigned = await this.sign(value);
178
+
179
+
// Constant-time comparison to prevent timing attacks
180
+
if (expectedSigned === signedValue) {
181
+
return value;
182
+
}
183
+
184
+
return null;
185
+
}
186
+
187
+
// Encrypt then sign for authenticated encryption
188
+
static async encryptAndSign(plaintext: string): Promise<string> {
189
+
const encrypted = await this.encrypt(plaintext);
190
+
return await this.sign(encrypted);
191
+
}
192
+
193
+
// Verify signature then decrypt
194
+
static async verifyAndDecrypt(
195
+
signedEncrypted: string
196
+
): Promise<string | null> {
197
+
const encrypted = await this.verify(signedEncrypted);
198
+
if (!encrypted) return null;
199
+
200
+
return await this.decrypt(encrypted);
201
+
}
202
+
}
203
+
204
+
// Types for session data
205
+
interface TokenStorage {
206
+
accessToken?: string;
207
+
refreshToken?: string;
208
+
expiresAt?: number;
209
+
tokenType?: string;
210
+
}
211
+
212
+
interface UserData {
213
+
handle?: string;
214
+
sub?: string;
215
+
tokens?: TokenStorage;
216
+
timestamp?: number;
217
+
}
218
+
219
+
// Cookie-based user session and token storage with HMAC signing
220
+
export class UserSessionManager {
221
+
static async refreshUserInfo(): Promise<UserData> {
222
+
const userInfo = await atprotoClient.oauth.getUserInfo();
223
+
224
+
// Get current token info from client
225
+
const tokens = atprotoClient.getTokenStorage();
226
+
227
+
if (userInfo) {
228
+
const userData = {
229
+
handle: userInfo.did, // Use DID as handle for now
230
+
sub: userInfo.sub,
231
+
tokens: tokens,
232
+
};
233
+
234
+
return userData;
235
+
}
236
+
237
+
return {};
238
+
}
239
+
240
+
static async getCurrentUser(
241
+
req: Request
242
+
): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> {
243
+
// Parse session data from signed cookie
244
+
const cookies = req.headers.get("cookie") || "";
245
+
const sessionCookie = cookies
246
+
.split("; ")
247
+
.find((row) => row.startsWith("user_session="));
248
+
249
+
let userData: UserData = {};
250
+
if (sessionCookie) {
251
+
try {
252
+
const signedEncryptedValue = decodeURIComponent(
253
+
sessionCookie.split("=")[1]
254
+
);
255
+
const decryptedValue = await SecureCookies.verifyAndDecrypt(
256
+
signedEncryptedValue
257
+
);
258
+
259
+
if (decryptedValue) {
260
+
userData = JSON.parse(decryptedValue);
261
+
262
+
// Restore tokens to client if available and not expired
263
+
if (userData.tokens) {
264
+
const now = Date.now();
265
+
const isExpired =
266
+
userData.tokens.expiresAt && now >= userData.tokens.expiresAt;
267
+
268
+
if (!isExpired) {
269
+
// Restore tokens to client using the public method
270
+
atprotoClient.setTokensFromSession(userData.tokens);
271
+
}
272
+
}
273
+
}
274
+
} catch (_e) {
275
+
// Silently ignore invalid cookies
276
+
}
277
+
}
278
+
279
+
const authInfo = atprotoClient.oauth.getAuthenticationInfo();
280
+
return {
281
+
handle: userData.handle,
282
+
sub: userData.sub,
283
+
isAuthenticated: authInfo.isAuthenticated,
284
+
};
285
+
}
286
+
287
+
static async createSessionCookie(userData: UserData): Promise<string> {
288
+
const dataToStore = {
289
+
handle: userData.handle,
290
+
sub: userData.sub,
291
+
tokens: userData.tokens,
292
+
timestamp: Date.now(), // Add timestamp for freshness checks
293
+
};
294
+
295
+
const jsonValue = JSON.stringify(dataToStore);
296
+
const encryptedSignedValue = await SecureCookies.encryptAndSign(jsonValue);
297
+
const cookieValue = encodeURIComponent(encryptedSignedValue);
298
+
299
+
// Production cookie settings - 30 days expiration
300
+
return `user_session=${cookieValue}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days
301
+
}
302
+
303
+
static createClearCookie(): string {
304
+
return `user_session=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
305
+
}
306
+
}
+14
frontend/src/main.tsx
+14
frontend/src/main.tsx
···
1
+
import { route } from "@std/http/unstable-route";
2
+
import { allRoutes } from "./routes/index.ts";
3
+
4
+
function defaultHandler(req: Request) {
5
+
return Response.redirect(new URL("/", req.url), 302);
6
+
}
7
+
8
+
Deno.serve(
9
+
{
10
+
port: 8080,
11
+
onListen: () => console.log("Frontend server running on http://localhost:8080"),
12
+
},
13
+
route(allRoutes, defaultHandler)
14
+
);
+122
frontend/src/pages/SliceSettingsPage.tsx
+122
frontend/src/pages/SliceSettingsPage.tsx
···
1
+
import { Layout } from "../components/Layout.tsx";
2
+
3
+
interface SliceSettingsPageProps {
4
+
sliceName?: string;
5
+
sliceId?: string;
6
+
currentUser?: { handle?: string; isAuthenticated: boolean };
7
+
}
8
+
9
+
export function SliceSettingsPage({
10
+
sliceName = "My Slice",
11
+
sliceId = "example",
12
+
currentUser,
13
+
}: SliceSettingsPageProps) {
14
+
const tabs = [
15
+
{ id: "overview", name: "Overview", href: `/slices/${sliceId}` },
16
+
{ id: "records", name: "Records", href: `/slices/${sliceId}/records` },
17
+
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
18
+
{ id: "lexicon", name: "Lexicons", href: `/slices/${sliceId}/lexicon` },
19
+
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
20
+
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
21
+
];
22
+
23
+
return (
24
+
<Layout title={`${sliceName} - Settings`} currentUser={currentUser}>
25
+
<div className="max-w-4xl mx-auto">
26
+
<div className="flex items-center justify-between mb-8">
27
+
<div className="flex items-center">
28
+
<a
29
+
href="/"
30
+
className="text-blue-600 hover:text-blue-800 mr-4"
31
+
>
32
+
← Back to Slices
33
+
</a>
34
+
<h1 className="text-3xl font-bold text-gray-800">
35
+
{sliceName}
36
+
</h1>
37
+
</div>
38
+
</div>
39
+
40
+
{/* Tab Navigation */}
41
+
<div className="border-b border-gray-200 mb-8">
42
+
<nav className="-mb-px flex space-x-8">
43
+
{tabs.map((tab) => (
44
+
<a
45
+
key={tab.id}
46
+
href={tab.href}
47
+
className={`py-2 px-1 border-b-2 font-medium text-sm ${
48
+
tab.id === "settings"
49
+
? "border-blue-500 text-blue-600"
50
+
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
51
+
}`}
52
+
>
53
+
{tab.name}
54
+
</a>
55
+
))}
56
+
</nav>
57
+
</div>
58
+
59
+
{/* Settings Content */}
60
+
<div className="space-y-8">
61
+
{/* Edit Name Section */}
62
+
<div className="bg-white rounded-lg shadow-md p-6">
63
+
<h2 className="text-xl font-semibold text-gray-800 mb-4">
64
+
⚙️ Edit Slice Name
65
+
</h2>
66
+
<p className="text-gray-600 mb-4">
67
+
Change the display name of your slice.
68
+
</p>
69
+
<form
70
+
hx-put={`/api/slices/${sliceId}/name`}
71
+
hx-target="#name-form-result"
72
+
hx-swap="innerHTML"
73
+
>
74
+
<div className="flex gap-4 items-end">
75
+
<div className="flex-1">
76
+
<label htmlFor="slice-name" className="block text-sm font-medium text-gray-700 mb-2">
77
+
Slice Name
78
+
</label>
79
+
<input
80
+
type="text"
81
+
id="slice-name"
82
+
name="name"
83
+
defaultValue={sliceName}
84
+
required
85
+
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
86
+
placeholder="Enter slice name..."
87
+
/>
88
+
</div>
89
+
<button
90
+
type="submit"
91
+
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-md font-medium"
92
+
>
93
+
Update Name
94
+
</button>
95
+
</div>
96
+
<div id="name-form-result" className="mt-4"></div>
97
+
</form>
98
+
</div>
99
+
100
+
{/* Danger Zone */}
101
+
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-500">
102
+
<h2 className="text-xl font-semibold text-red-800 mb-4">
103
+
🚨 Danger Zone
104
+
</h2>
105
+
<p className="text-gray-600 mb-4">
106
+
Permanently delete this slice and all associated data. This action cannot be undone.
107
+
</p>
108
+
<button
109
+
hx-delete={`/api/slices/${sliceId}`}
110
+
hx-confirm="Are you sure you want to delete this slice? This action cannot be undone."
111
+
hx-target="body"
112
+
hx-push-url="/"
113
+
className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-md font-medium"
114
+
>
115
+
Delete Slice
116
+
</button>
117
+
</div>
118
+
</div>
119
+
</div>
120
+
</Layout>
121
+
);
122
+
}
+24
frontend/src/routes/dialogs.tsx
+24
frontend/src/routes/dialogs.tsx
···
1
+
import type { Route } from "@std/http/unstable-route";
2
+
import { render } from "preact-render-to-string";
3
+
import { withAuth, requireAuth } from "./middleware.ts";
4
+
import { CreateSliceDialog } from "../components/CreateSliceDialog.tsx";
5
+
6
+
async function handleCreateSliceDialog(req: Request): Promise<Response> {
7
+
const context = await withAuth(req);
8
+
const authResponse = requireAuth(context, req);
9
+
if (authResponse) return authResponse;
10
+
11
+
const dialogHtml = render(<CreateSliceDialog />);
12
+
return new Response(dialogHtml, {
13
+
status: 200,
14
+
headers: { "content-type": "text/html" },
15
+
});
16
+
}
17
+
18
+
export const dialogRoutes: Route[] = [
19
+
{
20
+
method: "GET",
21
+
pattern: new URLPattern({ pathname: "/dialogs/create-slice" }),
22
+
handler: handleCreateSliceDialog,
23
+
},
24
+
];
+12
frontend/src/routes/index.ts
+12
frontend/src/routes/index.ts
···
1
+
import type { Route } from "@std/http/unstable-route";
2
+
import { oauthRoutes } from "./oauth.ts";
3
+
import { sliceRoutes } from "./slices.tsx";
4
+
import { dialogRoutes } from "./dialogs.tsx";
5
+
import { pageRoutes } from "./pages.tsx";
6
+
7
+
export const allRoutes: Route[] = [
8
+
...oauthRoutes,
9
+
...sliceRoutes,
10
+
...dialogRoutes,
11
+
...pageRoutes,
12
+
];
+50
frontend/src/routes/middleware.ts
+50
frontend/src/routes/middleware.ts
···
1
+
import { UserSessionManager } from "../config.ts";
2
+
3
+
export interface AuthenticatedUser {
4
+
handle?: string;
5
+
sub?: string;
6
+
isAuthenticated: boolean;
7
+
}
8
+
9
+
export interface RouteContext {
10
+
currentUser: AuthenticatedUser;
11
+
sessionCookieHeader?: string;
12
+
}
13
+
14
+
export async function withAuth(req: Request): Promise<RouteContext> {
15
+
// Get current user info (this already restores tokens from session)
16
+
const currentUser = await UserSessionManager.getCurrentUser(req);
17
+
18
+
// Check if we need to refresh the session cookie with updated tokens
19
+
let sessionCookieHeader: string | undefined;
20
+
if (currentUser.isAuthenticated) {
21
+
const { atprotoClient } = await import("../config.ts");
22
+
const tokens = atprotoClient.getTokenStorage();
23
+
if (tokens && tokens.accessToken) {
24
+
// Refresh the session cookie to extend expiration and update any refreshed tokens
25
+
const userData = {
26
+
handle: currentUser.handle,
27
+
sub: currentUser.sub,
28
+
tokens: tokens,
29
+
};
30
+
sessionCookieHeader = await UserSessionManager.createSessionCookie(
31
+
userData
32
+
);
33
+
}
34
+
}
35
+
36
+
return {
37
+
currentUser,
38
+
sessionCookieHeader,
39
+
};
40
+
}
41
+
42
+
export function requireAuth(
43
+
context: RouteContext,
44
+
req: Request
45
+
): Response | null {
46
+
if (!context.currentUser.isAuthenticated) {
47
+
return Response.redirect(new URL("/login", req.url), 302);
48
+
}
49
+
return null;
50
+
}
+134
frontend/src/routes/oauth.ts
+134
frontend/src/routes/oauth.ts
···
1
+
import type { Route } from "@std/http/unstable-route";
2
+
import {
3
+
atprotoClient,
4
+
oauthConfig,
5
+
OAuthStateManager,
6
+
UserSessionManager,
7
+
} from "../config.ts";
8
+
9
+
async function handleOAuthAuthorize(req: Request): Promise<Response> {
10
+
try {
11
+
// Clear any existing auth state before new login attempt
12
+
atprotoClient.oauth.logout();
13
+
14
+
const formData = await req.formData();
15
+
const loginHint = formData.get("loginHint") as string;
16
+
17
+
if (!loginHint) {
18
+
return new Response("Missing login hint", { status: 400 });
19
+
}
20
+
21
+
const authResult = await atprotoClient.oauth.authorize({
22
+
loginHint,
23
+
redirectUri: oauthConfig.redirectUri,
24
+
scope: oauthConfig.scopes,
25
+
});
26
+
27
+
// Store OAuth state for later verification
28
+
OAuthStateManager.store(authResult.state, authResult.codeVerifier);
29
+
30
+
// Redirect to authorization URL
31
+
return Response.redirect(authResult.authorizationUrl, 302);
32
+
} catch (error) {
33
+
console.error("OAuth authorize error:", error);
34
+
return Response.redirect(
35
+
new URL(
36
+
"/login?error=" + encodeURIComponent("OAuth initialization failed"),
37
+
req.url
38
+
),
39
+
302
40
+
);
41
+
}
42
+
}
43
+
44
+
async function handleOAuthCallback(req: Request): Promise<Response> {
45
+
try {
46
+
const url = new URL(req.url);
47
+
const code = url.searchParams.get("code");
48
+
const state = url.searchParams.get("state");
49
+
50
+
if (!code || !state) {
51
+
return Response.redirect(
52
+
new URL(
53
+
"/login?error=" + encodeURIComponent("Invalid OAuth callback"),
54
+
req.url
55
+
),
56
+
302
57
+
);
58
+
}
59
+
60
+
// Retrieve stored code verifier
61
+
const codeVerifier = OAuthStateManager.retrieve(state);
62
+
if (!codeVerifier) {
63
+
return Response.redirect(
64
+
new URL(
65
+
"/login?error=" +
66
+
encodeURIComponent("Invalid or expired OAuth state"),
67
+
req.url
68
+
),
69
+
302
70
+
);
71
+
}
72
+
73
+
// Exchange code for tokens
74
+
await atprotoClient.oauth.handleCallback({
75
+
code,
76
+
state,
77
+
codeVerifier,
78
+
redirectUri: oauthConfig.redirectUri,
79
+
});
80
+
81
+
// Fetch and store user info
82
+
const userData = await UserSessionManager.refreshUserInfo();
83
+
84
+
// Redirect to main app on successful login with session cookie
85
+
const sessionCookie = await UserSessionManager.createSessionCookie(
86
+
userData
87
+
);
88
+
return new Response(null, {
89
+
status: 302,
90
+
headers: {
91
+
Location: new URL("/", req.url).toString(),
92
+
"Set-Cookie": sessionCookie,
93
+
},
94
+
});
95
+
} catch (error) {
96
+
console.error("OAuth callback error:", error);
97
+
return Response.redirect(
98
+
new URL(
99
+
"/login?error=" + encodeURIComponent("Authentication failed"),
100
+
req.url
101
+
),
102
+
302
103
+
);
104
+
}
105
+
}
106
+
107
+
async function handleLogout(req: Request): Promise<Response> {
108
+
atprotoClient.oauth.logout();
109
+
return new Response(null, {
110
+
status: 302,
111
+
headers: {
112
+
Location: new URL("/login", req.url).toString(),
113
+
"Set-Cookie": UserSessionManager.createClearCookie(),
114
+
},
115
+
});
116
+
}
117
+
118
+
export const oauthRoutes: Route[] = [
119
+
{
120
+
method: "POST",
121
+
pattern: new URLPattern({ pathname: "/oauth/authorize" }),
122
+
handler: handleOAuthAuthorize,
123
+
},
124
+
{
125
+
method: "GET",
126
+
pattern: new URLPattern({ pathname: "/oauth/callback" }),
127
+
handler: handleOAuthCallback,
128
+
},
129
+
{
130
+
method: "POST",
131
+
pattern: new URLPattern({ pathname: "/logout" }),
132
+
handler: handleLogout,
133
+
},
134
+
];
+289
frontend/src/routes/pages.tsx
+289
frontend/src/routes/pages.tsx
···
1
+
import type { Route } from "@std/http/unstable-route";
2
+
import { render } from "preact-render-to-string";
3
+
import { withAuth } from "./middleware.ts";
4
+
import { atprotoClient } from "../config.ts";
5
+
import { IndexPage } from "../pages/IndexPage.tsx";
6
+
import { LoginPage } from "../pages/LoginPage.tsx";
7
+
import { SlicePage } from "../pages/SlicePage.tsx";
8
+
import { SliceRecordsPage } from "../pages/SliceRecordsPage.tsx";
9
+
import { SliceSyncPage } from "../pages/SliceSyncPage.tsx";
10
+
import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx";
11
+
import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx";
12
+
import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx";
13
+
14
+
async function handleIndexPage(req: Request): Promise<Response> {
15
+
const context = await withAuth(req);
16
+
17
+
// Slice list page - get real slices from AT Protocol
18
+
let slices: Array<{ id: string; name: string; createdAt: string }> = [];
19
+
20
+
if (context.currentUser.isAuthenticated) {
21
+
try {
22
+
const sliceRecords =
23
+
await atprotoClient.xyz.sliceat.slice.listRecords();
24
+
25
+
slices = sliceRecords.records.map((record) => {
26
+
// Extract slice ID from URI
27
+
const uriParts = record.uri.split("/");
28
+
const id = uriParts[uriParts.length - 1];
29
+
30
+
return {
31
+
id,
32
+
name: record.value.name,
33
+
createdAt: record.value.createdAt,
34
+
};
35
+
});
36
+
} catch (error) {
37
+
console.error("Failed to fetch slices:", error);
38
+
// Fall back to empty array if fetch fails
39
+
}
40
+
}
41
+
42
+
const html = render(<IndexPage slices={slices} currentUser={context.currentUser} />);
43
+
44
+
const responseHeaders: Record<string, string> = {
45
+
"content-type": "text/html",
46
+
};
47
+
48
+
// Add session cookie header if we need to refresh it
49
+
if (context.sessionCookieHeader) {
50
+
responseHeaders["Set-Cookie"] = context.sessionCookieHeader;
51
+
}
52
+
53
+
return new Response(`<!DOCTYPE html>${html}`, {
54
+
status: 200,
55
+
headers: responseHeaders,
56
+
});
57
+
}
58
+
59
+
async function handleLoginPage(req: Request): Promise<Response> {
60
+
const context = await withAuth(req);
61
+
const url = new URL(req.url);
62
+
63
+
// Login page with optional error message
64
+
const error = url.searchParams.get("error");
65
+
const html = render(
66
+
<LoginPage error={error || undefined} currentUser={context.currentUser} />
67
+
);
68
+
69
+
const responseHeaders: Record<string, string> = {
70
+
"content-type": "text/html",
71
+
};
72
+
73
+
if (context.sessionCookieHeader) {
74
+
responseHeaders["Set-Cookie"] = context.sessionCookieHeader;
75
+
}
76
+
77
+
return new Response(`<!DOCTYPE html>${html}`, {
78
+
status: 200,
79
+
headers: responseHeaders,
80
+
});
81
+
}
82
+
83
+
async function handleSlicePage(req: Request, params: any): Promise<Response> {
84
+
const context = await withAuth(req);
85
+
const sliceId = params?.pathname.groups.id;
86
+
87
+
if (!sliceId) {
88
+
return Response.redirect(new URL("/", req.url), 302);
89
+
}
90
+
91
+
// Get real slice data from AT Protocol
92
+
let sliceData = {
93
+
sliceId,
94
+
sliceName: "Unknown Slice",
95
+
totalRecords: 0,
96
+
collections: [] as Array<{ name: string; count: number }>,
97
+
};
98
+
99
+
if (context.currentUser.isAuthenticated) {
100
+
try {
101
+
// Construct the full URI for this slice
102
+
const sliceUri = `at://${
103
+
context.currentUser.sub || "unknown"
104
+
}/xyz.sliceat.slice/${sliceId}`;
105
+
106
+
const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
107
+
uri: sliceUri,
108
+
});
109
+
110
+
sliceData = {
111
+
sliceId,
112
+
sliceName: sliceRecord.value.name,
113
+
totalRecords: 1, // For now, just showing this slice
114
+
collections: [{ name: "xyz.sliceat.slice", count: 1 }],
115
+
};
116
+
} catch (error) {
117
+
// Fall back to default data
118
+
}
119
+
}
120
+
121
+
const html = render(
122
+
<SlicePage
123
+
{...sliceData}
124
+
currentTab="overview"
125
+
currentUser={context.currentUser}
126
+
/>
127
+
);
128
+
129
+
const responseHeaders: Record<string, string> = {
130
+
"content-type": "text/html",
131
+
};
132
+
133
+
if (context.sessionCookieHeader) {
134
+
responseHeaders["Set-Cookie"] = context.sessionCookieHeader;
135
+
}
136
+
137
+
return new Response(`<!DOCTYPE html>${html}`, {
138
+
status: 200,
139
+
headers: responseHeaders,
140
+
});
141
+
}
142
+
143
+
async function handleSliceTabPage(req: Request, params: any): Promise<Response> {
144
+
const context = await withAuth(req);
145
+
const sliceId = params?.pathname.groups.id;
146
+
const tab = params?.pathname.groups.tab;
147
+
148
+
if (!sliceId || !tab) {
149
+
return Response.redirect(new URL("/", req.url), 302);
150
+
}
151
+
152
+
// Get real slice data from AT Protocol
153
+
let sliceData = {
154
+
sliceId,
155
+
sliceName: "Unknown Slice",
156
+
totalRecords: 0,
157
+
collections: [] as Array<{ name: string; count: number }>,
158
+
};
159
+
160
+
if (context.currentUser.isAuthenticated) {
161
+
try {
162
+
// Construct the full URI for this slice
163
+
const sliceUri = `at://${
164
+
context.currentUser.sub || "unknown"
165
+
}/xyz.sliceat.slice/${sliceId}`;
166
+
167
+
const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
168
+
uri: sliceUri,
169
+
});
170
+
171
+
sliceData = {
172
+
sliceId,
173
+
sliceName: sliceRecord.value.name,
174
+
totalRecords: 1, // For now, just showing this slice
175
+
collections: [{ name: "xyz.sliceat.slice", count: 1 }],
176
+
};
177
+
} catch (error) {
178
+
console.error("Failed to fetch slice:", error);
179
+
// Fall back to default data
180
+
}
181
+
}
182
+
183
+
let html: string;
184
+
185
+
switch (tab) {
186
+
case "records": {
187
+
const recordsData = {
188
+
...sliceData,
189
+
records: [
190
+
{
191
+
uri: `at://did:plc:example/com.chadtmiller.slice/3k2a4b5c6d`,
192
+
indexed_at: "2024-01-15 12:45:00",
193
+
collection: "com.chadtmiller.slice",
194
+
did: "did:plc:example",
195
+
cid: "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua",
196
+
value: true,
197
+
pretty_value: `{\n "name": "${sliceData.sliceName}",\n "createdAt": "2024-01-15T12:45:00.000Z",\n "$type": "com.chadtmiller.slice"\n}`,
198
+
},
199
+
],
200
+
availableCollections: sliceData.collections,
201
+
};
202
+
html = render(
203
+
<SliceRecordsPage {...recordsData} currentUser={context.currentUser} />
204
+
);
205
+
break;
206
+
}
207
+
208
+
case "sync": {
209
+
html = render(
210
+
<SliceSyncPage {...sliceData} currentUser={context.currentUser} />
211
+
);
212
+
break;
213
+
}
214
+
215
+
case "lexicon": {
216
+
const lexiconData = {
217
+
...sliceData,
218
+
lexicons: [
219
+
{
220
+
nsid: "com.chadtmiller.slice",
221
+
updated_at: "2024-01-15 10:30:00",
222
+
pretty_definitions: `{\n "lexicon": 1,\n "id": "com.chadtmiller.slice",\n "defs": {\n "main": {\n "type": "record",\n "description": "Slice application record type"\n }\n }\n}`,
223
+
},
224
+
],
225
+
};
226
+
html = render(
227
+
<SliceLexiconPage {...lexiconData} currentUser={context.currentUser} />
228
+
);
229
+
break;
230
+
}
231
+
232
+
case "codegen": {
233
+
const codegenData = {
234
+
...sliceData,
235
+
lexicons: [
236
+
{ nsid: "com.chadtmiller.slice" },
237
+
{ nsid: "social.grain.gallery" },
238
+
],
239
+
};
240
+
html = render(
241
+
<SliceCodegenPage {...codegenData} currentUser={context.currentUser} />
242
+
);
243
+
break;
244
+
}
245
+
246
+
case "settings": {
247
+
html = render(
248
+
<SliceSettingsPage {...sliceData} currentUser={context.currentUser} />
249
+
);
250
+
break;
251
+
}
252
+
253
+
default:
254
+
// 404 for unknown slice subpaths
255
+
return Response.redirect(new URL("/", req.url), 302);
256
+
}
257
+
258
+
const responseHeaders: Record<string, string> = {
259
+
"content-type": "text/html",
260
+
};
261
+
262
+
if (context.sessionCookieHeader) {
263
+
responseHeaders["Set-Cookie"] = context.sessionCookieHeader;
264
+
}
265
+
266
+
return new Response(`<!DOCTYPE html>${html}`, {
267
+
status: 200,
268
+
headers: responseHeaders,
269
+
});
270
+
}
271
+
272
+
export const pageRoutes: Route[] = [
273
+
{
274
+
pattern: new URLPattern({ pathname: "/" }),
275
+
handler: handleIndexPage,
276
+
},
277
+
{
278
+
pattern: new URLPattern({ pathname: "/login" }),
279
+
handler: handleLoginPage,
280
+
},
281
+
{
282
+
pattern: new URLPattern({ pathname: "/slices/:id" }),
283
+
handler: handleSlicePage,
284
+
},
285
+
{
286
+
pattern: new URLPattern({ pathname: "/slices/:id/:tab" }),
287
+
handler: handleSliceTabPage,
288
+
},
289
+
];
+189
frontend/src/routes/slices.tsx
+189
frontend/src/routes/slices.tsx
···
1
+
import type { Route } from "@std/http/unstable-route";
2
+
import { render } from "preact-render-to-string";
3
+
import { withAuth, requireAuth } from "./middleware.ts";
4
+
import { atprotoClient } from "../config.ts";
5
+
import { CreateSliceDialog } from "../components/CreateSliceDialog.tsx";
6
+
import { UpdateResult } from "../components/UpdateResult.tsx";
7
+
8
+
async function handleCreateSlice(req: Request): Promise<Response> {
9
+
const context = await withAuth(req);
10
+
const authResponse = requireAuth(context, req);
11
+
if (authResponse) return authResponse;
12
+
13
+
// Ensure client has tokens before attempting API calls
14
+
const authInfo = atprotoClient.oauth.getAuthenticationInfo();
15
+
if (!authInfo.isAuthenticated) {
16
+
const dialogHtml = render(
17
+
<CreateSliceDialog error="Session expired. Please log in again." />
18
+
);
19
+
return new Response(dialogHtml, {
20
+
status: 200,
21
+
headers: { "content-type": "text/html" },
22
+
});
23
+
}
24
+
25
+
try {
26
+
const formData = await req.formData();
27
+
const name = formData.get("name") as string;
28
+
29
+
if (!name || name.trim().length === 0) {
30
+
const dialogHtml = render(
31
+
<CreateSliceDialog error="Slice name is required" name={name} />
32
+
);
33
+
return new Response(dialogHtml, {
34
+
status: 200,
35
+
headers: { "content-type": "text/html" },
36
+
});
37
+
}
38
+
39
+
// Create actual slice using AT Protocol
40
+
try {
41
+
const result = await atprotoClient.xyz.sliceat.slice.createRecord({
42
+
name: name.trim(),
43
+
createdAt: new Date().toISOString(),
44
+
});
45
+
46
+
// Extract record key from URI (format: at://did:plc:example/xyz.sliceat.slice/rkey)
47
+
const uriParts = result.uri.split("/");
48
+
const sliceId = uriParts[uriParts.length - 1];
49
+
50
+
return new Response("", {
51
+
status: 200,
52
+
headers: {
53
+
"HX-Redirect": `/slices/${sliceId}`,
54
+
},
55
+
});
56
+
} catch (createError) {
57
+
const dialogHtml = render(
58
+
<CreateSliceDialog
59
+
error="Failed to create slice record. Please try again."
60
+
name={name}
61
+
/>
62
+
);
63
+
return new Response(dialogHtml, {
64
+
status: 200,
65
+
headers: { "content-type": "text/html" },
66
+
});
67
+
}
68
+
} catch (error) {
69
+
const dialogHtml = render(
70
+
<CreateSliceDialog error="Failed to create slice" />
71
+
);
72
+
return new Response(dialogHtml, {
73
+
status: 200,
74
+
headers: { "content-type": "text/html" },
75
+
});
76
+
}
77
+
}
78
+
79
+
async function handleUpdateSliceName(req: Request, params: any): Promise<Response> {
80
+
const context = await withAuth(req);
81
+
const authResponse = requireAuth(context, req);
82
+
if (authResponse) return authResponse;
83
+
84
+
const sliceId = params?.pathname.groups.id;
85
+
if (!sliceId) {
86
+
return new Response("Invalid slice ID", { status: 400 });
87
+
}
88
+
89
+
try {
90
+
const formData = await req.formData();
91
+
const name = formData.get("name") as string;
92
+
93
+
if (!name || name.trim().length === 0) {
94
+
const resultHtml = render(
95
+
<UpdateResult type="error" message="Slice name is required" />
96
+
);
97
+
return new Response(resultHtml, {
98
+
status: 200,
99
+
headers: { "content-type": "text/html" },
100
+
});
101
+
}
102
+
103
+
// Construct the URI for this slice
104
+
const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/${sliceId}`;
105
+
106
+
// Get the current record first
107
+
const currentRecord = await atprotoClient.xyz.sliceat.slice.getRecord({
108
+
uri: sliceUri,
109
+
});
110
+
111
+
// Update the record with new name
112
+
const updatedRecord = {
113
+
...currentRecord.value,
114
+
name: name.trim(),
115
+
};
116
+
117
+
await atprotoClient.xyz.sliceat.slice.updateRecord(
118
+
sliceId,
119
+
updatedRecord
120
+
);
121
+
122
+
const resultHtml = render(
123
+
<UpdateResult
124
+
type="success"
125
+
message="Slice name updated successfully!"
126
+
showRefresh
127
+
/>
128
+
);
129
+
return new Response(resultHtml, {
130
+
status: 200,
131
+
headers: { "content-type": "text/html" },
132
+
});
133
+
} catch (error) {
134
+
const resultHtml = render(
135
+
<UpdateResult
136
+
type="error"
137
+
message="Failed to update slice name. Please try again."
138
+
/>
139
+
);
140
+
return new Response(resultHtml, {
141
+
status: 200,
142
+
headers: { "content-type": "text/html" },
143
+
});
144
+
}
145
+
}
146
+
147
+
async function handleDeleteSlice(req: Request, params: any): Promise<Response> {
148
+
const context = await withAuth(req);
149
+
const authResponse = requireAuth(context, req);
150
+
if (authResponse) return authResponse;
151
+
152
+
const sliceId = params?.pathname.groups.id;
153
+
if (!sliceId) {
154
+
return new Response("Invalid slice ID", { status: 400 });
155
+
}
156
+
157
+
try {
158
+
// Delete the slice record from AT Protocol
159
+
await atprotoClient.xyz.sliceat.slice.deleteRecord(sliceId);
160
+
161
+
// Redirect to home page
162
+
return new Response("", {
163
+
status: 200,
164
+
headers: {
165
+
"HX-Redirect": "/",
166
+
},
167
+
});
168
+
} catch (error) {
169
+
return new Response("Failed to delete slice", { status: 500 });
170
+
}
171
+
}
172
+
173
+
export const sliceRoutes: Route[] = [
174
+
{
175
+
method: "POST",
176
+
pattern: new URLPattern({ pathname: "/slices" }),
177
+
handler: handleCreateSlice,
178
+
},
179
+
{
180
+
method: "PUT",
181
+
pattern: new URLPattern({ pathname: "/api/slices/:id/name" }),
182
+
handler: handleUpdateSliceName,
183
+
},
184
+
{
185
+
method: "DELETE",
186
+
pattern: new URLPattern({ pathname: "/api/slices/:id" }),
187
+
handler: handleDeleteSlice,
188
+
},
189
+
];