Highly ambitious ATProtocol AppView service and sdks

get create,update,delete record routes working

+1
.gitignore
··· 1 + aip/
+3 -1
api/.env
··· 1 - DATABASE_URL=postgresql://slice:slice@localhost:5432/slice 1 + DATABASE_URL=postgresql://slice:slice@localhost:5432/slice 2 + AUTH_BASE_URL=http://localhost:8081 3 + RUST_LOG=debug
+1
api/.gitignore
··· 1 1 /target 2 + .env
+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
··· 48 48 zip = "4.3" 49 49 multer = "3.1" 50 50 futures-util = "0.3" 51 + 52 + # URL encoding for OAuth 53 + urlencoding = "2.1" 54 + 55 + # AT Protocol client 56 + atproto-client = "0.11.2" 57 + atproto-identity = "0.11.2" 58 + atproto-oauth = "0.11.2"
+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 - // 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
··· 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
··· 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
··· 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
··· 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(&params.collections, params.repos.as_deref()).await { 145 + match state 146 + .sync_service 147 + .backfill_collections(&params.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 }
+2 -1
frontend/.gitignore
··· 1 - node_modules 1 + node_modules 2 + .env
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + ];
+1 -1
lexicons/com/chadtmiller/slice.json lexicons/xyz/sliceat/slice.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "com.chadtmiller.slice", 3 + "id": "xyz.sliceat.slice", 4 4 "description": "Slice application record type", 5 5 "defs": { 6 6 "main": {