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 e2b90655d4ee4d285e31639e6b631b879de120a6
Parent: 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-cospan
11 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};
cospan · schematic version control on atproto built on AT Protocol