An encrypted personal cloud built on the AT Protocol.

Add appview indexer and unify config path resolution

- AppView crate: Jetstream firehose indexer for grants and keyrings,
Axum REST API with DID-scoped Ed25519 auth, SQLite storage
- Ed25519 signing keypair added to identity (backward-compatible migration)
- Shared path resolution (env → XDG → HOME) in opake-core/paths.rs
- AppView commands return anyhow::Result, matching CLI error pattern
- Workspace dependency inheritance for anyhow
- XDG_CONFIG_HOME support for both binaries

+3841 -62
+5
CHANGELOG.md
··· 6 6 7 7 ## [Unreleased] 8 8 9 + ### Security 10 + - Remove bearer token authentication fallback from AppView (#109) 11 + 9 12 ### Added 13 + - Audit workspace dependencies for consolidation and upgrades (#110) 14 + - Add AppView production readiness: clap, DID auth, XDG, health, docs (#101) 10 15 - Update docs to reflect module directory restructuring (#95) 11 16 - Add verbose flags for CLI debug output (#92) 12 17 - Add keyring rotation history to preserve member access to pre-rotation documents (#87)
+686 -24
Cargo.lock
··· 56 56 ] 57 57 58 58 [[package]] 59 + name = "allocator-api2" 60 + version = "0.2.21" 61 + source = "registry+https://github.com/rust-lang/crates.io-index" 62 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 63 + 64 + [[package]] 59 65 name = "android_system_properties" 60 66 version = "0.1.5" 61 67 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 121 127 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 122 128 123 129 [[package]] 130 + name = "async-trait" 131 + version = "0.1.89" 132 + source = "registry+https://github.com/rust-lang/crates.io-index" 133 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 134 + dependencies = [ 135 + "proc-macro2", 136 + "quote", 137 + "syn", 138 + ] 139 + 140 + [[package]] 124 141 name = "atomic-waker" 125 142 version = "1.1.2" 126 143 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 155 172 ] 156 173 157 174 [[package]] 175 + name = "axum" 176 + version = "0.8.8" 177 + source = "registry+https://github.com/rust-lang/crates.io-index" 178 + checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" 179 + dependencies = [ 180 + "axum-core", 181 + "bytes", 182 + "form_urlencoded", 183 + "futures-util", 184 + "http", 185 + "http-body", 186 + "http-body-util", 187 + "hyper", 188 + "hyper-util", 189 + "itoa", 190 + "matchit", 191 + "memchr", 192 + "mime", 193 + "percent-encoding", 194 + "pin-project-lite", 195 + "serde_core", 196 + "serde_json", 197 + "serde_path_to_error", 198 + "serde_urlencoded", 199 + "sync_wrapper", 200 + "tokio", 201 + "tower", 202 + "tower-layer", 203 + "tower-service", 204 + "tracing", 205 + ] 206 + 207 + [[package]] 208 + name = "axum-core" 209 + version = "0.5.6" 210 + source = "registry+https://github.com/rust-lang/crates.io-index" 211 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" 212 + dependencies = [ 213 + "bytes", 214 + "futures-core", 215 + "http", 216 + "http-body", 217 + "http-body-util", 218 + "mime", 219 + "pin-project-lite", 220 + "sync_wrapper", 221 + "tower-layer", 222 + "tower-service", 223 + "tracing", 224 + ] 225 + 226 + [[package]] 158 227 name = "base64" 159 228 version = "0.22.1" 160 229 source = "registry+https://github.com/rust-lang/crates.io-index" 161 230 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 231 + 232 + [[package]] 233 + name = "base64ct" 234 + version = "1.8.3" 235 + source = "registry+https://github.com/rust-lang/crates.io-index" 236 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 162 237 163 238 [[package]] 164 239 name = "bitflags" ··· 305 380 ] 306 381 307 382 [[package]] 383 + name = "const-oid" 384 + version = "0.9.6" 385 + source = "registry+https://github.com/rust-lang/crates.io-index" 386 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 387 + 388 + [[package]] 308 389 name = "core-foundation" 309 390 version = "0.10.1" 310 391 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 330 411 ] 331 412 332 413 [[package]] 414 + name = "crossbeam-utils" 415 + version = "0.8.21" 416 + source = "registry+https://github.com/rust-lang/crates.io-index" 417 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 418 + 419 + [[package]] 333 420 name = "crypto-common" 334 421 version = "0.1.7" 335 422 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 358 445 "cfg-if", 359 446 "cpufeatures", 360 447 "curve25519-dalek-derive", 448 + "digest", 361 449 "fiat-crypto", 362 450 "rustc_version", 363 451 "subtle", ··· 376 464 ] 377 465 378 466 [[package]] 467 + name = "dashmap" 468 + version = "6.1.0" 469 + source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 471 + dependencies = [ 472 + "cfg-if", 473 + "crossbeam-utils", 474 + "hashbrown 0.14.5", 475 + "lock_api", 476 + "once_cell", 477 + "parking_lot_core", 478 + ] 479 + 480 + [[package]] 481 + name = "data-encoding" 482 + version = "2.10.0" 483 + source = "registry+https://github.com/rust-lang/crates.io-index" 484 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 485 + 486 + [[package]] 487 + name = "der" 488 + version = "0.7.10" 489 + source = "registry+https://github.com/rust-lang/crates.io-index" 490 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 491 + dependencies = [ 492 + "const-oid", 493 + "zeroize", 494 + ] 495 + 496 + [[package]] 379 497 name = "digest" 380 498 version = "0.10.7" 381 499 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 404 522 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 405 523 406 524 [[package]] 525 + name = "ed25519" 526 + version = "2.2.3" 527 + source = "registry+https://github.com/rust-lang/crates.io-index" 528 + checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" 529 + dependencies = [ 530 + "pkcs8", 531 + "signature", 532 + ] 533 + 534 + [[package]] 535 + name = "ed25519-dalek" 536 + version = "2.2.0" 537 + source = "registry+https://github.com/rust-lang/crates.io-index" 538 + checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" 539 + dependencies = [ 540 + "curve25519-dalek", 541 + "ed25519", 542 + "rand_core 0.6.4", 543 + "serde", 544 + "sha2", 545 + "subtle", 546 + "zeroize", 547 + ] 548 + 549 + [[package]] 407 550 name = "env_filter" 408 551 version = "1.0.0" 409 552 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 443 586 ] 444 587 445 588 [[package]] 589 + name = "fallible-iterator" 590 + version = "0.3.0" 591 + source = "registry+https://github.com/rust-lang/crates.io-index" 592 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 593 + 594 + [[package]] 595 + name = "fallible-streaming-iterator" 596 + version = "0.1.9" 597 + source = "registry+https://github.com/rust-lang/crates.io-index" 598 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 599 + 600 + [[package]] 446 601 name = "fastrand" 447 602 version = "2.3.0" 448 603 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 461 616 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 462 617 463 618 [[package]] 619 + name = "fnv" 620 + version = "1.0.7" 621 + source = "registry+https://github.com/rust-lang/crates.io-index" 622 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 623 + 624 + [[package]] 625 + name = "foldhash" 626 + version = "0.2.0" 627 + source = "registry+https://github.com/rust-lang/crates.io-index" 628 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 629 + 630 + [[package]] 464 631 name = "form_urlencoded" 465 632 version = "1.2.2" 466 633 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 470 637 ] 471 638 472 639 [[package]] 640 + name = "forwarded-header-value" 641 + version = "0.1.1" 642 + source = "registry+https://github.com/rust-lang/crates.io-index" 643 + checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" 644 + dependencies = [ 645 + "nonempty", 646 + "thiserror 1.0.69", 647 + ] 648 + 649 + [[package]] 473 650 name = "fs_extra" 474 651 version = "1.3.0" 475 652 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 491 668 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 492 669 493 670 [[package]] 671 + name = "futures-macro" 672 + version = "0.3.32" 673 + source = "registry+https://github.com/rust-lang/crates.io-index" 674 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 675 + dependencies = [ 676 + "proc-macro2", 677 + "quote", 678 + "syn", 679 + ] 680 + 681 + [[package]] 682 + name = "futures-sink" 683 + version = "0.3.32" 684 + source = "registry+https://github.com/rust-lang/crates.io-index" 685 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 686 + 687 + [[package]] 494 688 name = "futures-task" 495 689 version = "0.3.32" 496 690 source = "registry+https://github.com/rust-lang/crates.io-index" 497 691 checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 498 692 499 693 [[package]] 694 + name = "futures-timer" 695 + version = "3.0.3" 696 + source = "registry+https://github.com/rust-lang/crates.io-index" 697 + checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" 698 + 699 + [[package]] 500 700 name = "futures-util" 501 701 version = "0.3.32" 502 702 source = "registry+https://github.com/rust-lang/crates.io-index" 503 703 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 504 704 dependencies = [ 505 705 "futures-core", 706 + "futures-macro", 707 + "futures-sink", 506 708 "futures-task", 507 709 "pin-project-lite", 508 710 "slab", ··· 556 758 ] 557 759 558 760 [[package]] 761 + name = "governor" 762 + version = "0.10.4" 763 + source = "registry+https://github.com/rust-lang/crates.io-index" 764 + checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" 765 + dependencies = [ 766 + "cfg-if", 767 + "dashmap", 768 + "futures-sink", 769 + "futures-timer", 770 + "futures-util", 771 + "getrandom 0.3.4", 772 + "hashbrown 0.16.1", 773 + "nonzero_ext", 774 + "parking_lot", 775 + "portable-atomic", 776 + "quanta", 777 + "rand", 778 + "smallvec", 779 + "spinning_top", 780 + "web-time", 781 + ] 782 + 783 + [[package]] 784 + name = "h2" 785 + version = "0.4.13" 786 + source = "registry+https://github.com/rust-lang/crates.io-index" 787 + checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" 788 + dependencies = [ 789 + "atomic-waker", 790 + "bytes", 791 + "fnv", 792 + "futures-core", 793 + "futures-sink", 794 + "http", 795 + "indexmap", 796 + "slab", 797 + "tokio", 798 + "tokio-util", 799 + "tracing", 800 + ] 801 + 802 + [[package]] 803 + name = "hashbrown" 804 + version = "0.14.5" 805 + source = "registry+https://github.com/rust-lang/crates.io-index" 806 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 807 + 808 + [[package]] 559 809 name = "hashbrown" 560 810 version = "0.16.1" 561 811 source = "registry+https://github.com/rust-lang/crates.io-index" 562 812 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 813 + dependencies = [ 814 + "allocator-api2", 815 + "equivalent", 816 + "foldhash", 817 + ] 818 + 819 + [[package]] 820 + name = "hashlink" 821 + version = "0.11.0" 822 + source = "registry+https://github.com/rust-lang/crates.io-index" 823 + checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" 824 + dependencies = [ 825 + "hashbrown 0.16.1", 826 + ] 563 827 564 828 [[package]] 565 829 name = "heck" ··· 625 889 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 626 890 627 891 [[package]] 892 + name = "httpdate" 893 + version = "1.0.3" 894 + source = "registry+https://github.com/rust-lang/crates.io-index" 895 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 896 + 897 + [[package]] 628 898 name = "hyper" 629 899 version = "1.8.1" 630 900 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 634 904 "bytes", 635 905 "futures-channel", 636 906 "futures-core", 907 + "h2", 637 908 "http", 638 909 "http-body", 639 910 "httparse", 911 + "httpdate", 640 912 "itoa", 641 913 "pin-project-lite", 642 914 "pin-utils", ··· 662 934 ] 663 935 664 936 [[package]] 937 + name = "hyper-timeout" 938 + version = "0.5.2" 939 + source = "registry+https://github.com/rust-lang/crates.io-index" 940 + checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" 941 + dependencies = [ 942 + "hyper", 943 + "hyper-util", 944 + "pin-project-lite", 945 + "tokio", 946 + "tower-service", 947 + ] 948 + 949 + [[package]] 665 950 name = "hyper-util" 666 951 version = "0.1.20" 667 952 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 817 1102 checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 818 1103 dependencies = [ 819 1104 "equivalent", 820 - "hashbrown", 1105 + "hashbrown 0.16.1", 821 1106 ] 822 1107 823 1108 [[package]] ··· 930 1215 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 931 1216 932 1217 [[package]] 1218 + name = "libsqlite3-sys" 1219 + version = "0.36.0" 1220 + source = "registry+https://github.com/rust-lang/crates.io-index" 1221 + checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" 1222 + dependencies = [ 1223 + "cc", 1224 + "pkg-config", 1225 + "vcpkg", 1226 + ] 1227 + 1228 + [[package]] 933 1229 name = "linux-raw-sys" 934 1230 version = "0.12.1" 935 1231 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 963 1259 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 964 1260 965 1261 [[package]] 1262 + name = "matchit" 1263 + version = "0.8.4" 1264 + source = "registry+https://github.com/rust-lang/crates.io-index" 1265 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 1266 + 1267 + [[package]] 966 1268 name = "memchr" 967 1269 version = "2.8.0" 968 1270 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 996 1298 ] 997 1299 998 1300 [[package]] 1301 + name = "nonempty" 1302 + version = "0.7.0" 1303 + source = "registry+https://github.com/rust-lang/crates.io-index" 1304 + checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" 1305 + 1306 + [[package]] 1307 + name = "nonzero_ext" 1308 + version = "0.3.0" 1309 + source = "registry+https://github.com/rust-lang/crates.io-index" 1310 + checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" 1311 + 1312 + [[package]] 999 1313 name = "num-traits" 1000 1314 version = "0.2.19" 1001 1315 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1017 1331 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1018 1332 1019 1333 [[package]] 1334 + name = "opake-appview" 1335 + version = "0.1.0" 1336 + dependencies = [ 1337 + "anyhow", 1338 + "axum", 1339 + "base64", 1340 + "chrono", 1341 + "clap", 1342 + "ed25519-dalek", 1343 + "env_logger", 1344 + "futures-util", 1345 + "http-body-util", 1346 + "log", 1347 + "opake-core", 1348 + "reqwest", 1349 + "rusqlite", 1350 + "rustls", 1351 + "serde", 1352 + "serde_json", 1353 + "tempfile", 1354 + "thiserror 2.0.18", 1355 + "tokio", 1356 + "tokio-tungstenite", 1357 + "toml", 1358 + "tower", 1359 + "tower_governor", 1360 + ] 1361 + 1362 + [[package]] 1020 1363 name = "opake-cli" 1021 1364 version = "0.1.0" 1022 1365 dependencies = [ ··· 1043 1386 "aes-gcm", 1044 1387 "aes-kw", 1045 1388 "base64", 1389 + "ed25519-dalek", 1046 1390 "getrandom 0.2.17", 1047 1391 "hkdf", 1048 1392 "log", ··· 1096 1440 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1097 1441 1098 1442 [[package]] 1443 + name = "pin-project" 1444 + version = "1.1.11" 1445 + source = "registry+https://github.com/rust-lang/crates.io-index" 1446 + checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" 1447 + dependencies = [ 1448 + "pin-project-internal", 1449 + ] 1450 + 1451 + [[package]] 1452 + name = "pin-project-internal" 1453 + version = "1.1.11" 1454 + source = "registry+https://github.com/rust-lang/crates.io-index" 1455 + checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" 1456 + dependencies = [ 1457 + "proc-macro2", 1458 + "quote", 1459 + "syn", 1460 + ] 1461 + 1462 + [[package]] 1099 1463 name = "pin-project-lite" 1100 1464 version = "0.2.17" 1101 1465 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1106 1470 version = "0.1.0" 1107 1471 source = "registry+https://github.com/rust-lang/crates.io-index" 1108 1472 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1473 + 1474 + [[package]] 1475 + name = "pkcs8" 1476 + version = "0.10.2" 1477 + source = "registry+https://github.com/rust-lang/crates.io-index" 1478 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 1479 + dependencies = [ 1480 + "der", 1481 + "spki", 1482 + ] 1483 + 1484 + [[package]] 1485 + name = "pkg-config" 1486 + version = "0.3.32" 1487 + source = "registry+https://github.com/rust-lang/crates.io-index" 1488 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1109 1489 1110 1490 [[package]] 1111 1491 name = "polyval" ··· 1159 1539 checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1160 1540 dependencies = [ 1161 1541 "unicode-ident", 1542 + ] 1543 + 1544 + [[package]] 1545 + name = "quanta" 1546 + version = "0.12.6" 1547 + source = "registry+https://github.com/rust-lang/crates.io-index" 1548 + checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" 1549 + dependencies = [ 1550 + "crossbeam-utils", 1551 + "libc", 1552 + "once_cell", 1553 + "raw-cpuid", 1554 + "wasi", 1555 + "web-sys", 1556 + "winapi", 1162 1557 ] 1163 1558 1164 1559 [[package]] ··· 1271 1666 ] 1272 1667 1273 1668 [[package]] 1669 + name = "raw-cpuid" 1670 + version = "11.6.0" 1671 + source = "registry+https://github.com/rust-lang/crates.io-index" 1672 + checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" 1673 + dependencies = [ 1674 + "bitflags", 1675 + ] 1676 + 1677 + [[package]] 1274 1678 name = "redox_syscall" 1275 1679 version = "0.5.18" 1276 1680 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1360 1764 ] 1361 1765 1362 1766 [[package]] 1767 + name = "rsqlite-vfs" 1768 + version = "0.1.0" 1769 + source = "registry+https://github.com/rust-lang/crates.io-index" 1770 + checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" 1771 + dependencies = [ 1772 + "hashbrown 0.16.1", 1773 + "thiserror 2.0.18", 1774 + ] 1775 + 1776 + [[package]] 1777 + name = "rusqlite" 1778 + version = "0.38.0" 1779 + source = "registry+https://github.com/rust-lang/crates.io-index" 1780 + checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" 1781 + dependencies = [ 1782 + "bitflags", 1783 + "fallible-iterator", 1784 + "fallible-streaming-iterator", 1785 + "hashlink", 1786 + "libsqlite3-sys", 1787 + "smallvec", 1788 + "sqlite-wasm-rs", 1789 + ] 1790 + 1791 + [[package]] 1363 1792 name = "rustc-hash" 1364 1793 version = "2.1.1" 1365 1794 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1394 1823 checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" 1395 1824 dependencies = [ 1396 1825 "aws-lc-rs", 1826 + "log", 1397 1827 "once_cell", 1828 + "ring", 1398 1829 "rustls-pki-types", 1399 1830 "rustls-webpki", 1400 1831 "subtle", ··· 1469 1900 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1470 1901 1471 1902 [[package]] 1903 + name = "ryu" 1904 + version = "1.0.23" 1905 + source = "registry+https://github.com/rust-lang/crates.io-index" 1906 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1907 + 1908 + [[package]] 1472 1909 name = "same-file" 1473 1910 version = "1.0.6" 1474 1911 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1565 2002 ] 1566 2003 1567 2004 [[package]] 2005 + name = "serde_path_to_error" 2006 + version = "0.1.20" 2007 + source = "registry+https://github.com/rust-lang/crates.io-index" 2008 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 2009 + dependencies = [ 2010 + "itoa", 2011 + "serde", 2012 + "serde_core", 2013 + ] 2014 + 2015 + [[package]] 1568 2016 name = "serde_spanned" 1569 - version = "0.6.9" 2017 + version = "1.0.4" 2018 + source = "registry+https://github.com/rust-lang/crates.io-index" 2019 + checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" 2020 + dependencies = [ 2021 + "serde_core", 2022 + ] 2023 + 2024 + [[package]] 2025 + name = "serde_urlencoded" 2026 + version = "0.7.1" 1570 2027 source = "registry+https://github.com/rust-lang/crates.io-index" 1571 - checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 2028 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1572 2029 dependencies = [ 2030 + "form_urlencoded", 2031 + "itoa", 2032 + "ryu", 1573 2033 "serde", 2034 + ] 2035 + 2036 + [[package]] 2037 + name = "sha1" 2038 + version = "0.10.6" 2039 + source = "registry+https://github.com/rust-lang/crates.io-index" 2040 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 2041 + dependencies = [ 2042 + "cfg-if", 2043 + "cpufeatures", 2044 + "digest", 1574 2045 ] 1575 2046 1576 2047 [[package]] ··· 1601 2072 ] 1602 2073 1603 2074 [[package]] 2075 + name = "signature" 2076 + version = "2.2.0" 2077 + source = "registry+https://github.com/rust-lang/crates.io-index" 2078 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2079 + dependencies = [ 2080 + "rand_core 0.6.4", 2081 + ] 2082 + 2083 + [[package]] 1604 2084 name = "slab" 1605 2085 version = "0.4.12" 1606 2086 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1620 2100 dependencies = [ 1621 2101 "libc", 1622 2102 "windows-sys 0.60.2", 2103 + ] 2104 + 2105 + [[package]] 2106 + name = "spinning_top" 2107 + version = "0.3.0" 2108 + source = "registry+https://github.com/rust-lang/crates.io-index" 2109 + checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" 2110 + dependencies = [ 2111 + "lock_api", 2112 + ] 2113 + 2114 + [[package]] 2115 + name = "spki" 2116 + version = "0.7.3" 2117 + source = "registry+https://github.com/rust-lang/crates.io-index" 2118 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2119 + dependencies = [ 2120 + "base64ct", 2121 + "der", 2122 + ] 2123 + 2124 + [[package]] 2125 + name = "sqlite-wasm-rs" 2126 + version = "0.5.2" 2127 + source = "registry+https://github.com/rust-lang/crates.io-index" 2128 + checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" 2129 + dependencies = [ 2130 + "cc", 2131 + "js-sys", 2132 + "rsqlite-vfs", 2133 + "wasm-bindgen", 1623 2134 ] 1624 2135 1625 2136 [[package]] ··· 1788 2299 ] 1789 2300 1790 2301 [[package]] 2302 + name = "tokio-stream" 2303 + version = "0.1.18" 2304 + source = "registry+https://github.com/rust-lang/crates.io-index" 2305 + checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" 2306 + dependencies = [ 2307 + "futures-core", 2308 + "pin-project-lite", 2309 + "tokio", 2310 + ] 2311 + 2312 + [[package]] 2313 + name = "tokio-tungstenite" 2314 + version = "0.28.0" 2315 + source = "registry+https://github.com/rust-lang/crates.io-index" 2316 + checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" 2317 + dependencies = [ 2318 + "futures-util", 2319 + "log", 2320 + "rustls", 2321 + "rustls-native-certs", 2322 + "rustls-pki-types", 2323 + "tokio", 2324 + "tokio-rustls", 2325 + "tungstenite", 2326 + ] 2327 + 2328 + [[package]] 2329 + name = "tokio-util" 2330 + version = "0.7.18" 2331 + source = "registry+https://github.com/rust-lang/crates.io-index" 2332 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 2333 + dependencies = [ 2334 + "bytes", 2335 + "futures-core", 2336 + "futures-sink", 2337 + "pin-project-lite", 2338 + "tokio", 2339 + ] 2340 + 2341 + [[package]] 1791 2342 name = "toml" 1792 - version = "0.8.23" 2343 + version = "1.0.3+spec-1.1.0" 1793 2344 source = "registry+https://github.com/rust-lang/crates.io-index" 1794 - checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 2345 + checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" 1795 2346 dependencies = [ 1796 - "serde", 2347 + "indexmap", 2348 + "serde_core", 1797 2349 "serde_spanned", 1798 2350 "toml_datetime", 1799 - "toml_edit", 2351 + "toml_parser", 2352 + "toml_writer", 2353 + "winnow", 1800 2354 ] 1801 2355 1802 2356 [[package]] 1803 2357 name = "toml_datetime" 1804 - version = "0.6.11" 2358 + version = "1.0.0+spec-1.1.0" 1805 2359 source = "registry+https://github.com/rust-lang/crates.io-index" 1806 - checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 2360 + checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" 1807 2361 dependencies = [ 1808 - "serde", 2362 + "serde_core", 1809 2363 ] 1810 2364 1811 2365 [[package]] 1812 - name = "toml_edit" 1813 - version = "0.22.27" 2366 + name = "toml_parser" 2367 + version = "1.0.9+spec-1.1.0" 1814 2368 source = "registry+https://github.com/rust-lang/crates.io-index" 1815 - checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 2369 + checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" 1816 2370 dependencies = [ 1817 - "indexmap", 1818 - "serde", 1819 - "serde_spanned", 1820 - "toml_datetime", 1821 - "toml_write", 1822 2371 "winnow", 1823 2372 ] 1824 2373 1825 2374 [[package]] 1826 - name = "toml_write" 1827 - version = "0.1.2" 2375 + name = "toml_writer" 2376 + version = "1.0.6+spec-1.1.0" 1828 2377 source = "registry+https://github.com/rust-lang/crates.io-index" 1829 - checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 2378 + checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" 2379 + 2380 + [[package]] 2381 + name = "tonic" 2382 + version = "0.14.5" 2383 + source = "registry+https://github.com/rust-lang/crates.io-index" 2384 + checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" 2385 + dependencies = [ 2386 + "async-trait", 2387 + "axum", 2388 + "base64", 2389 + "bytes", 2390 + "h2", 2391 + "http", 2392 + "http-body", 2393 + "http-body-util", 2394 + "hyper", 2395 + "hyper-timeout", 2396 + "hyper-util", 2397 + "percent-encoding", 2398 + "pin-project", 2399 + "socket2", 2400 + "sync_wrapper", 2401 + "tokio", 2402 + "tokio-stream", 2403 + "tower", 2404 + "tower-layer", 2405 + "tower-service", 2406 + "tracing", 2407 + ] 1830 2408 1831 2409 [[package]] 1832 2410 name = "tower" ··· 1836 2414 dependencies = [ 1837 2415 "futures-core", 1838 2416 "futures-util", 2417 + "indexmap", 1839 2418 "pin-project-lite", 2419 + "slab", 1840 2420 "sync_wrapper", 1841 2421 "tokio", 2422 + "tokio-util", 1842 2423 "tower-layer", 1843 2424 "tower-service", 2425 + "tracing", 1844 2426 ] 1845 2427 1846 2428 [[package]] ··· 1874 2456 checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1875 2457 1876 2458 [[package]] 2459 + name = "tower_governor" 2460 + version = "0.8.0" 2461 + source = "registry+https://github.com/rust-lang/crates.io-index" 2462 + checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" 2463 + dependencies = [ 2464 + "axum", 2465 + "forwarded-header-value", 2466 + "governor", 2467 + "http", 2468 + "pin-project", 2469 + "thiserror 2.0.18", 2470 + "tonic", 2471 + "tower", 2472 + "tracing", 2473 + ] 2474 + 2475 + [[package]] 1877 2476 name = "tracing" 1878 2477 version = "0.1.44" 1879 2478 source = "registry+https://github.com/rust-lang/crates.io-index" 1880 2479 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1881 2480 dependencies = [ 2481 + "log", 1882 2482 "pin-project-lite", 2483 + "tracing-attributes", 1883 2484 "tracing-core", 1884 2485 ] 1885 2486 1886 2487 [[package]] 2488 + name = "tracing-attributes" 2489 + version = "0.1.31" 2490 + source = "registry+https://github.com/rust-lang/crates.io-index" 2491 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 2492 + dependencies = [ 2493 + "proc-macro2", 2494 + "quote", 2495 + "syn", 2496 + ] 2497 + 2498 + [[package]] 1887 2499 name = "tracing-core" 1888 2500 version = "0.1.36" 1889 2501 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1899 2511 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1900 2512 1901 2513 [[package]] 2514 + name = "tungstenite" 2515 + version = "0.28.0" 2516 + source = "registry+https://github.com/rust-lang/crates.io-index" 2517 + checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" 2518 + dependencies = [ 2519 + "bytes", 2520 + "data-encoding", 2521 + "http", 2522 + "httparse", 2523 + "log", 2524 + "rand", 2525 + "rustls", 2526 + "rustls-pki-types", 2527 + "sha1", 2528 + "thiserror 2.0.18", 2529 + "utf-8", 2530 + ] 2531 + 2532 + [[package]] 1902 2533 name = "typenum" 1903 2534 version = "1.19.0" 1904 2535 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1945 2576 ] 1946 2577 1947 2578 [[package]] 2579 + name = "utf-8" 2580 + version = "0.7.6" 2581 + source = "registry+https://github.com/rust-lang/crates.io-index" 2582 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2583 + 2584 + [[package]] 1948 2585 name = "utf8_iter" 1949 2586 version = "1.0.4" 1950 2587 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1955 2592 version = "0.2.2" 1956 2593 source = "registry+https://github.com/rust-lang/crates.io-index" 1957 2594 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2595 + 2596 + [[package]] 2597 + name = "vcpkg" 2598 + version = "0.2.15" 2599 + source = "registry+https://github.com/rust-lang/crates.io-index" 2600 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1958 2601 1959 2602 [[package]] 1960 2603 name = "version_check" ··· 2085 2728 ] 2086 2729 2087 2730 [[package]] 2731 + name = "winapi" 2732 + version = "0.3.9" 2733 + source = "registry+https://github.com/rust-lang/crates.io-index" 2734 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2735 + dependencies = [ 2736 + "winapi-i686-pc-windows-gnu", 2737 + "winapi-x86_64-pc-windows-gnu", 2738 + ] 2739 + 2740 + [[package]] 2741 + name = "winapi-i686-pc-windows-gnu" 2742 + version = "0.4.0" 2743 + source = "registry+https://github.com/rust-lang/crates.io-index" 2744 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2745 + 2746 + [[package]] 2088 2747 name = "winapi-util" 2089 2748 version = "0.1.11" 2090 2749 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2092 2751 dependencies = [ 2093 2752 "windows-sys 0.61.2", 2094 2753 ] 2754 + 2755 + [[package]] 2756 + name = "winapi-x86_64-pc-windows-gnu" 2757 + version = "0.4.0" 2758 + source = "registry+https://github.com/rust-lang/crates.io-index" 2759 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2095 2760 2096 2761 [[package]] 2097 2762 name = "windows-core" ··· 2379 3044 version = "0.7.14" 2380 3045 source = "registry+https://github.com/rust-lang/crates.io-index" 2381 3046 checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 2382 - dependencies = [ 2383 - "memchr", 2384 - ] 2385 3047 2386 3048 [[package]] 2387 3049 name = "wit-bindgen"
+9 -1
Cargo.toml
··· 1 1 [workspace] 2 - members = ["crates/opake-core", "crates/opake-cli"] 2 + members = ["crates/opake-core", "crates/opake-cli", "crates/opake-appview"] 3 3 resolver = "2" 4 4 5 5 [workspace.package] ··· 8 8 license = "AGPL-3.0-or-later" 9 9 10 10 [workspace.dependencies] 11 + anyhow = "1" 11 12 base64 = "0.22" 12 13 chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } 14 + clap = { version = "4", features = ["derive"] } 15 + ed25519-dalek = { version = "2", features = ["rand_core"] } 16 + env_logger = "0.11" 13 17 log = "0.4" 18 + reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } 14 19 serde = { version = "1", features = ["derive"] } 15 20 serde_json = "1" 21 + tempfile = "3" 16 22 thiserror = "2" 23 + tokio = { version = "1", features = ["full"] } 24 + toml = "1"
+38
crates/opake-appview/Cargo.toml
··· 1 + [package] 2 + name = "opake-appview" 3 + description = "AppView indexer for Opake — indexes grants and keyrings from the AT Protocol firehose" 4 + edition.workspace = true 5 + version.workspace = true 6 + license.workspace = true 7 + 8 + [[bin]] 9 + name = "opake-appview" 10 + path = "src/main.rs" 11 + 12 + [dependencies] 13 + opake-core = { path = "../opake-core" } 14 + axum = "0.8" 15 + base64.workspace = true 16 + chrono.workspace = true 17 + clap.workspace = true 18 + ed25519-dalek.workspace = true 19 + env_logger.workspace = true 20 + futures-util = "0.3" 21 + log.workspace = true 22 + reqwest.workspace = true 23 + rusqlite = { version = "0.38", features = ["bundled"] } 24 + rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } 25 + serde.workspace = true 26 + serde_json.workspace = true 27 + thiserror.workspace = true 28 + tokio.workspace = true 29 + tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } 30 + toml.workspace = true 31 + tower_governor = "0.8" 32 + anyhow.workspace = true 33 + 34 + [dev-dependencies] 35 + http-body-util = "0.1" 36 + tempfile.workspace = true 37 + tokio = { workspace = true, features = ["test-util"] } 38 + tower = { version = "0.5", features = ["util"] }
+243
crates/opake-appview/src/api/api_tests.rs
··· 1 + use std::sync::Arc; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use axum::routing::get; 6 + use axum::Router; 7 + use http_body_util::BodyExt; 8 + use tower::ServiceExt; 9 + 10 + use crate::api; 11 + use crate::api::{health, inbox, keyrings}; 12 + use crate::db::grants::IndexedGrant; 13 + use crate::db::Database; 14 + use crate::db::{grants, keyrings as db_keyrings}; 15 + use crate::state::AppState; 16 + 17 + fn test_state() -> Arc<AppState> { 18 + let db = Database::open_in_memory().unwrap(); 19 + Arc::new(AppState::new(db)) 20 + } 21 + 22 + /// Router WITHOUT auth middleware — for testing handler logic in isolation. 23 + fn handler_router(state: Arc<AppState>) -> Router { 24 + Router::new() 25 + .route("/api/health", get(health::handle_health)) 26 + .route("/api/inbox", get(inbox::handle_inbox)) 27 + .route("/api/keyrings", get(keyrings::handle_keyrings)) 28 + .with_state(state) 29 + } 30 + 31 + /// Router WITH auth middleware but WITHOUT rate limiting — for testing auth rejection. 32 + fn auth_router(state: Arc<AppState>) -> Router { 33 + api::base_router(state) 34 + } 35 + 36 + fn get_request(uri: &str) -> Request<Body> { 37 + Request::builder().uri(uri).body(Body::empty()).unwrap() 38 + } 39 + 40 + async fn response_json(app: Router, req: Request<Body>) -> (StatusCode, serde_json::Value) { 41 + let resp = app.oneshot(req).await.unwrap(); 42 + let status = resp.status(); 43 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 44 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 45 + (status, json) 46 + } 47 + 48 + // -- auth middleware tests (use auth_router) -- 49 + 50 + #[tokio::test] 51 + async fn protected_routes_require_auth() { 52 + let state = test_state(); 53 + let app = auth_router(state); 54 + let (status, json) = response_json(app, get_request("/api/inbox?did=test")).await; 55 + assert_eq!(status, StatusCode::UNAUTHORIZED); 56 + assert!(json["error"].as_str().unwrap().contains("authorization")); 57 + } 58 + 59 + #[tokio::test] 60 + async fn rejects_bearer_token() { 61 + let state = test_state(); 62 + let app = auth_router(state); 63 + let req = Request::builder() 64 + .uri("/api/inbox?did=test") 65 + .header("authorization", "Bearer some-token") 66 + .body(Body::empty()) 67 + .unwrap(); 68 + let (status, json) = response_json(app, req).await; 69 + assert_eq!(status, StatusCode::UNAUTHORIZED); 70 + assert!(json["error"].as_str().unwrap().contains("unsupported")); 71 + } 72 + 73 + #[tokio::test] 74 + async fn rejects_basic_auth() { 75 + let state = test_state(); 76 + let app = auth_router(state); 77 + let req = Request::builder() 78 + .uri("/api/inbox?did=test") 79 + .header("authorization", "Basic dXNlcjpwYXNz") 80 + .body(Body::empty()) 81 + .unwrap(); 82 + let (status, json) = response_json(app, req).await; 83 + assert_eq!(status, StatusCode::UNAUTHORIZED); 84 + assert!(json["error"].as_str().unwrap().contains("unsupported")); 85 + } 86 + 87 + #[tokio::test] 88 + async fn health_works_without_auth() { 89 + let state = test_state(); 90 + let app = auth_router(state); 91 + let (status, json) = response_json(app, get_request("/api/health")).await; 92 + assert_eq!(status, StatusCode::OK); 93 + assert_eq!(json["indexerConnected"], false); 94 + assert!(json["cursorTime"].is_null()); 95 + assert!(json.get("grantCount").is_none()); 96 + assert!(json.get("keyringCount").is_none()); 97 + } 98 + 99 + // -- handler logic tests (use handler_router, no auth) -- 100 + 101 + #[tokio::test] 102 + async fn inbox_requires_did() { 103 + let state = test_state(); 104 + let app = handler_router(state); 105 + let (status, json) = response_json(app, get_request("/api/inbox")).await; 106 + assert_eq!(status, StatusCode::BAD_REQUEST); 107 + assert!(json["error"].as_str().unwrap().contains("did")); 108 + } 109 + 110 + #[tokio::test] 111 + async fn inbox_returns_empty_for_unknown_did() { 112 + let state = test_state(); 113 + let app = handler_router(state); 114 + let (status, json) = response_json(app, get_request("/api/inbox?did=did:plc:nobody")).await; 115 + assert_eq!(status, StatusCode::OK); 116 + assert_eq!(json["grants"].as_array().unwrap().len(), 0); 117 + assert!(json.get("cursor").is_none() || json["cursor"].is_null()); 118 + } 119 + 120 + #[tokio::test] 121 + async fn inbox_returns_grants() { 122 + let state = test_state(); 123 + 124 + let grant = IndexedGrant { 125 + uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 126 + owner_did: "did:plc:owner".into(), 127 + recipient_did: "did:plc:me".into(), 128 + document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 129 + permissions: Some("read".into()), 130 + note: Some("test file".into()), 131 + created_at: "2026-03-01T12:00:00Z".into(), 132 + indexed_at: "2026-03-01T12:00:01Z".into(), 133 + }; 134 + state 135 + .db 136 + .with_conn(|c| grants::upsert_grant(c, &grant)) 137 + .unwrap(); 138 + 139 + let app = handler_router(state); 140 + let (status, json) = response_json(app, get_request("/api/inbox?did=did:plc:me")).await; 141 + assert_eq!(status, StatusCode::OK); 142 + 143 + let items = json["grants"].as_array().unwrap(); 144 + assert_eq!(items.len(), 1); 145 + assert_eq!(items[0]["ownerDid"], "did:plc:owner"); 146 + assert_eq!( 147 + items[0]["documentUri"], 148 + "at://did:plc:owner/app.opake.cloud.document/3xyz" 149 + ); 150 + assert_eq!(items[0]["permissions"], "read"); 151 + assert_eq!(items[0]["note"], "test file"); 152 + } 153 + 154 + #[tokio::test] 155 + async fn inbox_pagination() { 156 + let state = test_state(); 157 + 158 + for i in 0..5 { 159 + let grant = IndexedGrant { 160 + uri: format!("at://did:plc:owner/app.opake.cloud.grant/{i}"), 161 + owner_did: "did:plc:owner".into(), 162 + recipient_did: "did:plc:me".into(), 163 + document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{i}"), 164 + permissions: None, 165 + note: None, 166 + created_at: "2026-03-01T12:00:00Z".into(), 167 + indexed_at: format!("2026-03-01T12:00:0{i}Z"), 168 + }; 169 + state 170 + .db 171 + .with_conn(|c| grants::upsert_grant(c, &grant)) 172 + .unwrap(); 173 + } 174 + 175 + let app = handler_router(state); 176 + let (status, json) = response_json(app, get_request("/api/inbox?did=did:plc:me&limit=3")).await; 177 + assert_eq!(status, StatusCode::OK); 178 + assert_eq!(json["grants"].as_array().unwrap().len(), 3); 179 + assert!(json["cursor"].is_string()); 180 + } 181 + 182 + #[tokio::test] 183 + async fn keyrings_requires_did() { 184 + let state = test_state(); 185 + let app = handler_router(state); 186 + let (status, json) = response_json(app, get_request("/api/keyrings")).await; 187 + assert_eq!(status, StatusCode::BAD_REQUEST); 188 + assert!(json["error"].as_str().unwrap().contains("did")); 189 + } 190 + 191 + #[tokio::test] 192 + async fn keyrings_returns_memberships() { 193 + let state = test_state(); 194 + 195 + state 196 + .db 197 + .with_conn(|c| { 198 + db_keyrings::upsert_keyring_members( 199 + c, 200 + "at://did:plc:owner/app.opake.cloud.keyring/3def", 201 + "did:plc:owner", 202 + "family-photos", 203 + &["did:plc:me".into(), "did:plc:other".into()], 204 + "2026-03-01T12:00:00Z", 205 + ) 206 + }) 207 + .unwrap(); 208 + 209 + let app = handler_router(state); 210 + let (status, json) = response_json(app, get_request("/api/keyrings?did=did:plc:me")).await; 211 + assert_eq!(status, StatusCode::OK); 212 + 213 + let items = json["keyrings"].as_array().unwrap(); 214 + assert_eq!(items.len(), 1); 215 + assert_eq!(items[0]["ownerDid"], "did:plc:owner"); 216 + assert_eq!(items[0]["name"], "family-photos"); 217 + } 218 + 219 + #[tokio::test] 220 + async fn health_omits_counts() { 221 + let state = test_state(); 222 + 223 + let grant = IndexedGrant { 224 + uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 225 + owner_did: "did:plc:owner".into(), 226 + recipient_did: "did:plc:me".into(), 227 + document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 228 + permissions: None, 229 + note: None, 230 + created_at: "2026-03-01T12:00:00Z".into(), 231 + indexed_at: "2026-03-01T12:00:01Z".into(), 232 + }; 233 + state 234 + .db 235 + .with_conn(|c| grants::upsert_grant(c, &grant)) 236 + .unwrap(); 237 + 238 + let app = handler_router(state); 239 + let (status, json) = response_json(app, get_request("/api/health")).await; 240 + assert_eq!(status, StatusCode::OK); 241 + assert!(json.get("grantCount").is_none()); 242 + assert!(json.get("keyringCount").is_none()); 243 + }
+188
crates/opake-appview/src/api/auth.rs
··· 1 + use std::sync::Arc; 2 + 3 + use axum::extract::{Request, State}; 4 + use axum::http::StatusCode; 5 + use axum::middleware::Next; 6 + use axum::response::{IntoResponse, Json, Response}; 7 + use base64::engine::general_purpose::STANDARD as BASE64; 8 + use base64::Engine; 9 + use ed25519_dalek::{Signature, Verifier}; 10 + 11 + use crate::api::types::ErrorResponse; 12 + use crate::state::AppState; 13 + 14 + /// Maximum age of a signed request timestamp (replay protection). 15 + const MAX_TIMESTAMP_DRIFT_SECS: i64 = 60; 16 + 17 + /// Auth middleware: validates `Opake-Ed25519` DID-scoped signatures. 18 + /// 19 + /// Header format: 20 + /// Authorization: Opake-Ed25519 <did>:<unix-timestamp>:<base64(signature)> 21 + /// Signature covers: <method>:<path>:<timestamp>:<did> 22 + pub async fn require_auth( 23 + State(state): State<Arc<AppState>>, 24 + req: Request, 25 + next: Next, 26 + ) -> Response { 27 + let auth_header = req 28 + .headers() 29 + .get("authorization") 30 + .and_then(|v| v.to_str().ok()) 31 + .map(|s| s.to_string()); 32 + 33 + let method = req.method().as_str().to_string(); 34 + let path = req.uri().path().to_string(); 35 + let query_did = extract_did_param(req.uri().query()); 36 + 37 + match auth_header.as_deref() { 38 + Some(h) if h.starts_with("Opake-Ed25519 ") => { 39 + match verify_did_auth(&state, h, &method, &path, query_did.as_deref()).await { 40 + Ok(()) => next.run(req).await, 41 + Err(msg) => unauthorized(&msg), 42 + } 43 + } 44 + Some(_) => unauthorized("unsupported authorization scheme — use Opake-Ed25519"), 45 + None => unauthorized("missing authorization header"), 46 + } 47 + } 48 + 49 + fn unauthorized(message: &str) -> Response { 50 + ( 51 + StatusCode::UNAUTHORIZED, 52 + Json(ErrorResponse { 53 + error: message.to_string(), 54 + }), 55 + ) 56 + .into_response() 57 + } 58 + 59 + /// Parse and verify an Opake-Ed25519 authorization header. 60 + async fn verify_did_auth( 61 + state: &AppState, 62 + header: &str, 63 + method: &str, 64 + path: &str, 65 + query_did: Option<&str>, 66 + ) -> Result<(), String> { 67 + let payload = header 68 + .strip_prefix("Opake-Ed25519 ") 69 + .ok_or("malformed auth header")?; 70 + 71 + let (did, timestamp_str, sig_b64) = parse_auth_payload(payload)?; 72 + 73 + // Enforce: authenticated DID must match the ?did= query parameter 74 + if let Some(qd) = query_did { 75 + if qd != did { 76 + return Err(format!( 77 + "authenticated as {did} but requesting data for {qd}" 78 + )); 79 + } 80 + } 81 + 82 + // Validate timestamp (replay protection) 83 + let timestamp: i64 = timestamp_str 84 + .parse() 85 + .map_err(|_| "invalid timestamp in auth header")?; 86 + let now = chrono::Utc::now().timestamp(); 87 + let drift = (now - timestamp).abs(); 88 + if drift > MAX_TIMESTAMP_DRIFT_SECS { 89 + return Err(format!( 90 + "timestamp too far from current time ({drift}s drift, max {MAX_TIMESTAMP_DRIFT_SECS}s)" 91 + )); 92 + } 93 + 94 + // Decode signature 95 + let sig_bytes = BASE64 96 + .decode(sig_b64) 97 + .or_else(|_| { 98 + use base64::engine::general_purpose::STANDARD_NO_PAD; 99 + STANDARD_NO_PAD.decode(sig_b64) 100 + }) 101 + .map_err(|_| "invalid base64 in signature")?; 102 + let signature = 103 + Signature::from_slice(&sig_bytes).map_err(|_| "invalid Ed25519 signature format")?; 104 + 105 + // Fetch/cache the signing key 106 + let verifying_key = state 107 + .key_cache 108 + .lock() 109 + .await 110 + .get_or_fetch(did) 111 + .await 112 + .map_err(|e| format!("failed to fetch signing key: {e}"))?; 113 + 114 + // Verify signature over: <method>:<path>:<timestamp>:<did> 115 + let message = format!("{method}:{path}:{timestamp_str}:{did}"); 116 + verifying_key 117 + .verify(message.as_bytes(), &signature) 118 + .map_err(|_| "signature verification failed".to_string()) 119 + } 120 + 121 + /// Parse the auth payload. DIDs contain colons, so we split from the right: 122 + /// last segment = signature, second-to-last = timestamp, rest = DID. 123 + fn parse_auth_payload(payload: &str) -> Result<(&str, &str, &str), String> { 124 + let last_colon = payload.rfind(':').ok_or("malformed auth payload")?; 125 + let sig = &payload[last_colon + 1..]; 126 + let rest = &payload[..last_colon]; 127 + 128 + let second_last = rest.rfind(':').ok_or("malformed auth payload")?; 129 + let timestamp = &rest[second_last + 1..]; 130 + let did = &rest[..second_last]; 131 + 132 + if did.is_empty() || timestamp.is_empty() || sig.is_empty() { 133 + return Err("malformed auth payload: empty component".into()); 134 + } 135 + 136 + Ok((did, timestamp, sig)) 137 + } 138 + 139 + /// Extract the `did` query parameter from a raw query string. 140 + fn extract_did_param(query: Option<&str>) -> Option<String> { 141 + query.and_then(|q| { 142 + q.split('&') 143 + .find_map(|pair| pair.strip_prefix("did=").map(|v| v.to_string())) 144 + }) 145 + } 146 + 147 + #[cfg(test)] 148 + mod tests { 149 + use super::*; 150 + 151 + #[test] 152 + fn parse_auth_payload_valid() { 153 + let (did, ts, sig) = parse_auth_payload("did:plc:abc123:1709330400:c2lnbmF0dXJl").unwrap(); 154 + assert_eq!(did, "did:plc:abc123"); 155 + assert_eq!(ts, "1709330400"); 156 + assert_eq!(sig, "c2lnbmF0dXJl"); 157 + } 158 + 159 + #[test] 160 + fn parse_auth_payload_did_web() { 161 + let (did, ts, sig) = parse_auth_payload("did:web:example.com:1709330400:c2ln").unwrap(); 162 + assert_eq!(did, "did:web:example.com"); 163 + assert_eq!(ts, "1709330400"); 164 + assert_eq!(sig, "c2ln"); 165 + } 166 + 167 + #[test] 168 + fn parse_auth_payload_rejects_garbage() { 169 + assert!(parse_auth_payload("nocolons").is_err()); 170 + assert!(parse_auth_payload("one:colon").is_err()); 171 + } 172 + 173 + #[test] 174 + fn parse_auth_payload_rejects_empty_components() { 175 + assert!(parse_auth_payload("::sig").is_err()); 176 + assert!(parse_auth_payload("did::sig").is_err()); 177 + } 178 + 179 + #[test] 180 + fn extract_did_from_query() { 181 + assert_eq!( 182 + extract_did_param(Some("did=did:plc:abc&limit=10")), 183 + Some("did:plc:abc".into()) 184 + ); 185 + assert_eq!(extract_did_param(Some("limit=10")), None); 186 + assert_eq!(extract_did_param(None), None); 187 + } 188 + }
+39
crates/opake-appview/src/api/health.rs
··· 1 + use std::sync::atomic::Ordering; 2 + use std::sync::Arc; 3 + 4 + use axum::extract::State; 5 + use axum::response::{IntoResponse, Json}; 6 + use serde::Serialize; 7 + 8 + use crate::db::cursor; 9 + use crate::state::AppState; 10 + 11 + #[derive(Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + struct HealthResponse { 14 + indexer_connected: bool, 15 + cursor_time: Option<String>, 16 + cursor_age_secs: Option<i64>, 17 + } 18 + 19 + pub async fn handle_health(State(state): State<Arc<AppState>>) -> impl IntoResponse { 20 + let indexer_connected = state.indexer_connected.load(Ordering::Relaxed); 21 + 22 + let cursor_us = state.db.with_conn(cursor::load_cursor).unwrap_or(None); 23 + 24 + let (cursor_time, cursor_age_secs) = match cursor_us { 25 + Some(us) => { 26 + let secs = us / 1_000_000; 27 + let now = chrono::Utc::now().timestamp(); 28 + let time = chrono::DateTime::from_timestamp(secs, 0).map(|dt| dt.to_rfc3339()); 29 + (time, Some(now - secs)) 30 + } 31 + None => (None, None), 32 + }; 33 + 34 + Json(HealthResponse { 35 + indexer_connected, 36 + cursor_time, 37 + cursor_age_secs, 38 + }) 39 + }
+78
crates/opake-appview/src/api/inbox.rs
··· 1 + use std::sync::Arc; 2 + 3 + use axum::extract::{Query, State}; 4 + use axum::http::StatusCode; 5 + use axum::response::{IntoResponse, Json}; 6 + use serde::Deserialize; 7 + 8 + use crate::api::types::{ErrorResponse, GrantItem, InboxResponse}; 9 + use crate::db::grants; 10 + use crate::state::AppState; 11 + 12 + const DEFAULT_LIMIT: u32 = 50; 13 + const MAX_LIMIT: u32 = 100; 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct InboxParams { 17 + pub did: Option<String>, 18 + pub limit: Option<u32>, 19 + pub cursor: Option<String>, 20 + } 21 + 22 + pub async fn handle_inbox( 23 + State(state): State<Arc<AppState>>, 24 + Query(params): Query<InboxParams>, 25 + ) -> impl IntoResponse { 26 + let did = match &params.did { 27 + Some(d) if !d.is_empty() => d.as_str(), 28 + _ => { 29 + return ( 30 + StatusCode::BAD_REQUEST, 31 + Json( 32 + serde_json::to_value(ErrorResponse { 33 + error: "missing required parameter: did".into(), 34 + }) 35 + .expect("ErrorResponse serializes"), 36 + ), 37 + ) 38 + .into_response(); 39 + } 40 + }; 41 + 42 + let limit = params.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); 43 + let cursor_ref = params.cursor.as_deref(); 44 + 45 + let result = state 46 + .db 47 + .with_conn(|conn| grants::list_inbox(conn, did, limit, cursor_ref)); 48 + 49 + match result { 50 + Ok(indexed_grants) => { 51 + let next_cursor = indexed_grants.last().map(grants::encode_cursor); 52 + let items: Vec<GrantItem> = indexed_grants.iter().map(GrantItem::from).collect(); 53 + 54 + let response = InboxResponse { 55 + grants: items, 56 + cursor: next_cursor, 57 + }; 58 + ( 59 + StatusCode::OK, 60 + Json(serde_json::to_value(response).expect("InboxResponse serializes")), 61 + ) 62 + .into_response() 63 + } 64 + Err(e) => { 65 + log::error!("inbox query failed: {e}"); 66 + ( 67 + StatusCode::INTERNAL_SERVER_ERROR, 68 + Json( 69 + serde_json::to_value(ErrorResponse { 70 + error: "internal server error".into(), 71 + }) 72 + .expect("ErrorResponse serializes"), 73 + ), 74 + ) 75 + .into_response() 76 + } 77 + } 78 + }
+142
crates/opake-appview/src/api/key_cache.rs
··· 1 + use std::collections::HashMap; 2 + use std::time::{Duration, Instant}; 3 + 4 + use ed25519_dalek::VerifyingKey; 5 + 6 + use crate::error::{Error, Result}; 7 + 8 + const TTL: Duration = Duration::from_secs(300); // 5 minutes 9 + 10 + /// Caches Ed25519 verifying keys fetched from users' PDS public key records. 11 + pub struct KeyCache { 12 + entries: HashMap<String, CacheEntry>, 13 + } 14 + 15 + struct CacheEntry { 16 + key: VerifyingKey, 17 + fetched_at: Instant, 18 + } 19 + 20 + impl KeyCache { 21 + pub fn new() -> Self { 22 + Self { 23 + entries: HashMap::new(), 24 + } 25 + } 26 + 27 + /// Return cached key if fresh, otherwise fetch from the user's PDS. 28 + pub async fn get_or_fetch(&mut self, did: &str) -> Result<VerifyingKey> { 29 + if let Some(entry) = self.entries.get(did) { 30 + if entry.fetched_at.elapsed() < TTL { 31 + return Ok(entry.key); 32 + } 33 + } 34 + 35 + let key = fetch_signing_key(did).await?; 36 + self.entries.insert( 37 + did.to_string(), 38 + CacheEntry { 39 + key, 40 + fetched_at: Instant::now(), 41 + }, 42 + ); 43 + Ok(key) 44 + } 45 + } 46 + 47 + /// Fetch the Ed25519 signing key from a user's `app.opake.cloud.publicKey/self` record. 48 + /// 49 + /// Resolution: DID → DID document → PDS URL → getRecord → signingKey field. 50 + async fn fetch_signing_key(did: &str) -> Result<VerifyingKey> { 51 + let client = reqwest::Client::new(); 52 + 53 + // Step 1: Resolve DID document to find PDS URL 54 + let did_doc_url = if did.starts_with("did:plc:") { 55 + format!("https://plc.directory/{did}") 56 + } else if did.starts_with("did:web:") { 57 + let host = did.strip_prefix("did:web:").unwrap_or(""); 58 + format!("https://{host}/.well-known/did.json") 59 + } else { 60 + return Err(Error::Auth(format!("unsupported DID method: {did}"))); 61 + }; 62 + 63 + let did_doc: serde_json::Value = client 64 + .get(&did_doc_url) 65 + .send() 66 + .await 67 + .map_err(|e| Error::Auth(format!("failed to fetch DID document for {did}: {e}")))? 68 + .json() 69 + .await 70 + .map_err(|e| Error::Auth(format!("invalid DID document for {did}: {e}")))?; 71 + 72 + let pds_url = did_doc["service"] 73 + .as_array() 74 + .and_then(|services| { 75 + services.iter().find_map(|s| { 76 + if s["id"].as_str() == Some("#atproto_pds") { 77 + s["serviceEndpoint"].as_str().map(|u| u.to_string()) 78 + } else { 79 + None 80 + } 81 + }) 82 + }) 83 + .ok_or_else(|| Error::Auth(format!("no PDS service in DID document for {did}")))?; 84 + 85 + // Step 2: Fetch public key record from the user's PDS 86 + let record_url = format!( 87 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.opake.cloud.publicKey&rkey=self", 88 + pds_url.trim_end_matches('/'), 89 + did 90 + ); 91 + 92 + let record_resp: serde_json::Value = client 93 + .get(&record_url) 94 + .send() 95 + .await 96 + .map_err(|e| Error::Auth(format!("failed to fetch public key record for {did}: {e}")))? 97 + .json() 98 + .await 99 + .map_err(|e| Error::Auth(format!("invalid public key record for {did}: {e}")))?; 100 + 101 + // Step 3: Extract signing key from the record 102 + let signing_key_b64 = record_resp["value"]["signingKey"]["$bytes"] 103 + .as_str() 104 + .ok_or_else(|| { 105 + Error::Auth(format!( 106 + "no signingKey in public key record for {did} — user needs to re-login to publish signing key" 107 + )) 108 + })?; 109 + 110 + use base64::engine::general_purpose::STANDARD as BASE64; 111 + use base64::Engine; 112 + 113 + let key_bytes = BASE64 114 + .decode(signing_key_b64) 115 + .or_else(|_| { 116 + // PDS may strip padding 117 + use base64::engine::general_purpose::STANDARD_NO_PAD; 118 + STANDARD_NO_PAD.decode(signing_key_b64) 119 + }) 120 + .map_err(|e| Error::Auth(format!("invalid base64 in signing key for {did}: {e}")))?; 121 + 122 + let key_array: [u8; 32] = key_bytes.try_into().map_err(|v: Vec<u8>| { 123 + Error::Auth(format!( 124 + "signing key for {did} is {} bytes, expected 32", 125 + v.len() 126 + )) 127 + })?; 128 + 129 + VerifyingKey::from_bytes(&key_array) 130 + .map_err(|e| Error::Auth(format!("invalid Ed25519 key for {did}: {e}"))) 131 + } 132 + 133 + #[cfg(test)] 134 + mod tests { 135 + use super::*; 136 + 137 + #[test] 138 + fn cache_returns_none_for_missing_key() { 139 + let cache = KeyCache::new(); 140 + assert!(!cache.entries.contains_key("did:plc:unknown")); 141 + } 142 + }
+78
crates/opake-appview/src/api/keyrings.rs
··· 1 + use std::sync::Arc; 2 + 3 + use axum::extract::{Query, State}; 4 + use axum::http::StatusCode; 5 + use axum::response::{IntoResponse, Json}; 6 + use serde::Deserialize; 7 + 8 + use crate::api::types::{ErrorResponse, KeyringItem, KeyringsResponse}; 9 + use crate::db::keyrings; 10 + use crate::state::AppState; 11 + 12 + const DEFAULT_LIMIT: u32 = 50; 13 + const MAX_LIMIT: u32 = 100; 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct KeyringsParams { 17 + pub did: Option<String>, 18 + pub limit: Option<u32>, 19 + pub cursor: Option<String>, 20 + } 21 + 22 + pub async fn handle_keyrings( 23 + State(state): State<Arc<AppState>>, 24 + Query(params): Query<KeyringsParams>, 25 + ) -> impl IntoResponse { 26 + let did = match &params.did { 27 + Some(d) if !d.is_empty() => d.as_str(), 28 + _ => { 29 + return ( 30 + StatusCode::BAD_REQUEST, 31 + Json( 32 + serde_json::to_value(ErrorResponse { 33 + error: "missing required parameter: did".into(), 34 + }) 35 + .expect("ErrorResponse serializes"), 36 + ), 37 + ) 38 + .into_response(); 39 + } 40 + }; 41 + 42 + let limit = params.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); 43 + let cursor_ref = params.cursor.as_deref(); 44 + 45 + let result = state 46 + .db 47 + .with_conn(|conn| keyrings::list_keyrings_for_member(conn, did, limit, cursor_ref)); 48 + 49 + match result { 50 + Ok(members) => { 51 + let next_cursor = members.last().map(keyrings::encode_cursor); 52 + let items: Vec<KeyringItem> = members.iter().map(KeyringItem::from).collect(); 53 + 54 + let response = KeyringsResponse { 55 + keyrings: items, 56 + cursor: next_cursor, 57 + }; 58 + ( 59 + StatusCode::OK, 60 + Json(serde_json::to_value(response).expect("KeyringsResponse serializes")), 61 + ) 62 + .into_response() 63 + } 64 + Err(e) => { 65 + log::error!("keyrings query failed: {e}"); 66 + ( 67 + StatusCode::INTERNAL_SERVER_ERROR, 68 + Json( 69 + serde_json::to_value(ErrorResponse { 70 + error: "internal server error".into(), 71 + }) 72 + .expect("ErrorResponse serializes"), 73 + ), 74 + ) 75 + .into_response() 76 + } 77 + } 78 + }
+62
crates/opake-appview/src/api/mod.rs
··· 1 + pub mod auth; 2 + pub mod health; 3 + pub mod inbox; 4 + pub mod key_cache; 5 + pub mod keyrings; 6 + pub mod types; 7 + 8 + use std::sync::Arc; 9 + 10 + use axum::middleware; 11 + use axum::routing::get; 12 + use axum::Router; 13 + use tower_governor::governor::GovernorConfigBuilder; 14 + use tower_governor::key_extractor::SmartIpKeyExtractor; 15 + use tower_governor::GovernorLayer; 16 + 17 + use crate::state::AppState; 18 + 19 + /// Routes + auth middleware, without rate limiting. Used by tests. 20 + #[cfg(test)] 21 + pub(crate) fn base_router(state: Arc<AppState>) -> Router { 22 + let protected = Router::new() 23 + .route("/api/inbox", get(inbox::handle_inbox)) 24 + .route("/api/keyrings", get(keyrings::handle_keyrings)) 25 + .layer(middleware::from_fn_with_state( 26 + state.clone(), 27 + auth::require_auth, 28 + )); 29 + 30 + Router::new() 31 + .route("/api/health", get(health::handle_health)) 32 + .merge(protected) 33 + .with_state(state) 34 + } 35 + 36 + /// Build the Axum router with all API routes, auth middleware, and rate limiting. 37 + pub fn router(state: Arc<AppState>) -> Router { 38 + let protected = Router::new() 39 + .route("/api/inbox", get(inbox::handle_inbox)) 40 + .route("/api/keyrings", get(keyrings::handle_keyrings)) 41 + .layer(middleware::from_fn_with_state( 42 + state.clone(), 43 + auth::require_auth, 44 + )); 45 + 46 + let governor_config = GovernorConfigBuilder::default() 47 + .per_second(10) 48 + .burst_size(30) 49 + .key_extractor(SmartIpKeyExtractor) 50 + .finish() 51 + .expect("governor config"); 52 + 53 + Router::new() 54 + .route("/api/health", get(health::handle_health)) 55 + .merge(protected) 56 + .layer(GovernorLayer::new(governor_config)) 57 + .with_state(state) 58 + } 59 + 60 + #[cfg(test)] 61 + #[path = "api_tests.rs"] 62 + mod tests;
+70
crates/opake-appview/src/api/types.rs
··· 1 + use serde::Serialize; 2 + 3 + use crate::db::grants::IndexedGrant; 4 + use crate::db::keyrings::IndexedKeyringMember; 5 + 6 + #[derive(Debug, Serialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct InboxResponse { 9 + pub grants: Vec<GrantItem>, 10 + #[serde(skip_serializing_if = "Option::is_none")] 11 + pub cursor: Option<String>, 12 + } 13 + 14 + #[derive(Debug, Serialize)] 15 + #[serde(rename_all = "camelCase")] 16 + pub struct GrantItem { 17 + pub uri: String, 18 + pub owner_did: String, 19 + pub document_uri: String, 20 + pub permissions: Option<String>, 21 + pub note: Option<String>, 22 + pub created_at: String, 23 + } 24 + 25 + impl From<&IndexedGrant> for GrantItem { 26 + fn from(g: &IndexedGrant) -> Self { 27 + Self { 28 + uri: g.uri.clone(), 29 + owner_did: g.owner_did.clone(), 30 + document_uri: g.document_uri.clone(), 31 + permissions: g.permissions.clone(), 32 + note: g.note.clone(), 33 + created_at: g.created_at.clone(), 34 + } 35 + } 36 + } 37 + 38 + #[derive(Debug, Serialize)] 39 + #[serde(rename_all = "camelCase")] 40 + pub struct KeyringsResponse { 41 + pub keyrings: Vec<KeyringItem>, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub cursor: Option<String>, 44 + } 45 + 46 + #[derive(Debug, Serialize)] 47 + #[serde(rename_all = "camelCase")] 48 + pub struct KeyringItem { 49 + pub uri: String, 50 + pub owner_did: String, 51 + pub name: String, 52 + pub indexed_at: String, 53 + } 54 + 55 + impl From<&IndexedKeyringMember> for KeyringItem { 56 + fn from(m: &IndexedKeyringMember) -> Self { 57 + Self { 58 + uri: m.keyring_uri.clone(), 59 + owner_did: m.owner_did.clone(), 60 + name: m.keyring_name.clone(), 61 + indexed_at: m.indexed_at.clone(), 62 + } 63 + } 64 + } 65 + 66 + #[derive(Debug, Serialize)] 67 + #[serde(rename_all = "camelCase")] 68 + pub struct ErrorResponse { 69 + pub error: String, 70 + }
+23
crates/opake-appview/src/commands/index.rs
··· 1 + use clap::Args; 2 + 3 + use crate::config::Config; 4 + use crate::indexer; 5 + 6 + use super::build_state; 7 + 8 + #[derive(Args)] 9 + pub struct IndexCommand {} 10 + 11 + impl IndexCommand { 12 + pub async fn execute(self, config: &Config) -> anyhow::Result<()> { 13 + let state = build_state(config)?; 14 + 15 + log::info!( 16 + "opake-appview indexer (db: {})", 17 + config.resolved_db_path().display() 18 + ); 19 + 20 + indexer::run(state, config.jetstream_url.clone()).await; 21 + Ok(()) 22 + } 23 + }
+56
crates/opake-appview/src/commands/mod.rs
··· 1 + pub mod index; 2 + pub mod run; 3 + pub mod serve; 4 + pub mod status; 5 + 6 + use std::sync::Arc; 7 + 8 + use anyhow::Context; 9 + use clap::Subcommand; 10 + 11 + use crate::config::Config; 12 + use crate::db::Database; 13 + use crate::state::AppState; 14 + 15 + #[derive(Subcommand)] 16 + pub enum Command { 17 + /// Run both indexer and API server (default) 18 + Run(run::RunCommand), 19 + /// Run indexer only (write-only, no HTTP server) 20 + Index(index::IndexCommand), 21 + /// Run API server only (read-only, no Jetstream connection) 22 + Serve(serve::ServeCommand), 23 + /// Print cursor position, lag, and stats, then exit 24 + Status(status::StatusCommand), 25 + } 26 + 27 + pub fn build_state(config: &Config) -> anyhow::Result<Arc<AppState>> { 28 + let db = Database::open(&config.resolved_db_path()).context("failed to open database")?; 29 + Ok(Arc::new(AppState::new(db))) 30 + } 31 + 32 + pub async fn serve_http(listen: &str, app: axum::Router, config: &Config) -> anyhow::Result<()> { 33 + let listener = tokio::net::TcpListener::bind(listen) 34 + .await 35 + .with_context(|| format!("failed to bind to {listen}"))?; 36 + 37 + log::info!( 38 + "opake-appview listening on {} (db: {})", 39 + listen, 40 + config.resolved_db_path().display() 41 + ); 42 + 43 + axum::serve(listener, app) 44 + .with_graceful_shutdown(shutdown_signal()) 45 + .await 46 + .context("server error")?; 47 + 48 + log::info!("shutting down"); 49 + Ok(()) 50 + } 51 + 52 + async fn shutdown_signal() { 53 + tokio::signal::ctrl_c() 54 + .await 55 + .expect("failed to listen for ctrl-c"); 56 + }
+25
crates/opake-appview/src/commands/run.rs
··· 1 + use clap::Args; 2 + 3 + use crate::api; 4 + use crate::config::Config; 5 + use crate::indexer; 6 + 7 + use super::{build_state, serve_http}; 8 + 9 + #[derive(Args)] 10 + pub struct RunCommand {} 11 + 12 + impl RunCommand { 13 + pub async fn execute(self, config: &Config) -> anyhow::Result<()> { 14 + let state = build_state(config)?; 15 + 16 + tokio::spawn({ 17 + let state = state.clone(); 18 + let url = config.jetstream_url.clone(); 19 + async move { indexer::run(state, url).await } 20 + }); 21 + 22 + let app = api::router(state); 23 + serve_http(&config.listen, app, config).await 24 + } 25 + }
+18
crates/opake-appview/src/commands/serve.rs
··· 1 + use clap::Args; 2 + 3 + use crate::api; 4 + use crate::config::Config; 5 + 6 + use super::{build_state, serve_http}; 7 + 8 + #[derive(Args)] 9 + pub struct ServeCommand {} 10 + 11 + impl ServeCommand { 12 + pub async fn execute(self, config: &Config) -> anyhow::Result<()> { 13 + let state = build_state(config)?; 14 + 15 + let app = api::router(state); 16 + serve_http(&config.listen, app, config).await 17 + } 18 + }
+46
crates/opake-appview/src/commands/status.rs
··· 1 + use clap::Args; 2 + 3 + use crate::config::Config; 4 + use crate::db; 5 + 6 + use super::build_state; 7 + 8 + #[derive(Args)] 9 + pub struct StatusCommand {} 10 + 11 + impl StatusCommand { 12 + pub fn execute(self, config: &Config) -> anyhow::Result<()> { 13 + let state = build_state(config)?; 14 + 15 + let cursor_us = state.db.with_conn(db::cursor::load_cursor).unwrap_or(None); 16 + 17 + let grant_count = state.db.with_conn(db::grants::count_grants).unwrap_or(0); 18 + 19 + let keyring_count = state 20 + .db 21 + .with_conn(db::keyrings::count_unique_keyrings) 22 + .unwrap_or(0); 23 + 24 + match cursor_us { 25 + Some(us) => { 26 + let cursor_secs = us / 1_000_000; 27 + let now_secs = chrono::Utc::now().timestamp(); 28 + let lag_secs = now_secs - cursor_secs; 29 + 30 + let cursor_time = chrono::DateTime::from_timestamp(cursor_secs, 0) 31 + .map(|dt| dt.to_rfc3339()) 32 + .unwrap_or_else(|| format!("{us}µs")); 33 + 34 + println!("Cursor: {cursor_time}"); 35 + println!("Lag: {lag_secs}s"); 36 + } 37 + None => { 38 + println!("Cursor: (none — indexer has not run)"); 39 + } 40 + } 41 + 42 + println!("Grants: {grant_count}"); 43 + println!("Keyrings: {keyring_count}"); 44 + Ok(()) 45 + } 46 + }
+53
crates/opake-appview/src/config.rs
··· 1 + use std::fs; 2 + use std::path::{Path, PathBuf}; 3 + 4 + use serde::Deserialize; 5 + 6 + use crate::error::{Error, Result}; 7 + 8 + #[derive(Debug, Deserialize)] 9 + pub struct Config { 10 + pub jetstream_url: String, 11 + pub listen: String, 12 + pub db_path: String, 13 + } 14 + 15 + impl Config { 16 + /// Load config from the `appview.toml` inside the resolved data directory. 17 + /// Priority: override_dir > OPAKE_DATA_DIR env > XDG_CONFIG_HOME/opake > ~/.config/opake 18 + pub fn load(override_dir: Option<PathBuf>) -> Result<Self> { 19 + let dir = opake_core::paths::resolve_data_dir(override_dir); 20 + let path = dir.join("appview.toml"); 21 + Self::load_from(&path) 22 + } 23 + 24 + /// Load config from a specific path. 25 + pub fn load_from(path: &Path) -> Result<Self> { 26 + let content = fs::read_to_string(path).map_err(|e| { 27 + Error::Config(format!("failed to read config at {}: {e}", path.display())) 28 + })?; 29 + let config: Config = toml::from_str(&content) 30 + .map_err(|e| Error::Config(format!("failed to parse config: {e}")))?; 31 + config.validate()?; 32 + Ok(config) 33 + } 34 + 35 + fn validate(&self) -> Result<()> { 36 + if !self.jetstream_url.starts_with("ws://") && !self.jetstream_url.starts_with("wss://") { 37 + return Err(Error::Config(format!( 38 + "jetstream_url must start with ws:// or wss://, got: {}", 39 + self.jetstream_url 40 + ))); 41 + } 42 + Ok(()) 43 + } 44 + 45 + /// Resolve db_path with `~` expansion. 46 + pub fn resolved_db_path(&self) -> PathBuf { 47 + opake_core::paths::expand_tilde(&self.db_path) 48 + } 49 + } 50 + 51 + #[cfg(test)] 52 + #[path = "config_tests.rs"] 53 + mod tests;
+78
crates/opake-appview/src/config_tests.rs
··· 1 + use std::io::Write; 2 + 3 + use tempfile::NamedTempFile; 4 + 5 + use super::*; 6 + 7 + fn minimal_config_toml() -> &'static str { 8 + r#" 9 + jetstream_url = "wss://jetstream2.us-east.bsky.network/subscribe" 10 + listen = "127.0.0.1:6100" 11 + db_path = "/tmp/test.db" 12 + "# 13 + } 14 + 15 + #[test] 16 + fn loads_valid_config() { 17 + let mut f = NamedTempFile::new().unwrap(); 18 + write!(f, "{}", minimal_config_toml()).unwrap(); 19 + 20 + let config = Config::load_from(f.path()).unwrap(); 21 + assert_eq!(config.listen, "127.0.0.1:6100"); 22 + assert_eq!(config.db_path, "/tmp/test.db"); 23 + } 24 + 25 + #[test] 26 + fn ignores_unknown_fields() { 27 + let mut f = NamedTempFile::new().unwrap(); 28 + write!( 29 + f, 30 + r#" 31 + jetstream_url = "wss://example.com/subscribe" 32 + listen = "127.0.0.1:6100" 33 + db_path = "/tmp/test.db" 34 + auth_token = "leftover-from-old-config" 35 + "# 36 + ) 37 + .unwrap(); 38 + 39 + // Old configs with auth_token should still parse fine (toml ignores unknown keys) 40 + let config = Config::load_from(f.path()).unwrap(); 41 + assert_eq!(config.listen, "127.0.0.1:6100"); 42 + } 43 + 44 + #[test] 45 + fn rejects_invalid_jetstream_url() { 46 + let mut f = NamedTempFile::new().unwrap(); 47 + write!( 48 + f, 49 + r#" 50 + jetstream_url = "https://example.com/subscribe" 51 + listen = "127.0.0.1:6100" 52 + db_path = "/tmp/test.db" 53 + "# 54 + ) 55 + .unwrap(); 56 + 57 + let err = Config::load_from(f.path()).unwrap_err(); 58 + assert!(err.to_string().contains("ws:// or wss://")); 59 + } 60 + 61 + #[test] 62 + fn expands_tilde_in_db_path() { 63 + let mut f = NamedTempFile::new().unwrap(); 64 + write!( 65 + f, 66 + r#" 67 + jetstream_url = "wss://example.com/subscribe" 68 + listen = "127.0.0.1:6100" 69 + db_path = "~/opake/appview.db" 70 + "# 71 + ) 72 + .unwrap(); 73 + 74 + let config = Config::load_from(f.path()).unwrap(); 75 + let resolved = config.resolved_db_path(); 76 + assert!(!resolved.to_string_lossy().contains('~')); 77 + assert!(resolved.to_string_lossy().ends_with("opake/appview.db")); 78 + }
+29
crates/opake-appview/src/db/cursor.rs
··· 1 + use rusqlite::{params, Connection}; 2 + 3 + use crate::error::Result; 4 + 5 + /// Save the Jetstream cursor (unix microseconds timestamp). 6 + /// Uses upsert into the singleton row (id = 1). 7 + pub fn save_cursor(conn: &Connection, time_us: i64) -> Result<()> { 8 + let now = chrono::Utc::now().to_rfc3339(); 9 + conn.execute( 10 + "INSERT INTO cursor (id, time_us, updated_at) 11 + VALUES (1, ?1, ?2) 12 + ON CONFLICT(id) DO UPDATE SET 13 + time_us = excluded.time_us, 14 + updated_at = excluded.updated_at", 15 + params![time_us, now], 16 + )?; 17 + Ok(()) 18 + } 19 + 20 + /// Load the last saved Jetstream cursor, if any. 21 + pub fn load_cursor(conn: &Connection) -> Result<Option<i64>> { 22 + let mut stmt = conn.prepare("SELECT time_us FROM cursor WHERE id = 1")?; 23 + let result = stmt.query_row([], |row| row.get::<_, i64>(0)); 24 + match result { 25 + Ok(time_us) => Ok(Some(time_us)), 26 + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), 27 + Err(e) => Err(e.into()), 28 + } 29 + }
+239
crates/opake-appview/src/db/db_tests.rs
··· 1 + use super::*; 2 + use grants::IndexedGrant; 3 + 4 + fn test_db() -> Database { 5 + Database::open_in_memory().unwrap() 6 + } 7 + 8 + fn make_grant(uri: &str, recipient: &str, owner: &str, doc_uri: &str) -> IndexedGrant { 9 + IndexedGrant { 10 + uri: uri.into(), 11 + owner_did: owner.into(), 12 + recipient_did: recipient.into(), 13 + document_uri: doc_uri.into(), 14 + permissions: Some("read".into()), 15 + note: None, 16 + created_at: "2026-03-01T12:00:00Z".into(), 17 + indexed_at: "2026-03-01T12:00:01Z".into(), 18 + } 19 + } 20 + 21 + #[test] 22 + fn grant_upsert_and_query() { 23 + let db = test_db(); 24 + let grant = make_grant( 25 + "at://did:plc:owner/app.opake.cloud.grant/3abc", 26 + "did:plc:recipient", 27 + "did:plc:owner", 28 + "at://did:plc:owner/app.opake.cloud.document/3xyz", 29 + ); 30 + 31 + db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 32 + 33 + let inbox = db 34 + .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 35 + .unwrap(); 36 + assert_eq!(inbox.len(), 1); 37 + assert_eq!(inbox[0].uri, grant.uri); 38 + assert_eq!(inbox[0].document_uri, grant.document_uri); 39 + } 40 + 41 + #[test] 42 + fn grant_upsert_overwrites() { 43 + let db = test_db(); 44 + let mut grant = make_grant( 45 + "at://did:plc:owner/app.opake.cloud.grant/3abc", 46 + "did:plc:recipient", 47 + "did:plc:owner", 48 + "at://did:plc:owner/app.opake.cloud.document/3xyz", 49 + ); 50 + db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 51 + 52 + grant.note = Some("updated note".into()); 53 + db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 54 + 55 + let inbox = db 56 + .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 57 + .unwrap(); 58 + assert_eq!(inbox.len(), 1); 59 + assert_eq!(inbox[0].note.as_deref(), Some("updated note")); 60 + } 61 + 62 + #[test] 63 + fn grant_delete() { 64 + let db = test_db(); 65 + let grant = make_grant( 66 + "at://did:plc:owner/app.opake.cloud.grant/3abc", 67 + "did:plc:recipient", 68 + "did:plc:owner", 69 + "at://did:plc:owner/app.opake.cloud.document/3xyz", 70 + ); 71 + db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 72 + db.with_conn(|c| grants::delete_grant(c, &grant.uri)) 73 + .unwrap(); 74 + 75 + let inbox = db 76 + .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 77 + .unwrap(); 78 + assert!(inbox.is_empty()); 79 + } 80 + 81 + #[test] 82 + fn grant_pagination() { 83 + let db = test_db(); 84 + 85 + for i in 0..5 { 86 + let grant = IndexedGrant { 87 + uri: format!("at://did:plc:owner/app.opake.cloud.grant/{i}"), 88 + owner_did: "did:plc:owner".into(), 89 + recipient_did: "did:plc:me".into(), 90 + document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{i}"), 91 + permissions: None, 92 + note: None, 93 + created_at: "2026-03-01T12:00:00Z".into(), 94 + indexed_at: format!("2026-03-01T12:00:0{i}Z"), 95 + }; 96 + db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 97 + } 98 + 99 + // First page: 2 items 100 + let page1 = db 101 + .with_conn(|c| grants::list_inbox(c, "did:plc:me", 2, None)) 102 + .unwrap(); 103 + assert_eq!(page1.len(), 2); 104 + // Newest first 105 + assert!(page1[0].indexed_at > page1[1].indexed_at); 106 + 107 + // Second page using cursor from last item of page 1 108 + let cursor = grants::encode_cursor(&page1[1]); 109 + let page2 = db 110 + .with_conn(|c| grants::list_inbox(c, "did:plc:me", 2, Some(&cursor))) 111 + .unwrap(); 112 + assert_eq!(page2.len(), 2); 113 + assert!(page2[0].indexed_at < page1[1].indexed_at); 114 + } 115 + 116 + #[test] 117 + fn keyring_upsert_and_query() { 118 + let db = test_db(); 119 + let members = vec!["did:plc:alice".to_string(), "did:plc:bob".to_string()]; 120 + 121 + db.with_conn(|c| { 122 + keyrings::upsert_keyring_members( 123 + c, 124 + "at://did:plc:owner/app.opake.cloud.keyring/3def", 125 + "did:plc:owner", 126 + "family-photos", 127 + &members, 128 + "2026-03-01T12:00:00Z", 129 + ) 130 + }) 131 + .unwrap(); 132 + 133 + let alice_keyrings = db 134 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 135 + .unwrap(); 136 + assert_eq!(alice_keyrings.len(), 1); 137 + assert_eq!(alice_keyrings[0].keyring_name, "family-photos"); 138 + 139 + let bob_keyrings = db 140 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None)) 141 + .unwrap(); 142 + assert_eq!(bob_keyrings.len(), 1); 143 + 144 + // Charlie is not a member 145 + let charlie_keyrings = db 146 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:charlie", 50, None)) 147 + .unwrap(); 148 + assert!(charlie_keyrings.is_empty()); 149 + } 150 + 151 + #[test] 152 + fn keyring_update_replaces_members() { 153 + let db = test_db(); 154 + let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 155 + 156 + // Initially: alice + bob 157 + db.with_conn(|c| { 158 + keyrings::upsert_keyring_members( 159 + c, 160 + uri, 161 + "did:plc:owner", 162 + "family-photos", 163 + &["did:plc:alice".into(), "did:plc:bob".into()], 164 + "2026-03-01T12:00:00Z", 165 + ) 166 + }) 167 + .unwrap(); 168 + 169 + // Update: bob removed, charlie added 170 + db.with_conn(|c| { 171 + keyrings::upsert_keyring_members( 172 + c, 173 + uri, 174 + "did:plc:owner", 175 + "family-photos", 176 + &["did:plc:alice".into(), "did:plc:charlie".into()], 177 + "2026-03-01T13:00:00Z", 178 + ) 179 + }) 180 + .unwrap(); 181 + 182 + // Bob should no longer see it 183 + let bob = db 184 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None)) 185 + .unwrap(); 186 + assert!(bob.is_empty()); 187 + 188 + // Charlie should see it 189 + let charlie = db 190 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:charlie", 50, None)) 191 + .unwrap(); 192 + assert_eq!(charlie.len(), 1); 193 + } 194 + 195 + #[test] 196 + fn keyring_delete() { 197 + let db = test_db(); 198 + let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 199 + 200 + db.with_conn(|c| { 201 + keyrings::upsert_keyring_members( 202 + c, 203 + uri, 204 + "did:plc:owner", 205 + "family-photos", 206 + &["did:plc:alice".into()], 207 + "2026-03-01T12:00:00Z", 208 + ) 209 + }) 210 + .unwrap(); 211 + 212 + db.with_conn(|c| keyrings::delete_keyring(c, uri)).unwrap(); 213 + 214 + let alice = db 215 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 216 + .unwrap(); 217 + assert!(alice.is_empty()); 218 + } 219 + 220 + #[test] 221 + fn cursor_roundtrip() { 222 + let db = test_db(); 223 + 224 + // No cursor initially 225 + let initial = db.with_conn(|c| cursor::load_cursor(c)).unwrap(); 226 + assert!(initial.is_none()); 227 + 228 + // Save and load 229 + db.with_conn(|c| cursor::save_cursor(c, 1709330400000000)) 230 + .unwrap(); 231 + let loaded = db.with_conn(|c| cursor::load_cursor(c)).unwrap(); 232 + assert_eq!(loaded, Some(1709330400000000)); 233 + 234 + // Update 235 + db.with_conn(|c| cursor::save_cursor(c, 1709330500000000)) 236 + .unwrap(); 237 + let updated = db.with_conn(|c| cursor::load_cursor(c)).unwrap(); 238 + assert_eq!(updated, Some(1709330500000000)); 239 + }
+165
crates/opake-appview/src/db/grants.rs
··· 1 + use rusqlite::{params, Connection}; 2 + 3 + use crate::error::Result; 4 + 5 + /// A grant row as stored in the index. 6 + #[derive(Debug, Clone)] 7 + pub struct IndexedGrant { 8 + pub uri: String, 9 + pub owner_did: String, 10 + pub recipient_did: String, 11 + pub document_uri: String, 12 + pub permissions: Option<String>, 13 + pub note: Option<String>, 14 + pub created_at: String, 15 + pub indexed_at: String, 16 + } 17 + 18 + pub fn upsert_grant(conn: &Connection, grant: &IndexedGrant) -> Result<()> { 19 + conn.execute( 20 + "INSERT INTO grants (uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at) 21 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) 22 + ON CONFLICT(uri) DO UPDATE SET 23 + owner_did = excluded.owner_did, 24 + recipient_did = excluded.recipient_did, 25 + document_uri = excluded.document_uri, 26 + permissions = excluded.permissions, 27 + note = excluded.note, 28 + created_at = excluded.created_at, 29 + indexed_at = excluded.indexed_at", 30 + params![ 31 + grant.uri, 32 + grant.owner_did, 33 + grant.recipient_did, 34 + grant.document_uri, 35 + grant.permissions, 36 + grant.note, 37 + grant.created_at, 38 + grant.indexed_at, 39 + ], 40 + )?; 41 + Ok(()) 42 + } 43 + 44 + pub fn delete_grant(conn: &Connection, uri: &str) -> Result<()> { 45 + conn.execute("DELETE FROM grants WHERE uri = ?1", params![uri])?; 46 + Ok(()) 47 + } 48 + 49 + /// Paginated inbox query: grants for a recipient DID, newest first. 50 + /// Cursor is a composite `indexed_at::uri` string. 51 + pub fn list_inbox( 52 + conn: &Connection, 53 + recipient_did: &str, 54 + limit: u32, 55 + cursor: Option<&str>, 56 + ) -> Result<Vec<IndexedGrant>> { 57 + let mut grants = Vec::new(); 58 + 59 + if let Some(cursor) = cursor { 60 + let (cursor_time, cursor_uri) = parse_cursor(cursor); 61 + let mut stmt = conn.prepare( 62 + "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 63 + FROM grants 64 + WHERE recipient_did = ?1 65 + AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3)) 66 + ORDER BY indexed_at DESC, uri DESC 67 + LIMIT ?4", 68 + )?; 69 + let rows = stmt.query_map( 70 + params![recipient_did, cursor_time, cursor_uri, limit], 71 + row_to_grant, 72 + )?; 73 + for row in rows { 74 + grants.push(row?); 75 + } 76 + } else { 77 + let mut stmt = conn.prepare( 78 + "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 79 + FROM grants 80 + WHERE recipient_did = ?1 81 + ORDER BY indexed_at DESC, uri DESC 82 + LIMIT ?2", 83 + )?; 84 + let rows = stmt.query_map(params![recipient_did, limit], row_to_grant)?; 85 + for row in rows { 86 + grants.push(row?); 87 + } 88 + } 89 + 90 + Ok(grants) 91 + } 92 + 93 + /// List grants created by an owner DID, newest first. 94 + pub fn list_grants_by_owner( 95 + conn: &Connection, 96 + owner_did: &str, 97 + limit: u32, 98 + cursor: Option<&str>, 99 + ) -> Result<Vec<IndexedGrant>> { 100 + let mut grants = Vec::new(); 101 + 102 + if let Some(cursor) = cursor { 103 + let (cursor_time, cursor_uri) = parse_cursor(cursor); 104 + let mut stmt = conn.prepare( 105 + "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 106 + FROM grants 107 + WHERE owner_did = ?1 108 + AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3)) 109 + ORDER BY indexed_at DESC, uri DESC 110 + LIMIT ?4", 111 + )?; 112 + let rows = stmt.query_map( 113 + params![owner_did, cursor_time, cursor_uri, limit], 114 + row_to_grant, 115 + )?; 116 + for row in rows { 117 + grants.push(row?); 118 + } 119 + } else { 120 + let mut stmt = conn.prepare( 121 + "SELECT uri, owner_did, recipient_did, document_uri, permissions, note, created_at, indexed_at 122 + FROM grants 123 + WHERE owner_did = ?1 124 + ORDER BY indexed_at DESC, uri DESC 125 + LIMIT ?2", 126 + )?; 127 + let rows = stmt.query_map(params![owner_did, limit], row_to_grant)?; 128 + for row in rows { 129 + grants.push(row?); 130 + } 131 + } 132 + 133 + Ok(grants) 134 + } 135 + 136 + fn row_to_grant(row: &rusqlite::Row) -> rusqlite::Result<IndexedGrant> { 137 + Ok(IndexedGrant { 138 + uri: row.get(0)?, 139 + owner_did: row.get(1)?, 140 + recipient_did: row.get(2)?, 141 + document_uri: row.get(3)?, 142 + permissions: row.get(4)?, 143 + note: row.get(5)?, 144 + created_at: row.get(6)?, 145 + indexed_at: row.get(7)?, 146 + }) 147 + } 148 + 149 + /// Build a cursor string from an indexed grant. 150 + pub fn encode_cursor(grant: &IndexedGrant) -> String { 151 + format!("{}::{}", grant.indexed_at, grant.uri) 152 + } 153 + 154 + /// Count total grants in the index. 155 + pub fn count_grants(conn: &Connection) -> Result<i64> { 156 + let count = conn.query_row("SELECT COUNT(*) FROM grants", [], |row| row.get(0))?; 157 + Ok(count) 158 + } 159 + 160 + fn parse_cursor(cursor: &str) -> (&str, &str) { 161 + match cursor.split_once("::") { 162 + Some((time, uri)) => (time, uri), 163 + None => (cursor, ""), 164 + } 165 + }
+132
crates/opake-appview/src/db/keyrings.rs
··· 1 + use rusqlite::{params, Connection}; 2 + 3 + use crate::error::Result; 4 + 5 + /// A keyring membership row as stored in the index. 6 + #[derive(Debug, Clone)] 7 + pub struct IndexedKeyringMember { 8 + pub keyring_uri: String, 9 + pub member_did: String, 10 + pub owner_did: String, 11 + pub keyring_name: String, 12 + pub indexed_at: String, 13 + } 14 + 15 + /// Replace all members for a keyring (delete-and-reinsert). 16 + /// This handles both create and update events correctly — on update, 17 + /// the member list may have changed, so we wipe and rewrite. 18 + pub fn upsert_keyring_members( 19 + conn: &Connection, 20 + keyring_uri: &str, 21 + owner_did: &str, 22 + keyring_name: &str, 23 + member_dids: &[String], 24 + indexed_at: &str, 25 + ) -> Result<()> { 26 + conn.execute( 27 + "DELETE FROM keyring_members WHERE keyring_uri = ?1", 28 + params![keyring_uri], 29 + )?; 30 + 31 + let mut stmt = conn.prepare( 32 + "INSERT INTO keyring_members (keyring_uri, member_did, owner_did, keyring_name, indexed_at) 33 + VALUES (?1, ?2, ?3, ?4, ?5)", 34 + )?; 35 + 36 + for did in member_dids { 37 + stmt.execute(params![ 38 + keyring_uri, 39 + did, 40 + owner_did, 41 + keyring_name, 42 + indexed_at 43 + ])?; 44 + } 45 + 46 + Ok(()) 47 + } 48 + 49 + /// Delete all member rows for a keyring (when the record is deleted). 50 + pub fn delete_keyring(conn: &Connection, keyring_uri: &str) -> Result<()> { 51 + conn.execute( 52 + "DELETE FROM keyring_members WHERE keyring_uri = ?1", 53 + params![keyring_uri], 54 + )?; 55 + Ok(()) 56 + } 57 + 58 + /// Paginated query: keyrings where a DID is a member, newest first. 59 + /// Returns one row per unique keyring (not per member). 60 + pub fn list_keyrings_for_member( 61 + conn: &Connection, 62 + member_did: &str, 63 + limit: u32, 64 + cursor: Option<&str>, 65 + ) -> Result<Vec<IndexedKeyringMember>> { 66 + let mut keyrings = Vec::new(); 67 + 68 + if let Some(cursor) = cursor { 69 + let (cursor_time, cursor_uri) = parse_cursor(cursor); 70 + let mut stmt = conn.prepare( 71 + "SELECT keyring_uri, member_did, owner_did, keyring_name, indexed_at 72 + FROM keyring_members 73 + WHERE member_did = ?1 74 + AND (indexed_at < ?2 OR (indexed_at = ?2 AND keyring_uri < ?3)) 75 + ORDER BY indexed_at DESC, keyring_uri DESC 76 + LIMIT ?4", 77 + )?; 78 + let rows = stmt.query_map( 79 + params![member_did, cursor_time, cursor_uri, limit], 80 + row_to_member, 81 + )?; 82 + for row in rows { 83 + keyrings.push(row?); 84 + } 85 + } else { 86 + let mut stmt = conn.prepare( 87 + "SELECT keyring_uri, member_did, owner_did, keyring_name, indexed_at 88 + FROM keyring_members 89 + WHERE member_did = ?1 90 + ORDER BY indexed_at DESC, keyring_uri DESC 91 + LIMIT ?2", 92 + )?; 93 + let rows = stmt.query_map(params![member_did, limit], row_to_member)?; 94 + for row in rows { 95 + keyrings.push(row?); 96 + } 97 + } 98 + 99 + Ok(keyrings) 100 + } 101 + 102 + fn row_to_member(row: &rusqlite::Row) -> rusqlite::Result<IndexedKeyringMember> { 103 + Ok(IndexedKeyringMember { 104 + keyring_uri: row.get(0)?, 105 + member_did: row.get(1)?, 106 + owner_did: row.get(2)?, 107 + keyring_name: row.get(3)?, 108 + indexed_at: row.get(4)?, 109 + }) 110 + } 111 + 112 + /// Build a cursor string from an indexed keyring member. 113 + pub fn encode_cursor(member: &IndexedKeyringMember) -> String { 114 + format!("{}::{}", member.indexed_at, member.keyring_uri) 115 + } 116 + 117 + /// Count unique keyrings in the index. 118 + pub fn count_unique_keyrings(conn: &Connection) -> Result<i64> { 119 + let count = conn.query_row( 120 + "SELECT COUNT(DISTINCT keyring_uri) FROM keyring_members", 121 + [], 122 + |row| row.get(0), 123 + )?; 124 + Ok(count) 125 + } 126 + 127 + fn parse_cursor(cursor: &str) -> (&str, &str) { 128 + match cursor.split_once("::") { 129 + Some((time, uri)) => (time, uri), 130 + None => (cursor, ""), 131 + } 132 + }
+57
crates/opake-appview/src/db/mod.rs
··· 1 + pub mod cursor; 2 + pub mod grants; 3 + pub mod keyrings; 4 + mod schema; 5 + 6 + use std::path::Path; 7 + use std::sync::Mutex; 8 + 9 + use rusqlite::Connection; 10 + 11 + use crate::error::Result; 12 + 13 + /// Thread-safe database handle. Axum handlers and the indexer share this. 14 + pub struct Database { 15 + conn: Mutex<Connection>, 16 + } 17 + 18 + impl Database { 19 + /// Open (or create) the SQLite database at the given path and run migrations. 20 + pub fn open(path: &Path) -> Result<Self> { 21 + if let Some(parent) = path.parent() { 22 + if !parent.exists() { 23 + std::fs::create_dir_all(parent)?; 24 + } 25 + } 26 + let conn = Connection::open(path)?; 27 + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; 28 + conn.execute_batch(schema::SCHEMA)?; 29 + Ok(Self { 30 + conn: Mutex::new(conn), 31 + }) 32 + } 33 + 34 + /// Create an in-memory database for testing. 35 + #[cfg(test)] 36 + pub fn open_in_memory() -> Result<Self> { 37 + let conn = Connection::open_in_memory()?; 38 + conn.execute_batch(schema::SCHEMA)?; 39 + Ok(Self { 40 + conn: Mutex::new(conn), 41 + }) 42 + } 43 + 44 + /// Run a closure with a reference to the connection. 45 + /// Panics if the mutex is poisoned (unrecoverable). 46 + pub fn with_conn<F, T>(&self, f: F) -> T 47 + where 48 + F: FnOnce(&Connection) -> T, 49 + { 50 + let conn = self.conn.lock().expect("database mutex poisoned"); 51 + f(&conn) 52 + } 53 + } 54 + 55 + #[cfg(test)] 56 + #[path = "db_tests.rs"] 57 + mod tests;
+31
crates/opake-appview/src/db/schema.rs
··· 1 + /// SQL statements to initialize the database schema. 2 + pub const SCHEMA: &str = " 3 + CREATE TABLE IF NOT EXISTS cursor ( 4 + id INTEGER PRIMARY KEY CHECK (id = 1), 5 + time_us INTEGER NOT NULL, 6 + updated_at TEXT NOT NULL 7 + ); 8 + 9 + CREATE TABLE IF NOT EXISTS grants ( 10 + uri TEXT PRIMARY KEY, 11 + owner_did TEXT NOT NULL, 12 + recipient_did TEXT NOT NULL, 13 + document_uri TEXT NOT NULL, 14 + permissions TEXT, 15 + note TEXT, 16 + created_at TEXT NOT NULL, 17 + indexed_at TEXT NOT NULL 18 + ); 19 + CREATE INDEX IF NOT EXISTS idx_grants_recipient ON grants (recipient_did); 20 + CREATE INDEX IF NOT EXISTS idx_grants_owner ON grants (owner_did); 21 + 22 + CREATE TABLE IF NOT EXISTS keyring_members ( 23 + keyring_uri TEXT NOT NULL, 24 + member_did TEXT NOT NULL, 25 + owner_did TEXT NOT NULL, 26 + keyring_name TEXT NOT NULL, 27 + indexed_at TEXT NOT NULL, 28 + PRIMARY KEY (keyring_uri, member_did) 29 + ); 30 + CREATE INDEX IF NOT EXISTS idx_keyring_members_did ON keyring_members (member_did); 31 + ";
+24
crates/opake-appview/src/error.rs
··· 1 + use thiserror::Error; 2 + 3 + #[derive(Debug, Error)] 4 + pub enum Error { 5 + #[error("database error: {0}")] 6 + Database(#[from] rusqlite::Error), 7 + 8 + #[error("config error: {0}")] 9 + Config(String), 10 + 11 + #[error("auth error: {0}")] 12 + Auth(String), 13 + 14 + #[error("firehose error: {0}")] 15 + Firehose(String), 16 + 17 + #[error("IO error: {0}")] 18 + Io(#[from] std::io::Error), 19 + 20 + #[error("JSON error: {0}")] 21 + Json(#[from] serde_json::Error), 22 + } 23 + 24 + pub type Result<T> = std::result::Result<T, Error>;
+106
crates/opake-appview/src/firehose/events.rs
··· 1 + use serde::Deserialize; 2 + 3 + /// Top-level Jetstream WebSocket message. 4 + #[derive(Debug, Deserialize)] 5 + pub struct JetstreamEvent { 6 + pub did: String, 7 + pub time_us: i64, 8 + pub kind: String, 9 + pub commit: Option<CommitEvent>, 10 + } 11 + 12 + /// A commit event within a Jetstream message. 13 + #[derive(Debug, Deserialize)] 14 + pub struct CommitEvent { 15 + pub operation: String, 16 + pub collection: String, 17 + pub rkey: String, 18 + /// Present for create/update, absent for delete. 19 + pub record: Option<serde_json::Value>, 20 + } 21 + 22 + /// Collections we index. 23 + pub const COLLECTION_GRANT: &str = "app.opake.cloud.grant"; 24 + pub const COLLECTION_KEYRING: &str = "app.opake.cloud.keyring"; 25 + 26 + /// Parsed event ready for indexing. 27 + #[derive(Debug)] 28 + pub enum IndexableEvent { 29 + UpsertGrant { 30 + uri: String, 31 + owner_did: String, 32 + recipient_did: String, 33 + document_uri: String, 34 + permissions: Option<String>, 35 + note: Option<String>, 36 + created_at: String, 37 + }, 38 + DeleteGrant { 39 + uri: String, 40 + }, 41 + UpsertKeyring { 42 + uri: String, 43 + owner_did: String, 44 + name: String, 45 + member_dids: Vec<String>, 46 + }, 47 + DeleteKeyring { 48 + uri: String, 49 + }, 50 + } 51 + 52 + /// Try to parse a Jetstream JSON message into an indexable event. 53 + /// Returns None for events we don't care about (wrong collection, identity events, etc). 54 + pub fn parse_event(raw: &str) -> Option<(IndexableEvent, i64)> { 55 + let event: JetstreamEvent = serde_json::from_str(raw).ok()?; 56 + 57 + if event.kind != "commit" { 58 + return None; 59 + } 60 + 61 + let commit = event.commit.as_ref()?; 62 + let uri = format!("at://{}/{}/{}", event.did, commit.collection, commit.rkey); 63 + 64 + match (commit.collection.as_str(), commit.operation.as_str()) { 65 + (COLLECTION_GRANT, "create" | "update") => { 66 + let record = commit.record.as_ref()?; 67 + let grant: opake_core::records::Grant = serde_json::from_value(record.clone()).ok()?; 68 + Some(( 69 + IndexableEvent::UpsertGrant { 70 + uri, 71 + owner_did: event.did, 72 + recipient_did: grant.recipient, 73 + document_uri: grant.document, 74 + permissions: grant.permissions, 75 + note: grant.note, 76 + created_at: grant.created_at, 77 + }, 78 + event.time_us, 79 + )) 80 + } 81 + (COLLECTION_GRANT, "delete") => Some((IndexableEvent::DeleteGrant { uri }, event.time_us)), 82 + (COLLECTION_KEYRING, "create" | "update") => { 83 + let record = commit.record.as_ref()?; 84 + let keyring: opake_core::records::Keyring = 85 + serde_json::from_value(record.clone()).ok()?; 86 + let member_dids: Vec<String> = keyring.members.iter().map(|m| m.did.clone()).collect(); 87 + Some(( 88 + IndexableEvent::UpsertKeyring { 89 + uri, 90 + owner_did: event.did, 91 + name: keyring.name, 92 + member_dids, 93 + }, 94 + event.time_us, 95 + )) 96 + } 97 + (COLLECTION_KEYRING, "delete") => { 98 + Some((IndexableEvent::DeleteKeyring { uri }, event.time_us)) 99 + } 100 + _ => None, 101 + } 102 + } 103 + 104 + #[cfg(test)] 105 + #[path = "events_tests.rs"] 106 + mod tests;
+203
crates/opake-appview/src/firehose/events_tests.rs
··· 1 + use super::*; 2 + 3 + fn grant_event_json(operation: &str) -> String { 4 + format!( 5 + r#"{{ 6 + "did": "did:plc:owner123", 7 + "time_us": 1709330400000000, 8 + "kind": "commit", 9 + "commit": {{ 10 + "rev": "3l3qo2vutsw2b", 11 + "operation": "{operation}", 12 + "collection": "app.opake.cloud.grant", 13 + "rkey": "3abc", 14 + "record": {{ 15 + "version": 1, 16 + "document": "at://did:plc:owner123/app.opake.cloud.document/3xyz", 17 + "recipient": "did:plc:recipient456", 18 + "wrappedKey": {{ 19 + "did": "did:plc:recipient456", 20 + "ciphertext": {{ "$bytes": "AAAA" }}, 21 + "algo": "x25519-hkdf-a256kw" 22 + }}, 23 + "createdAt": "2026-03-01T12:00:00Z" 24 + }}, 25 + "cid": "bafyabc" 26 + }} 27 + }}"# 28 + ) 29 + } 30 + 31 + fn keyring_event_json(operation: &str) -> String { 32 + format!( 33 + r#"{{ 34 + "did": "did:plc:owner123", 35 + "time_us": 1709330500000000, 36 + "kind": "commit", 37 + "commit": {{ 38 + "rev": "3l3qo2vutsw2b", 39 + "operation": "{operation}", 40 + "collection": "app.opake.cloud.keyring", 41 + "rkey": "3def", 42 + "record": {{ 43 + "version": 1, 44 + "name": "family-photos", 45 + "algo": "aes-256-gcm", 46 + "members": [ 47 + {{ 48 + "did": "did:plc:alice", 49 + "ciphertext": {{ "$bytes": "AAAA" }}, 50 + "algo": "x25519-hkdf-a256kw" 51 + }}, 52 + {{ 53 + "did": "did:plc:bob", 54 + "ciphertext": {{ "$bytes": "BBBB" }}, 55 + "algo": "x25519-hkdf-a256kw" 56 + }} 57 + ], 58 + "rotation": 0, 59 + "createdAt": "2026-03-01T12:00:00Z" 60 + }}, 61 + "cid": "bafydef" 62 + }} 63 + }}"# 64 + ) 65 + } 66 + 67 + fn delete_event_json(collection: &str, rkey: &str) -> String { 68 + format!( 69 + r#"{{ 70 + "did": "did:plc:owner123", 71 + "time_us": 1709330600000000, 72 + "kind": "commit", 73 + "commit": {{ 74 + "rev": "3l3qo2vutsw2b", 75 + "operation": "delete", 76 + "collection": "{collection}", 77 + "rkey": "{rkey}" 78 + }} 79 + }}"# 80 + ) 81 + } 82 + 83 + #[test] 84 + fn parses_grant_create() { 85 + let json = grant_event_json("create"); 86 + let (event, time_us) = parse_event(&json).unwrap(); 87 + assert_eq!(time_us, 1709330400000000); 88 + 89 + match event { 90 + IndexableEvent::UpsertGrant { 91 + uri, 92 + owner_did, 93 + recipient_did, 94 + document_uri, 95 + .. 96 + } => { 97 + assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.grant/3abc"); 98 + assert_eq!(owner_did, "did:plc:owner123"); 99 + assert_eq!(recipient_did, "did:plc:recipient456"); 100 + assert_eq!( 101 + document_uri, 102 + "at://did:plc:owner123/app.opake.cloud.document/3xyz" 103 + ); 104 + } 105 + other => panic!("expected UpsertGrant, got {other:?}"), 106 + } 107 + } 108 + 109 + #[test] 110 + fn parses_grant_update() { 111 + let json = grant_event_json("update"); 112 + let (event, _) = parse_event(&json).unwrap(); 113 + assert!(matches!(event, IndexableEvent::UpsertGrant { .. })); 114 + } 115 + 116 + #[test] 117 + fn parses_grant_delete() { 118 + let json = delete_event_json("app.opake.cloud.grant", "3abc"); 119 + let (event, _) = parse_event(&json).unwrap(); 120 + match event { 121 + IndexableEvent::DeleteGrant { uri } => { 122 + assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.grant/3abc"); 123 + } 124 + other => panic!("expected DeleteGrant, got {other:?}"), 125 + } 126 + } 127 + 128 + #[test] 129 + fn parses_keyring_create() { 130 + let json = keyring_event_json("create"); 131 + let (event, time_us) = parse_event(&json).unwrap(); 132 + assert_eq!(time_us, 1709330500000000); 133 + 134 + match event { 135 + IndexableEvent::UpsertKeyring { 136 + uri, 137 + owner_did, 138 + name, 139 + member_dids, 140 + } => { 141 + assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.keyring/3def"); 142 + assert_eq!(owner_did, "did:plc:owner123"); 143 + assert_eq!(name, "family-photos"); 144 + assert_eq!(member_dids, vec!["did:plc:alice", "did:plc:bob"]); 145 + } 146 + other => panic!("expected UpsertKeyring, got {other:?}"), 147 + } 148 + } 149 + 150 + #[test] 151 + fn parses_keyring_delete() { 152 + let json = delete_event_json("app.opake.cloud.keyring", "3def"); 153 + let (event, _) = parse_event(&json).unwrap(); 154 + assert!(matches!(event, IndexableEvent::DeleteKeyring { .. })); 155 + } 156 + 157 + #[test] 158 + fn ignores_identity_events() { 159 + let json = r#"{"did":"did:plc:abc","time_us":123,"kind":"identity"}"#; 160 + assert!(parse_event(json).is_none()); 161 + } 162 + 163 + #[test] 164 + fn ignores_unknown_collections() { 165 + let json = r#"{ 166 + "did": "did:plc:abc", 167 + "time_us": 123, 168 + "kind": "commit", 169 + "commit": { 170 + "rev": "abc", 171 + "operation": "create", 172 + "collection": "app.bsky.feed.post", 173 + "rkey": "3abc", 174 + "record": {"text": "hello"}, 175 + "cid": "bafyabc" 176 + } 177 + }"#; 178 + assert!(parse_event(json).is_none()); 179 + } 180 + 181 + #[test] 182 + fn ignores_malformed_json() { 183 + assert!(parse_event("not json at all").is_none()); 184 + } 185 + 186 + #[test] 187 + fn ignores_grant_with_invalid_record() { 188 + // record is present but doesn't match Grant schema 189 + let json = r#"{ 190 + "did": "did:plc:owner", 191 + "time_us": 123, 192 + "kind": "commit", 193 + "commit": { 194 + "rev": "abc", 195 + "operation": "create", 196 + "collection": "app.opake.cloud.grant", 197 + "rkey": "3abc", 198 + "record": {"garbage": true}, 199 + "cid": "bafyabc" 200 + } 201 + }"#; 202 + assert!(parse_event(json).is_none()); 203 + }
+2
crates/opake-appview/src/firehose/mod.rs
··· 1 + pub mod events; 2 + pub mod subscribe;
+47
crates/opake-appview/src/firehose/subscribe.rs
··· 1 + use futures_util::stream::SplitStream; 2 + use futures_util::StreamExt; 3 + use tokio::net::TcpStream; 4 + use tokio_tungstenite::tungstenite::Message; 5 + use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; 6 + 7 + use crate::error::{Error, Result}; 8 + 9 + type WsStream = SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>; 10 + 11 + /// Build the Jetstream subscription URL with collection filters and optional cursor. 12 + pub fn subscription_url(base_url: &str, cursor: Option<i64>) -> String { 13 + let mut url = format!( 14 + "{base_url}?wantedCollections=app.opake.cloud.grant&wantedCollections=app.opake.cloud.keyring" 15 + ); 16 + if let Some(cursor_us) = cursor { 17 + url.push_str(&format!("&cursor={cursor_us}")); 18 + } 19 + url 20 + } 21 + 22 + /// Connect to the Jetstream WebSocket. Returns the read half of the stream. 23 + pub async fn connect(url: &str) -> Result<WsStream> { 24 + log::info!("connecting to jetstream: {url}"); 25 + let (ws, _response) = connect_async(url) 26 + .await 27 + .map_err(|e| Error::Firehose(format!("WebSocket connection failed: {e}")))?; 28 + let (_, read) = ws.split(); 29 + Ok(read) 30 + } 31 + 32 + /// Read the next text message from the WebSocket stream. 33 + /// Returns None if the stream is closed. 34 + pub async fn next_message(stream: &mut WsStream) -> Result<Option<String>> { 35 + loop { 36 + match stream.next().await { 37 + Some(Ok(Message::Text(text))) => return Ok(Some(text.to_string())), 38 + Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue, 39 + Some(Ok(Message::Close(_))) => return Ok(None), 40 + Some(Ok(_)) => continue, 41 + Some(Err(e)) => { 42 + return Err(Error::Firehose(format!("WebSocket error: {e}"))); 43 + } 44 + None => return Ok(None), 45 + } 46 + } 47 + }
+131
crates/opake-appview/src/indexer.rs
··· 1 + use std::sync::atomic::Ordering; 2 + use std::sync::Arc; 3 + 4 + use crate::db::cursor; 5 + use crate::db::grants::{self, IndexedGrant}; 6 + use crate::db::keyrings; 7 + use crate::firehose::events::{self, IndexableEvent}; 8 + use crate::firehose::subscribe; 9 + use crate::state::AppState; 10 + 11 + const CURSOR_SAVE_INTERVAL: u64 = 100; 12 + const MAX_BACKOFF_SECS: u64 = 60; 13 + 14 + /// Run the indexer loop. Connects to Jetstream, processes events, writes to DB. 15 + /// Reconnects with exponential backoff on failure. Runs until the task is cancelled. 16 + pub async fn run(state: Arc<AppState>, jetstream_url: String) { 17 + let mut backoff_secs: u64 = 1; 18 + 19 + loop { 20 + let cursor_us = state.db.with_conn(cursor::load_cursor).unwrap_or(None); 21 + 22 + let url = subscribe::subscription_url(&jetstream_url, cursor_us); 23 + 24 + match subscribe::connect(&url).await { 25 + Ok(mut stream) => { 26 + backoff_secs = 1; 27 + state.indexer_connected.store(true, Ordering::Relaxed); 28 + log::info!("connected to jetstream, indexing events"); 29 + 30 + let mut events_since_cursor_save: u64 = 0; 31 + 32 + loop { 33 + match subscribe::next_message(&mut stream).await { 34 + Ok(Some(text)) => { 35 + if let Some((event, time_us)) = events::parse_event(&text) { 36 + if let Err(e) = process_event(&state, &event, time_us) { 37 + log::error!("failed to process event: {e}"); 38 + continue; 39 + } 40 + events_since_cursor_save += 1; 41 + if events_since_cursor_save >= CURSOR_SAVE_INTERVAL { 42 + if let Err(e) = 43 + state.db.with_conn(|c| cursor::save_cursor(c, time_us)) 44 + { 45 + log::error!("failed to save cursor: {e}"); 46 + } 47 + events_since_cursor_save = 0; 48 + } 49 + } 50 + } 51 + Ok(None) => { 52 + log::warn!("jetstream stream closed, reconnecting"); 53 + break; 54 + } 55 + Err(e) => { 56 + log::error!("jetstream read error: {e}"); 57 + break; 58 + } 59 + } 60 + } 61 + 62 + state.indexer_connected.store(false, Ordering::Relaxed); 63 + } 64 + Err(e) => { 65 + log::error!("jetstream connection failed: {e}"); 66 + } 67 + } 68 + 69 + log::info!("reconnecting in {backoff_secs}s"); 70 + tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; 71 + backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS); 72 + } 73 + } 74 + 75 + fn process_event( 76 + state: &AppState, 77 + event: &IndexableEvent, 78 + _time_us: i64, 79 + ) -> crate::error::Result<()> { 80 + let now = chrono::Utc::now().to_rfc3339(); 81 + 82 + state.db.with_conn(|conn| match event { 83 + IndexableEvent::UpsertGrant { 84 + uri, 85 + owner_did, 86 + recipient_did, 87 + document_uri, 88 + permissions, 89 + note, 90 + created_at, 91 + } => { 92 + let grant = IndexedGrant { 93 + uri: uri.clone(), 94 + owner_did: owner_did.clone(), 95 + recipient_did: recipient_did.clone(), 96 + document_uri: document_uri.clone(), 97 + permissions: permissions.clone(), 98 + note: note.clone(), 99 + created_at: created_at.clone(), 100 + indexed_at: now.clone(), 101 + }; 102 + grants::upsert_grant(conn, &grant)?; 103 + log::debug!("indexed grant: {uri}"); 104 + Ok(()) 105 + } 106 + IndexableEvent::DeleteGrant { uri } => { 107 + grants::delete_grant(conn, uri)?; 108 + log::debug!("deleted grant: {uri}"); 109 + Ok(()) 110 + } 111 + IndexableEvent::UpsertKeyring { 112 + uri, 113 + owner_did, 114 + name, 115 + member_dids, 116 + } => { 117 + keyrings::upsert_keyring_members(conn, uri, owner_did, name, member_dids, &now)?; 118 + log::debug!("indexed keyring: {uri} ({} members)", member_dids.len()); 119 + Ok(()) 120 + } 121 + IndexableEvent::DeleteKeyring { uri } => { 122 + keyrings::delete_keyring(conn, uri)?; 123 + log::debug!("deleted keyring: {uri}"); 124 + Ok(()) 125 + } 126 + }) 127 + } 128 + 129 + #[cfg(test)] 130 + #[path = "indexer_tests.rs"] 131 + mod tests;
+143
crates/opake-appview/src/indexer_tests.rs
··· 1 + use std::sync::Arc; 2 + 3 + use super::*; 4 + use crate::db::Database; 5 + use crate::firehose::events::IndexableEvent; 6 + use crate::state::AppState; 7 + 8 + fn test_state() -> Arc<AppState> { 9 + let db = Database::open_in_memory().unwrap(); 10 + Arc::new(AppState::new(db)) 11 + } 12 + 13 + #[test] 14 + fn indexes_grant_create() { 15 + let state = test_state(); 16 + let event = IndexableEvent::UpsertGrant { 17 + uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 18 + owner_did: "did:plc:owner".into(), 19 + recipient_did: "did:plc:recipient".into(), 20 + document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 21 + permissions: Some("read".into()), 22 + note: Some("shared file".into()), 23 + created_at: "2026-03-01T12:00:00Z".into(), 24 + }; 25 + process_event(&state, &event, 1709330400000000).unwrap(); 26 + 27 + let inbox = state 28 + .db 29 + .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 30 + .unwrap(); 31 + assert_eq!(inbox.len(), 1); 32 + assert_eq!(inbox[0].owner_did, "did:plc:owner"); 33 + assert_eq!(inbox[0].note.as_deref(), Some("shared file")); 34 + } 35 + 36 + #[test] 37 + fn indexes_grant_delete() { 38 + let state = test_state(); 39 + let uri = "at://did:plc:owner/app.opake.cloud.grant/3abc"; 40 + 41 + let create = IndexableEvent::UpsertGrant { 42 + uri: uri.into(), 43 + owner_did: "did:plc:owner".into(), 44 + recipient_did: "did:plc:recipient".into(), 45 + document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 46 + permissions: None, 47 + note: None, 48 + created_at: "2026-03-01T12:00:00Z".into(), 49 + }; 50 + process_event(&state, &create, 1709330400000000).unwrap(); 51 + 52 + let delete = IndexableEvent::DeleteGrant { uri: uri.into() }; 53 + process_event(&state, &delete, 1709330500000000).unwrap(); 54 + 55 + let inbox = state 56 + .db 57 + .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None)) 58 + .unwrap(); 59 + assert!(inbox.is_empty()); 60 + } 61 + 62 + #[test] 63 + fn indexes_keyring_create() { 64 + let state = test_state(); 65 + let event = IndexableEvent::UpsertKeyring { 66 + uri: "at://did:plc:owner/app.opake.cloud.keyring/3def".into(), 67 + owner_did: "did:plc:owner".into(), 68 + name: "family-photos".into(), 69 + member_dids: vec!["did:plc:alice".into(), "did:plc:bob".into()], 70 + }; 71 + process_event(&state, &event, 1709330400000000).unwrap(); 72 + 73 + let alice = state 74 + .db 75 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 76 + .unwrap(); 77 + assert_eq!(alice.len(), 1); 78 + assert_eq!(alice[0].keyring_name, "family-photos"); 79 + 80 + let bob = state 81 + .db 82 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None)) 83 + .unwrap(); 84 + assert_eq!(bob.len(), 1); 85 + } 86 + 87 + #[test] 88 + fn indexes_keyring_update_replaces_members() { 89 + let state = test_state(); 90 + let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 91 + 92 + let create = IndexableEvent::UpsertKeyring { 93 + uri: uri.into(), 94 + owner_did: "did:plc:owner".into(), 95 + name: "family-photos".into(), 96 + member_dids: vec!["did:plc:alice".into(), "did:plc:bob".into()], 97 + }; 98 + process_event(&state, &create, 1709330400000000).unwrap(); 99 + 100 + // Bob removed, charlie added 101 + let update = IndexableEvent::UpsertKeyring { 102 + uri: uri.into(), 103 + owner_did: "did:plc:owner".into(), 104 + name: "family-photos".into(), 105 + member_dids: vec!["did:plc:alice".into(), "did:plc:charlie".into()], 106 + }; 107 + process_event(&state, &update, 1709330500000000).unwrap(); 108 + 109 + let bob = state 110 + .db 111 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None)) 112 + .unwrap(); 113 + assert!(bob.is_empty()); 114 + 115 + let charlie = state 116 + .db 117 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:charlie", 50, None)) 118 + .unwrap(); 119 + assert_eq!(charlie.len(), 1); 120 + } 121 + 122 + #[test] 123 + fn indexes_keyring_delete() { 124 + let state = test_state(); 125 + let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 126 + 127 + let create = IndexableEvent::UpsertKeyring { 128 + uri: uri.into(), 129 + owner_did: "did:plc:owner".into(), 130 + name: "family-photos".into(), 131 + member_dids: vec!["did:plc:alice".into()], 132 + }; 133 + process_event(&state, &create, 1709330400000000).unwrap(); 134 + 135 + let delete = IndexableEvent::DeleteKeyring { uri: uri.into() }; 136 + process_event(&state, &delete, 1709330500000000).unwrap(); 137 + 138 + let alice = state 139 + .db 140 + .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None)) 141 + .unwrap(); 142 + assert!(alice.is_empty()); 143 + }
+58
crates/opake-appview/src/main.rs
··· 1 + mod api; 2 + mod commands; 3 + mod config; 4 + mod db; 5 + mod error; 6 + mod firehose; 7 + mod indexer; 8 + mod state; 9 + 10 + use std::path::PathBuf; 11 + 12 + use clap::Parser; 13 + 14 + use commands::Command; 15 + 16 + #[derive(Parser)] 17 + #[command(name = "opake-appview", about = "AppView indexer and API for Opake")] 18 + struct Cli { 19 + /// Increase output verbosity (-v info, -vv debug, -vvv trace) 20 + #[arg(short, long, action = clap::ArgAction::Count, global = true)] 21 + verbose: u8, 22 + 23 + /// Override config directory (where appview.toml lives) 24 + #[arg(long, global = true)] 25 + config_dir: Option<String>, 26 + 27 + #[command(subcommand)] 28 + command: Option<Command>, 29 + } 30 + 31 + #[tokio::main] 32 + async fn main() -> anyhow::Result<()> { 33 + let cli = Cli::parse(); 34 + 35 + let log_level = match cli.verbose { 36 + 0 => log::LevelFilter::Warn, 37 + 1 => log::LevelFilter::Info, 38 + 2 => log::LevelFilter::Debug, 39 + _ => log::LevelFilter::Trace, 40 + }; 41 + 42 + env_logger::Builder::new() 43 + .filter_level(log_level) 44 + .parse_default_env() 45 + .init(); 46 + 47 + let config = config::Config::load(cli.config_dir.map(PathBuf::from))?; 48 + 49 + match cli 50 + .command 51 + .unwrap_or(Command::Run(commands::run::RunCommand {})) 52 + { 53 + Command::Run(cmd) => cmd.execute(&config).await, 54 + Command::Index(cmd) => cmd.execute(&config).await, 55 + Command::Serve(cmd) => cmd.execute(&config).await, 56 + Command::Status(cmd) => cmd.execute(&config), 57 + } 58 + }
+23
crates/opake-appview/src/state.rs
··· 1 + use std::sync::atomic::AtomicBool; 2 + 3 + use tokio::sync::Mutex; 4 + 5 + use crate::api::key_cache::KeyCache; 6 + use crate::db::Database; 7 + 8 + /// Shared application state for Axum handlers and the indexer. 9 + pub struct AppState { 10 + pub db: Database, 11 + pub indexer_connected: AtomicBool, 12 + pub key_cache: Mutex<KeyCache>, 13 + } 14 + 15 + impl AppState { 16 + pub fn new(db: Database) -> Self { 17 + Self { 18 + db, 19 + indexer_connected: AtomicBool::new(false), 20 + key_cache: Mutex::new(KeyCache::new()), 21 + } 22 + } 23 + }
+9 -9
crates/opake-cli/Cargo.toml
··· 11 11 12 12 [dependencies] 13 13 opake-core = { path = "../opake-core" } 14 + anyhow.workspace = true 14 15 base64.workspace = true 15 16 chrono.workspace = true 16 - clap = { version = "4", features = ["derive"] } 17 - mime_guess = "2" 18 - tokio = { version = "1", features = ["full"] } 19 - toml = "0.8" 20 - anyhow = "1" 21 - env_logger = "0.11" 17 + clap.workspace = true 18 + env_logger.workspace = true 22 19 log.workspace = true 23 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } 20 + mime_guess = "2" 21 + reqwest.workspace = true 24 22 serde.workspace = true 25 23 serde_json.workspace = true 24 + tokio.workspace = true 25 + toml.workspace = true 26 26 27 27 [dev-dependencies] 28 28 opake-core = { path = "../opake-core", features = ["test-utils"] } 29 - tempfile = "3" 30 - tokio = { version = "1", features = ["full", "test-util"] } 29 + tempfile.workspace = true 30 + tokio = { workspace = true, features = ["test-util"] }
+2
crates/opake-cli/src/commands/login.rs
··· 89 89 } 90 90 91 91 let public_key_bytes = identity.public_key_bytes()?; 92 + let verify_key_bytes = identity.verify_key_bytes()?; 92 93 opake_core::resolve::publish_public_key( 93 94 &mut client, 94 95 &public_key_bytes, 96 + verify_key_bytes.as_ref(), 95 97 &Utc::now().to_rfc3339(), 96 98 ) 97 99 .await?;
+16 -7
crates/opake-cli/src/config.rs
··· 1 1 use std::collections::BTreeMap; 2 2 use std::fs; 3 3 use std::path::PathBuf; 4 + use std::sync::RwLock; 4 5 5 6 use anyhow::Context; 6 7 use serde::de::DeserializeOwned; 7 8 use serde::{Deserialize, Serialize}; 9 + 10 + static DATA_DIR: RwLock<Option<PathBuf>> = RwLock::new(None); 11 + 12 + /// Resolve and store the data directory. Call once at startup. 13 + /// Priority: override > OPAKE_DATA_DIR env > XDG_CONFIG_HOME/opake > ~/.config/opake 14 + pub fn init_data_dir(override_dir: Option<PathBuf>) { 15 + let dir = opake_core::paths::resolve_data_dir(override_dir); 16 + *DATA_DIR.write().unwrap() = Some(dir); 17 + } 8 18 9 19 /// Persistent CLI configuration — tracks all logged-in accounts. 10 20 #[derive(Debug, Serialize, Deserialize)] ··· 21 31 pub handle: String, 22 32 } 23 33 24 - /// Where Opake stores its state on disk. Overridable via `OPAKE_DATA_DIR` 25 - /// for testing — production code never sets this. 34 + /// The resolved data directory. Must call `init_data_dir()` before use. 26 35 pub fn data_dir() -> PathBuf { 27 - if let Ok(dir) = std::env::var("OPAKE_DATA_DIR") { 28 - return PathBuf::from(dir); 29 - } 30 - let home = std::env::var("HOME").expect("HOME not set"); 31 - PathBuf::from(home).join(".config").join("opake") 36 + DATA_DIR 37 + .read() 38 + .unwrap() 39 + .clone() 40 + .expect("data_dir not initialized: call init_data_dir() first") 32 41 } 33 42 34 43 /// Create the data directory if it doesn't exist.
+1 -1
crates/opake-cli/src/config_tests.rs
··· 126 126 fn ensure_data_dir_creates_directory() { 127 127 with_test_dir(|dir| { 128 128 let target = dir.path().join("nested"); 129 - std::env::set_var("OPAKE_DATA_DIR", &target); 129 + init_data_dir(Some(target.clone())); 130 130 assert!(!target.exists()); 131 131 ensure_data_dir().unwrap(); 132 132 assert!(target.exists());
+171 -6
crates/opake-cli/src/identity.rs
··· 2 2 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 3 3 use log::info; 4 4 use opake_core::crypto::{ 5 - CryptoRng, RngCore, X25519DalekPublicKey, X25519DalekStaticSecret, X25519PrivateKey, 6 - X25519PublicKey, 5 + CryptoRng, Ed25519SigningKey, RngCore, X25519DalekPublicKey, X25519DalekStaticSecret, 6 + X25519PrivateKey, X25519PublicKey, 7 7 }; 8 8 use serde::{Deserialize, Serialize}; 9 9 10 10 use crate::config; 11 11 12 - /// X25519 encryption keypair, stored as base64 in `identity.json`. 12 + /// Ed25519 signing key: 32 raw bytes (the secret scalar). 13 + pub type Ed25519SecretKey = [u8; 32]; 14 + /// Ed25519 verify key: 32 raw bytes (the public point). 15 + pub type Ed25519VerifyKey = [u8; 32]; 16 + 17 + /// Encryption + signing keypairs, stored as base64 in `identity.json`. 18 + /// The signing fields are optional for backward compat with old identity files. 13 19 #[derive(Debug, Serialize, Deserialize)] 14 20 pub struct Identity { 15 21 pub did: String, 16 22 pub public_key: String, 17 23 pub private_key: String, 24 + /// Ed25519 signing secret key (base64). 25 + #[serde(default)] 26 + pub signing_key: Option<String>, 27 + /// Ed25519 signing public/verify key (base64). 28 + #[serde(default)] 29 + pub verify_key: Option<String>, 18 30 } 19 31 20 32 impl Identity { ··· 37 49 })?; 38 50 Ok(key) 39 51 } 52 + 53 + pub fn signing_key_bytes(&self) -> anyhow::Result<Option<Ed25519SecretKey>> { 54 + match &self.signing_key { 55 + None => Ok(None), 56 + Some(b64) => { 57 + let bytes = BASE64 58 + .decode(b64) 59 + .context("invalid base64 in identity signing_key")?; 60 + let key: Ed25519SecretKey = bytes.try_into().map_err(|v: Vec<u8>| { 61 + anyhow::anyhow!("signing key is {} bytes, expected 32", v.len()) 62 + })?; 63 + Ok(Some(key)) 64 + } 65 + } 66 + } 67 + 68 + pub fn verify_key_bytes(&self) -> anyhow::Result<Option<Ed25519VerifyKey>> { 69 + match &self.verify_key { 70 + None => Ok(None), 71 + Some(b64) => { 72 + let bytes = BASE64 73 + .decode(b64) 74 + .context("invalid base64 in identity verify_key")?; 75 + let key: Ed25519VerifyKey = bytes.try_into().map_err(|v: Vec<u8>| { 76 + anyhow::anyhow!("verify key is {} bytes, expected 32", v.len()) 77 + })?; 78 + Ok(Some(key)) 79 + } 80 + } 81 + } 82 + 83 + /// Whether this identity has Ed25519 signing keys. 84 + pub fn has_signing_keys(&self) -> bool { 85 + self.signing_key.is_some() && self.verify_key.is_some() 86 + } 40 87 } 41 88 42 89 pub fn save_identity(did: &str, identity: &Identity) -> anyhow::Result<()> { ··· 47 94 config::load_account_json(did, "identity.json") 48 95 } 49 96 97 + /// Generate a fresh Ed25519 signing keypair, returning (secret_b64, verify_b64). 98 + fn generate_signing_keypair(rng: &mut (impl CryptoRng + RngCore)) -> (String, String) { 99 + let signing_key = Ed25519SigningKey::generate(rng); 100 + let verify_key = signing_key.verifying_key(); 101 + ( 102 + BASE64.encode(signing_key.to_bytes()), 103 + BASE64.encode(verify_key.to_bytes()), 104 + ) 105 + } 106 + 50 107 /// Return the existing identity if present, otherwise generate a new 51 - /// X25519 keypair, save it, and return it. The boolean indicates whether 52 - /// a new keypair was generated. 108 + /// keypair set, save it, and return it. The boolean indicates whether 109 + /// a new keypair was generated (or an existing one was migrated). 110 + /// 111 + /// Migration: old identity files without signing keys get Ed25519 keys 112 + /// added transparently on load. 53 113 pub fn ensure_identity( 54 114 did: &str, 55 115 rng: &mut (impl CryptoRng + RngCore), 56 116 ) -> anyhow::Result<(Identity, bool)> { 57 - if let Ok(existing) = load_identity(did) { 117 + if let Ok(mut existing) = load_identity(did) { 58 118 if existing.did == did { 119 + if !existing.has_signing_keys() { 120 + info!("migrating identity: adding Ed25519 signing keypair"); 121 + let (sk, vk) = generate_signing_keypair(rng); 122 + existing.signing_key = Some(sk); 123 + existing.verify_key = Some(vk); 124 + save_identity(did, &existing)?; 125 + return Ok((existing, true)); 126 + } 59 127 return Ok((existing, false)); 60 128 } 61 129 info!( ··· 66 134 67 135 let private_secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 68 136 let public_key = X25519DalekPublicKey::from(&private_secret); 137 + let (signing_key, verify_key) = generate_signing_keypair(rng); 69 138 70 139 let identity = Identity { 71 140 did: did.to_string(), 72 141 public_key: BASE64.encode(public_key.as_bytes()), 73 142 private_key: BASE64.encode(private_secret.to_bytes()), 143 + signing_key: Some(signing_key), 144 + verify_key: Some(verify_key), 74 145 }; 75 146 save_identity(did, &identity)?; 76 147 Ok((identity, true)) ··· 108 179 did: did.into(), 109 180 public_key: BASE64.encode([1u8; 32]), 110 181 private_key: BASE64.encode([2u8; 32]), 182 + signing_key: Some(BASE64.encode([3u8; 32])), 183 + verify_key: Some(BASE64.encode([4u8; 32])), 111 184 }; 112 185 save_identity(did, &identity).unwrap(); 113 186 ··· 115 188 assert_eq!(loaded.did, identity.did); 116 189 assert_eq!(loaded.public_key, identity.public_key); 117 190 assert_eq!(loaded.private_key, identity.private_key); 191 + assert_eq!(loaded.signing_key, identity.signing_key); 192 + assert_eq!(loaded.verify_key, identity.verify_key); 118 193 119 194 assert_eq!(loaded.public_key_bytes().unwrap(), [1u8; 32]); 120 195 assert_eq!(loaded.private_key_bytes().unwrap(), [2u8; 32]); 196 + assert_eq!(loaded.signing_key_bytes().unwrap().unwrap(), [3u8; 32]); 197 + assert_eq!(loaded.verify_key_bytes().unwrap().unwrap(), [4u8; 32]); 121 198 }); 122 199 } 123 200 ··· 131 208 assert_eq!(identity.did, did); 132 209 assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 133 210 assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 211 + assert!(identity.has_signing_keys()); 212 + assert!(identity.signing_key_bytes().unwrap().is_some()); 213 + assert!(identity.verify_key_bytes().unwrap().is_some()); 134 214 }); 135 215 } 136 216 ··· 146 226 assert!(!generated); 147 227 assert_eq!(first.public_key, second.public_key); 148 228 assert_eq!(first.private_key, second.private_key); 229 + assert_eq!(first.signing_key, second.signing_key); 230 + }); 231 + } 232 + 233 + #[test] 234 + fn ensure_identity_migrates_old_identity_without_signing_keys() { 235 + with_test_dir(|_| { 236 + let did = "did:plc:legacy"; 237 + setup_account(did); 238 + 239 + // Write an old-format identity (no signing keys) 240 + let old_identity = serde_json::json!({ 241 + "did": did, 242 + "public_key": BASE64.encode([1u8; 32]), 243 + "private_key": BASE64.encode([2u8; 32]), 244 + }); 245 + config::ensure_account_dir(did).unwrap(); 246 + std::fs::write( 247 + config::account_dir(did).join("identity.json"), 248 + serde_json::to_string_pretty(&old_identity).unwrap(), 249 + ) 250 + .unwrap(); 251 + 252 + let (identity, generated) = ensure_identity(did, &mut OsRng).unwrap(); 253 + assert!(generated, "migration should report as generated"); 254 + assert!(identity.has_signing_keys()); 255 + // X25519 keys should be preserved 256 + assert_eq!(identity.public_key_bytes().unwrap(), [1u8; 32]); 257 + assert_eq!(identity.private_key_bytes().unwrap(), [2u8; 32]); 258 + 259 + // Re-load should have signing keys persisted 260 + let reloaded = load_identity(did).unwrap(); 261 + assert!(reloaded.has_signing_keys()); 262 + assert_eq!(reloaded.signing_key, identity.signing_key); 149 263 }); 150 264 } 151 265 ··· 185 299 did: "did:plc:test".into(), 186 300 public_key: "not!valid!base64!!!".into(), 187 301 private_key: BASE64.encode([0u8; 32]), 302 + signing_key: None, 303 + verify_key: None, 188 304 }; 189 305 assert!(identity.public_key_bytes().is_err()); 190 306 } ··· 195 311 did: "did:plc:test".into(), 196 312 public_key: BASE64.encode([0u8; 32]), 197 313 private_key: "~~~garbage~~~".into(), 314 + signing_key: None, 315 + verify_key: None, 198 316 }; 199 317 assert!(identity.private_key_bytes().is_err()); 200 318 } ··· 205 323 did: "did:plc:test".into(), 206 324 public_key: BASE64.encode([0u8; 16]), 207 325 private_key: BASE64.encode([0u8; 32]), 326 + signing_key: None, 327 + verify_key: None, 208 328 }; 209 329 let err = identity.public_key_bytes().unwrap_err().to_string(); 210 330 assert!(err.contains("16 bytes"), "expected length in error: {err}"); ··· 216 336 did: "did:plc:test".into(), 217 337 public_key: BASE64.encode([0u8; 32]), 218 338 private_key: BASE64.encode([0u8; 64]), 339 + signing_key: None, 340 + verify_key: None, 219 341 }; 220 342 let err = identity.private_key_bytes().unwrap_err().to_string(); 221 343 assert!(err.contains("64 bytes"), "expected length in error: {err}"); 344 + } 345 + 346 + #[test] 347 + fn signing_key_bytes_rejects_bad_base64() { 348 + let identity = Identity { 349 + did: "did:plc:test".into(), 350 + public_key: BASE64.encode([0u8; 32]), 351 + private_key: BASE64.encode([0u8; 32]), 352 + signing_key: Some("!!!bad!!!".into()), 353 + verify_key: None, 354 + }; 355 + assert!(identity.signing_key_bytes().is_err()); 356 + } 357 + 358 + #[test] 359 + fn verify_key_bytes_rejects_wrong_length() { 360 + let identity = Identity { 361 + did: "did:plc:test".into(), 362 + public_key: BASE64.encode([0u8; 32]), 363 + private_key: BASE64.encode([0u8; 32]), 364 + signing_key: None, 365 + verify_key: Some(BASE64.encode([0u8; 16])), 366 + }; 367 + let err = identity.verify_key_bytes().unwrap_err().to_string(); 368 + assert!(err.contains("16 bytes"), "expected length in error: {err}"); 369 + } 370 + 371 + #[test] 372 + fn has_signing_keys_requires_both() { 373 + let mut identity = Identity { 374 + did: "did:plc:test".into(), 375 + public_key: BASE64.encode([0u8; 32]), 376 + private_key: BASE64.encode([0u8; 32]), 377 + signing_key: None, 378 + verify_key: None, 379 + }; 380 + assert!(!identity.has_signing_keys()); 381 + 382 + identity.signing_key = Some(BASE64.encode([0u8; 32])); 383 + assert!(!identity.has_signing_keys()); 384 + 385 + identity.verify_key = Some(BASE64.encode([0u8; 32])); 386 + assert!(identity.has_signing_keys()); 222 387 } 223 388 }
+7
crates/opake-cli/src/main.rs
··· 17 17 #[arg(long, global = true)] 18 18 r#as: Option<String>, 19 19 20 + /// Override config directory 21 + #[arg(long, global = true)] 22 + config_dir: Option<String>, 23 + 20 24 /// Increase output verbosity (-v info, -vv debug, -vvv trace) 21 25 #[arg(short, long, action = clap::ArgAction::Count, global = true)] 22 26 verbose: u8, ··· 55 59 async fn main() -> anyhow::Result<()> { 56 60 let Cli { 57 61 r#as: as_flag, 62 + config_dir, 58 63 verbose, 59 64 command, 60 65 } = Cli::parse(); 66 + 67 + config::init_data_dir(config_dir.map(Into::into)); 61 68 62 69 let log_level = match verbose { 63 70 0 => log::LevelFilter::Warn,
+5 -6
crates/opake-cli/src/utils.rs
··· 8 8 std::env::var(format_env_key(key)).ok() 9 9 } 10 10 11 - /// Test helpers for modules that need to override `OPAKE_DATA_DIR`. 12 - /// A global mutex prevents parallel tests from stomping each other's env var. 11 + /// Test helpers for modules that need an isolated data directory. 12 + /// A global mutex prevents parallel tests from stomping each other's state. 13 13 #[cfg(test)] 14 14 pub mod test_harness { 15 15 use std::sync::Mutex; 16 16 use tempfile::TempDir; 17 17 18 - static ENV_LOCK: Mutex<()> = Mutex::new(()); 18 + static TEST_LOCK: Mutex<()> = Mutex::new(()); 19 19 20 20 pub fn with_test_dir(f: impl FnOnce(&TempDir)) { 21 - let _guard = ENV_LOCK.lock().unwrap(); 21 + let _guard = TEST_LOCK.lock().unwrap(); 22 22 let dir = TempDir::new().unwrap(); 23 - unsafe { std::env::set_var("OPAKE_DATA_DIR", dir.path()) }; 23 + crate::config::init_data_dir(Some(dir.path().to_path_buf())); 24 24 f(&dir); 25 - unsafe { std::env::remove_var("OPAKE_DATA_DIR") }; 26 25 } 27 26 } 28 27
+3 -2
crates/opake-core/Cargo.toml
··· 9 9 test-utils = [] 10 10 11 11 [dependencies] 12 + base64.workspace = true 13 + ed25519-dalek.workspace = true 12 14 log.workspace = true 13 15 serde.workspace = true 14 16 serde_json.workspace = true ··· 19 21 aes-kw = { version = "0.2", features = ["alloc"] } 20 22 hkdf = "0.12" # HKDF key derivation (RFC 5869) 21 23 sha2 = "0.10" # SHA-256 hash — used by HKDF internally 22 - base64 = "0.22" # atproto $bytes encoding 23 24 24 25 [dev-dependencies] 25 - tokio = { version = "1", features = ["macros", "rt"] } 26 + tokio = { workspace = true, features = ["macros", "rt"] } 26 27 27 28 # aes-gcm pulls in getrandom transitively. On wasm32, getrandom needs the 28 29 # "js" feature to use crypto.getRandomValues() instead of OS-level randomness.
+5 -1
crates/opake-core/src/crypto/mod.rs
··· 15 15 16 16 use crate::records::SCHEMA_VERSION; 17 17 18 - /// Re-export so callers don't need direct rand_core / x25519_dalek dependencies. 18 + /// Re-export so callers don't need direct rand_core / x25519_dalek / ed25519_dalek dependencies. 19 19 pub use aes_gcm::aead::rand_core::{CryptoRng, OsRng, RngCore}; 20 + pub use ed25519_dalek::{ 21 + Signature as Ed25519Signature, SigningKey as Ed25519SigningKey, 22 + VerifyingKey as Ed25519VerifyingKey, 23 + }; 20 24 pub use x25519_dalek::{ 21 25 PublicKey as X25519DalekPublicKey, StaticSecret as X25519DalekStaticSecret, 22 26 };
+1
crates/opake-core/src/lib.rs
··· 14 14 pub mod documents; 15 15 pub mod error; 16 16 pub mod keyrings; 17 + pub mod paths; 17 18 pub mod records; 18 19 pub mod resolve; 19 20 pub mod sharing;
+60
crates/opake-core/src/paths.rs
··· 1 + use std::path::PathBuf; 2 + 3 + /// Resolve the opake data directory from available sources. 4 + /// 5 + /// Priority: `override_dir` > `OPAKE_DATA_DIR` env > `XDG_CONFIG_HOME/opake` > `~/.config/opake` 6 + /// 7 + /// Pure function — no filesystem I/O, no singleton. Callers decide what to do with the path. 8 + pub fn resolve_data_dir(override_dir: Option<PathBuf>) -> PathBuf { 9 + override_dir 10 + .or_else(|| std::env::var("OPAKE_DATA_DIR").ok().map(PathBuf::from)) 11 + .or_else(|| { 12 + std::env::var("XDG_CONFIG_HOME") 13 + .ok() 14 + .map(|xdg| PathBuf::from(xdg).join("opake")) 15 + }) 16 + .unwrap_or_else(|| { 17 + let home = std::env::var("HOME").expect("HOME not set"); 18 + PathBuf::from(home).join(".config").join("opake") 19 + }) 20 + } 21 + 22 + /// Expand a leading `~/` to `$HOME/`. Anything else passes through unchanged. 23 + pub fn expand_tilde(path: &str) -> PathBuf { 24 + if let Some(rest) = path.strip_prefix("~/") { 25 + let home = std::env::var("HOME").expect("HOME not set"); 26 + PathBuf::from(home).join(rest) 27 + } else { 28 + PathBuf::from(path) 29 + } 30 + } 31 + 32 + #[cfg(test)] 33 + mod tests { 34 + use super::*; 35 + 36 + #[test] 37 + fn override_takes_priority() { 38 + let dir = resolve_data_dir(Some(PathBuf::from("/custom/path"))); 39 + assert_eq!(dir, PathBuf::from("/custom/path")); 40 + } 41 + 42 + #[test] 43 + fn expand_tilde_with_home_prefix() { 44 + let expanded = expand_tilde("~/some/dir"); 45 + assert!(!expanded.to_string_lossy().contains('~')); 46 + assert!(expanded.to_string_lossy().ends_with("some/dir")); 47 + } 48 + 49 + #[test] 50 + fn expand_tilde_no_prefix_passthrough() { 51 + let expanded = expand_tilde("/absolute/path"); 52 + assert_eq!(expanded, PathBuf::from("/absolute/path")); 53 + } 54 + 55 + #[test] 56 + fn expand_tilde_relative_passthrough() { 57 + let expanded = expand_tilde("relative/path"); 58 + assert_eq!(expanded, PathBuf::from("relative/path")); 59 + } 60 + }
+24
crates/opake-core/src/records/public_key.rs
··· 15 15 pub version: u32, 16 16 pub public_key: AtBytes, 17 17 pub algo: String, 18 + /// Ed25519 signing public key for DID-scoped authentication. 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub signing_key: Option<AtBytes>, 21 + /// Algorithm for the signing key (always "ed25519" when present). 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + pub signing_algo: Option<String>, 18 24 pub created_at: String, 19 25 } 20 26 ··· 27 33 encoded: BASE64.encode(public_key_bytes), 28 34 }, 29 35 algo: "x25519".into(), 36 + signing_key: None, 37 + signing_algo: None, 30 38 created_at: created_at.into(), 39 + } 40 + } 41 + 42 + /// Create a record with both encryption and signing keys. 43 + pub fn with_signing_key( 44 + public_key_bytes: &[u8], 45 + signing_key_bytes: &[u8], 46 + created_at: &str, 47 + ) -> Self { 48 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 49 + Self { 50 + signing_key: Some(AtBytes { 51 + encoded: BASE64.encode(signing_key_bytes), 52 + }), 53 + signing_algo: Some("ed25519".into()), 54 + ..Self::new(public_key_bytes, created_at) 31 55 } 32 56 } 33 57 }
+35 -5
crates/opake-core/src/resolve.rs
··· 14 14 use crate::error::Error; 15 15 use crate::records::{self, PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 16 16 17 + /// Ed25519 signing public key: 32 raw bytes. 18 + pub type Ed25519PublicKeyBytes = [u8; 32]; 19 + 17 20 /// Everything we learn about a remote user during resolution. 18 21 #[derive(Debug)] 19 22 pub struct ResolvedIdentity { ··· 22 25 pub pds_url: String, 23 26 pub public_key: X25519PublicKey, 24 27 pub algo: String, 28 + /// Ed25519 signing key — present if the user has published one. 29 + pub signing_key: Option<Ed25519PublicKeyBytes>, 25 30 } 26 31 27 32 /// Full resolution: input → DID → PDS → public key. ··· 81 86 Error::InvalidRecord(format!("public key is {} bytes, expected 32", v.len())) 82 87 })?; 83 88 89 + // Step 7: Decode optional signing key 90 + let signing_key = match record.signing_key { 91 + Some(ref sk) => { 92 + let sk_bytes = sk 93 + .decode() 94 + .map_err(|e| Error::InvalidRecord(format!("invalid signing key: {e}")))?; 95 + let key: [u8; 32] = sk_bytes.try_into().map_err(|v: Vec<u8>| { 96 + Error::InvalidRecord(format!("signing key is {} bytes, expected 32", v.len())) 97 + })?; 98 + Some(key) 99 + } 100 + None => None, 101 + }; 102 + 84 103 Ok(ResolvedIdentity { 85 104 did, 86 105 handle, 87 106 pds_url, 88 107 public_key, 89 108 algo: record.algo, 109 + signing_key, 90 110 }) 91 111 } 92 112 93 - /// Publish (upsert) the user's X25519 encryption public key to their PDS. 113 + /// Publish (upsert) the user's encryption + signing public keys to their PDS. 94 114 /// 95 115 /// Called on every login — `putRecord` is idempotent, so this is always 96 116 /// one request regardless of whether the record already exists. 97 117 pub async fn publish_public_key( 98 118 client: &mut XrpcClient<impl Transport>, 99 119 public_key: &X25519PublicKey, 120 + signing_key: Option<&Ed25519PublicKeyBytes>, 100 121 created_at: &str, 101 122 ) -> Result<String, Error> { 102 - let record = PublicKeyRecord::new(public_key, created_at); 123 + let record = match signing_key { 124 + Some(sk) => PublicKeyRecord::with_signing_key(public_key, sk, created_at), 125 + None => PublicKeyRecord::new(public_key, created_at), 126 + }; 103 127 let result = client 104 128 .put_record(PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY, &record) 105 129 .await?; ··· 262 286 }; 263 287 let mut client = XrpcClient::with_session(mock.clone(), "https://pds.test".into(), session); 264 288 265 - let uri = publish_public_key(&mut client, &pubkey, "2026-03-01T12:00:00Z") 266 - .await 267 - .unwrap(); 289 + let signing_key = [88u8; 32]; 290 + let uri = publish_public_key( 291 + &mut client, 292 + &pubkey, 293 + Some(&signing_key), 294 + "2026-03-01T12:00:00Z", 295 + ) 296 + .await 297 + .unwrap(); 268 298 269 299 assert_eq!(uri, "at://did:plc:test/app.opake.cloud.publicKey/self"); 270 300
+165
docs/appview.md
··· 1 + # AppView: API & Deployment 2 + 3 + The AppView indexes `app.opake.cloud.grant` and `app.opake.cloud.keyring` records from the AT Protocol firehose and serves them via a REST API. It enables the `inbox` command — "what's been shared with me?" — without scanning every PDS in the network. 4 + 5 + ## Running Modes 6 + 7 + ```bash 8 + opake-appview run # indexer + API (default) 9 + opake-appview index # indexer only (write-only, no HTTP) 10 + opake-appview serve # API only (read-only, no Jetstream) 11 + opake-appview status # print cursor + stats, exit 12 + ``` 13 + 14 + Running with no subcommand is equivalent to `run`. 15 + 16 + ### Flags 17 + 18 + | Flag | Effect | 19 + |------|--------| 20 + | `-v` / `-vv` / `-vvv` | Logging: info / debug / trace | 21 + | `--config-dir <path>` | Override data directory containing `appview.toml` | 22 + 23 + ## Configuration 24 + 25 + TOML file at `~/.config/opake/appview.toml` (or `$XDG_CONFIG_HOME/opake/appview.toml`). 26 + 27 + Override: set `OPAKE_DATA_DIR` to the directory containing `appview.toml`, or use `--config-dir`. 28 + 29 + ```toml 30 + jetstream_url = "wss://jetstream2.us-east.bsky.network/subscribe" 31 + listen = "127.0.0.1:6100" 32 + db_path = "~/.config/opake/appview.db" 33 + ``` 34 + 35 + | Field | Required | Notes | 36 + |-------|----------|-------| 37 + | `jetstream_url` | yes | Must start with `ws://` or `wss://` | 38 + | `listen` | yes | `host:port` for the HTTP server | 39 + | `db_path` | yes | SQLite path. `~` is expanded. | 40 + 41 + ## Authentication 42 + 43 + All API endpoints except `/api/health` require authentication via DID-scoped Ed25519 signatures. 44 + 45 + Users prove they control a DID by signing with their opake Ed25519 signing key. 46 + 47 + **Header format:** 48 + ``` 49 + Authorization: Opake-Ed25519 <did>:<unix-timestamp>:<base64(signature)> 50 + ``` 51 + 52 + **Signature covers:** 53 + ``` 54 + <METHOD>:<path>:<timestamp>:<did> 55 + ``` 56 + 57 + Example: 58 + ``` 59 + GET:/api/inbox:1709330400:did:plc:abc123 60 + ``` 61 + 62 + **Verification flow:** 63 + 1. Parse header — extract DID, timestamp, signature 64 + 2. Reject if timestamp is >60 seconds from now (replay protection) 65 + 3. Reject if `?did=` parameter doesn't match authenticated DID (scope enforcement) 66 + 4. Fetch `app.opake.cloud.publicKey/self` from the user's PDS 67 + 5. Extract `signingKey` (Ed25519) from the record 68 + 6. Verify signature with `ed25519-dalek` 69 + 7. Cache verified key for 5 minutes 70 + 71 + **CLI side:** 72 + ```rust 73 + let timestamp = Utc::now().timestamp(); 74 + let message = format!("GET:/api/inbox:{timestamp}:{did}"); 75 + let signature = signing_key.sign(message.as_bytes()); 76 + let header = format!("Opake-Ed25519 {did}:{timestamp}:{}", BASE64.encode(signature.to_bytes())); 77 + ``` 78 + 79 + ## API Endpoints 80 + 81 + ### `GET /api/health` 82 + 83 + Always unauthenticated. Returns indexer status only — no aggregate data. 84 + 85 + ```json 86 + { 87 + "indexerConnected": true, 88 + "cursorTime": "2026-03-02T12:00:00+00:00", 89 + "cursorAgeSecs": 5 90 + } 91 + ``` 92 + 93 + ### `GET /api/inbox?did=<did>&limit=<n>&cursor=<cursor>` 94 + 95 + Returns grants where `did` is the recipient. Newest first. 96 + 97 + | Param | Required | Default | Max | 98 + |-------|----------|---------|-----| 99 + | `did` | yes | — | — | 100 + | `limit` | no | 50 | 100 | 101 + | `cursor` | no | — | — | 102 + 103 + ```json 104 + { 105 + "grants": [ 106 + { 107 + "uri": "at://did:plc:owner/app.opake.cloud.grant/3abc", 108 + "ownerDid": "did:plc:owner", 109 + "documentUri": "at://did:plc:owner/app.opake.cloud.document/3xyz", 110 + "permissions": "read", 111 + "note": "photos from the trip", 112 + "createdAt": "2026-03-01T12:00:00Z" 113 + } 114 + ], 115 + "cursor": "2026-03-01T12:00:01Z::at://did:plc:owner/app.opake.cloud.grant/3abc" 116 + } 117 + 118 + ``` 119 + 120 + ### `GET /api/keyrings?did=<did>&limit=<n>&cursor=<cursor>` 121 + 122 + Returns keyrings where `did` is a member. 123 + 124 + ```json 125 + { 126 + "keyrings": [ 127 + { 128 + "uri": "at://did:plc:owner/app.opake.cloud.keyring/3def", 129 + "ownerDid": "did:plc:owner", 130 + "name": "family-photos", 131 + "indexedAt": "2026-03-01T12:00:00Z" 132 + } 133 + ], 134 + "cursor": "..." 135 + } 136 + ``` 137 + 138 + ## Rate Limiting 139 + 140 + All endpoints are rate-limited per IP via `tower_governor`. Limits: 10 requests/second sustained, 30 burst. Requests beyond the limit receive `429 Too Many Requests`. 141 + 142 + IP extraction checks `X-Forwarded-For`, `X-Real-Ip`, and falls back to peer address — works correctly behind Traefik or similar reverse proxies. 143 + 144 + ## Horizontal Scaling 145 + 146 + SQLite WAL mode supports one writer + many readers. For horizontal scaling: 147 + 148 + - **One `index` process** — writes to the database 149 + - **N `serve` processes** — read-only, behind a load balancer 150 + 151 + All processes point at the same `db_path`. WAL mode handles concurrent reads during writes. 152 + 153 + For setups beyond a single machine, migrate to Postgres (future work). 154 + 155 + ## Status Command 156 + 157 + Quick operational check without starting a server: 158 + 159 + ``` 160 + $ opake-appview status 161 + Cursor: 2026-03-02T12:00:00+00:00 162 + Lag: 5s 163 + Grants: 42 164 + Keyrings: 3 165 + ```
+10
lexicons/app.opake.cloud.publicKey.json
··· 25 25 "knownValues": ["x25519"], 26 26 "description": "Key algorithm identifier." 27 27 }, 28 + "signingKey": { 29 + "type": "bytes", 30 + "maxLength": 32, 31 + "description": "Raw Ed25519 signing public key bytes. Used for DID-scoped authentication with the AppView." 32 + }, 33 + "signingAlgo": { 34 + "type": "string", 35 + "knownValues": ["ed25519"], 36 + "description": "Signing key algorithm identifier." 37 + }, 28 38 "createdAt": { 29 39 "type": "string", 30 40 "format": "datetime",