feat: authenticated fork with real PDS record creation The previous fork handler was a stub: it inserted a DB row with no auth, no PDS record, and no node placement. Clicking Fork would 500 in production (FK violation on empty node_did) and, even if it succeeded, the "fork" was a local-only fiction that would be wiped on re-indexing. This replaces the stub with a real implementation: - Requires an authenticated session (cookie). The user's DID is derived from the session, not the request body. Removes the "fork as anyone" security hole. - Calls the user's PDS via com.atproto.repo.createRecord using OAuth/DPoP (bearer token + DPoP proof with ath claim). Handles the DPoP nonce retry dance required by ATProto. - Assigns the fork to the configured default cospan node (DEFAULT_NODE_DID / DEFAULT_NODE_URL env vars), not the source's node. A fork of tangled.org/core no longer claims to live at knot1.tangled.sh where the user has no write access. - Writes an optimistic DB row so the UI sees the fork immediately; the firehose will upsert the canonical version once it arrives. Git object copy is still a follow-up — this gives us a correctly authenticated PDS record with proper node placement. New infrastructure: - auth::pds_client module with create_record helper - DpopKey::create_resource_proof (adds ath claim for PDS requests) - AppError::Upstream variant (502 Bad Gateway) - AppConfig::default_node_did / default_node_url Tests (7 fork cases, all passing): - fork_creates_new_repo — end-to-end with wiremock PDS - fork_requires_authentication — 401 without session - fork_with_custom_name — name override - fork_increments_source_fork_count — counter side effect - fork_nonexistent_source_returns_404 — missing source - fork_invalid_uri_returns_400 — malformed AT-URI - fork_pds_error_returns_upstream_error — 502 on PDS failure Added wiremock as a dev dependency for mocking the user's PDS.
Author: Aaron Steven White
Commit
e2b90655d4ee4d285e31639e6b631b879de120a6Parent: f493856be9
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan11 files changed +606 -44
@@ -123,6 +123,16 @@ dependencies = [
123123 ] 124124 125125 [[package]] 126+name = "assert-json-diff" 127+version = "2.0.2" 128+source = "registry+https://github.com/rust-lang/crates.io-index" 129+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" 130+dependencies = [ 131+ "serde", 132+ "serde_json", 133+] 134+ 135+[[package]] 126136 name = "async-compression" 127137 version = "0.4.41" 128138 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -565,6 +575,7 @@ dependencies = [
565575 "tracing-subscriber", 566576 "urlencoding", 567577 "uuid", 578+ "wiremock", 568579 ] 569580 570581 [[package]]
@@ -726,6 +737,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
726737 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 727738 728739 [[package]] 740+name = "deadpool" 741+version = "0.12.3" 742+source = "registry+https://github.com/rust-lang/crates.io-index" 743+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" 744+dependencies = [ 745+ "deadpool-runtime", 746+ "lazy_static", 747+ "num_cpus", 748+ "tokio", 749+] 750+ 751+[[package]] 752+name = "deadpool-runtime" 753+version = "0.1.4" 754+source = "registry+https://github.com/rust-lang/crates.io-index" 755+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" 756+ 757+[[package]] 729758 name = "der" 730759 version = "0.7.10" 731760 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1014,6 +1043,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
10141043 checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" 10151044 10161045 [[package]] 1046+name = "futures" 1047+version = "0.3.32" 1048+source = "registry+https://github.com/rust-lang/crates.io-index" 1049+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 1050+dependencies = [ 1051+ "futures-channel", 1052+ "futures-core", 1053+ "futures-executor", 1054+ "futures-io", 1055+ "futures-sink", 1056+ "futures-task", 1057+ "futures-util", 1058+] 1059+ 1060+[[package]] 10171061 name = "futures-channel" 10181062 version = "0.3.32" 10191063 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1086,6 +1130,7 @@ version = "0.3.32"
10861130 source = "registry+https://github.com/rust-lang/crates.io-index" 10871131 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 10881132 dependencies = [ 1133+ "futures-channel", 10891134 "futures-core", 10901135 "futures-io", 10911136 "futures-macro",
@@ -1279,6 +1324,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12791324 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 12801325 12811326 [[package]] 1327+name = "hermit-abi" 1328+version = "0.5.2" 1329+source = "registry+https://github.com/rust-lang/crates.io-index" 1330+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1331+ 1332+[[package]] 12821333 name = "hex" 12831334 version = "0.4.3" 12841335 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2313,6 +2364,16 @@ dependencies = [
23132364 ] 23142365 23152366 [[package]] 2367+name = "num_cpus" 2368+version = "1.17.0" 2369+source = "registry+https://github.com/rust-lang/crates.io-index" 2370+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 2371+dependencies = [ 2372+ "hermit-abi", 2373+ "libc", 2374+] 2375+ 2376+[[package]] 23162377 name = "object" 23172378 version = "0.37.3" 23182379 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5358,6 +5419,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
53585419 checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" 53595420 53605421 [[package]] 5422+name = "wiremock" 5423+version = "0.6.5" 5424+source = "registry+https://github.com/rust-lang/crates.io-index" 5425+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" 5426+dependencies = [ 5427+ "assert-json-diff", 5428+ "base64", 5429+ "deadpool", 5430+ "futures", 5431+ "http", 5432+ "http-body-util", 5433+ "hyper", 5434+ "hyper-util", 5435+ "log", 5436+ "once_cell", 5437+ "regex", 5438+ "serde", 5439+ "serde_json", 5440+ "tokio", 5441+ "url", 5442+] 5443+ 5444+[[package]] 53615445 name = "wit-bindgen" 53625446 version = "0.51.0" 53635447 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -20,11 +20,11 @@
2020 forkError = null; 2121 try { 2222 const sourceUri = `at://${repo.did}/dev.cospan.repo/${repo.rkey ?? repo.name}`; 23- await xrpcProcedure('dev.cospan.repo.fork', { 24- sourceRepo: sourceUri, 25- did: auth.did, 26- }); 27- goto(`/${auth.did}/${repo.name}`); 23+ const result = await xrpcProcedure<{ rkey: string; name: string }>( 24+ 'dev.cospan.repo.fork', 25+ { sourceRepo: sourceUri } 26+ ); 27+ goto(`/${auth.did}/${result.name}`); 2828 } catch (e: any) { 2929 forkError = e.message ?? 'Fork failed'; 3030 forkingRepo = null;
@@ -61,3 +61,4 @@ uuid.workspace = true
6161 mockall.workspace = true 6262 reqwest.workspace = true 6363 tempfile = "3" 64+wiremock = "0.6"
@@ -113,6 +113,38 @@ impl DpopKey {
113113 htu: url.to_string(), 114114 iat: chrono::Utc::now().timestamp(), 115115 nonce: nonce.map(String::from), 116+ ath: None, 117+ }; 118+ 119+ encode_es256_jwt(&header, &claims, &self.signing_key) 120+ } 121+ 122+ /// Create a DPoP proof JWT for a resource-server (PDS) request. 123+ /// Includes the `ath` claim (SHA-256 hash of the access token, base64url). 124+ pub fn create_resource_proof( 125+ &self, 126+ method: &str, 127+ url: &str, 128+ access_token: &str, 129+ nonce: Option<&str>, 130+ ) -> anyhow::Result<String> { 131+ let mut hasher = Sha256::new(); 132+ hasher.update(access_token.as_bytes()); 133+ let ath = URL_SAFE_NO_PAD.encode(hasher.finalize()); 134+ 135+ let header = DpopHeader { 136+ alg: "ES256".to_string(), 137+ typ: "dpop+jwt".to_string(), 138+ jwk: self.public_jwk.clone(), 139+ }; 140+ 141+ let claims = DpopClaims { 142+ jti: uuid::Uuid::new_v4().to_string(), 143+ htm: method.to_uppercase(), 144+ htu: url.to_string(), 145+ iat: chrono::Utc::now().timestamp(), 146+ nonce: nonce.map(String::from), 147+ ath: Some(ath), 116148 }; 117149 118150 encode_es256_jwt(&header, &claims, &self.signing_key)
@@ -163,6 +195,9 @@ struct DpopClaims {
163195 iat: i64, 164196 #[serde(skip_serializing_if = "Option::is_none")] 165197 nonce: Option<String>, 198+ /// Access token hash (SHA-256, base64url) — required for resource server requests. 199+ #[serde(skip_serializing_if = "Option::is_none")] 200+ ath: Option<String>, 166201 } 167202 168203 #[derive(Serialize)]
@@ -1,6 +1,7 @@
11 pub mod did_resolver; 22 pub mod dpop; 33 pub mod oauth; 4+pub mod pds_client; 45 pub mod session; 56 67 use serde::{Deserialize, Serialize};