wip: Custom mirroring tangled knot written in Rust
rust tangled mirror knot
1

Configure Feed

Select the types of activity you want to include in your feed.

xrpc(knotty/create): init route with bare repo and did creation

Clément (Jun 13, 2026, 3:18 AM +0200) 23cf58ae ef65cfd7

+133 -16
+3
pnpm-lock.yaml
··· 219 219 '@ark-ui/solid': 220 220 specifier: ^5.37.1 221 221 version: 5.37.1(solid-js@1.9.13) 222 + '@atcute/atproto': 223 + specifier: ^4.0.2 224 + version: 4.0.2(@atcute/lexicons@2.0.0) 222 225 '@atcute/bluesky': 223 226 specifier: ^4.0.6 224 227 version: 4.0.6(@atcute/lexicons@2.0.0)
+1
app/package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@ark-ui/solid": "^5.37.1", 14 + "@atcute/atproto": "^4.0.2", 14 15 "@atcute/bluesky": "^4.0.6", 15 16 "@atcute/client": "^5.0.0", 16 17 "@atcute/identity-resolver": "^2.0.0",
+27 -3
app/src/MirrorButton.tsx
··· 9 9 import { FormError } from "./components/FormError"; 10 10 import { useSession } from "./session"; 11 11 import { jwtHeaders } from "./lib/atproto"; 12 + import * as TangledRepo from "@atcute/tangled/types/repo"; 13 + import { GenericUri, parse } from "@atcute/lexicons"; 12 14 13 15 async function createKnottyMirror( 14 16 agent: OAuthUserAgent, ··· 46 48 }, 47 49 defaultValues: { url: "", name: "" }, 48 50 onSubmit: async ({ value }) => { 49 - const a = agent(); 50 - if (!a) return; 51 - await createKnottyMirror(a, { 51 + const oauthAgent = agent(); 52 + if (!oauthAgent) return; 53 + const did = await createKnottyMirror(oauthAgent, { 52 54 name: value.name, 53 55 rkey: value.name, 54 56 source: value.url, 55 57 defaultBranch: undefined, 58 + }); 59 + if (!did) return; 60 + 61 + const recordInput: TangledRepo.Main = { 62 + $type: "sh.tangled.repo", 63 + name: value.name, 64 + website: value.url as GenericUri, 65 + knot: import.meta.env.KNOT_HOSTNAME, 66 + repoDid: did.repoDid, 67 + createdAt: new Date().toISOString(), 68 + }; 69 + const record = parse(TangledRepo.mainSchema, recordInput); 70 + 71 + const pds = new Client({ handler: oauthAgent }); 72 + await pds.post("com.atproto.repo.putRecord", { 73 + input: { 74 + repo: oauthAgent.sub, 75 + collection: "sh.tangled.repo", 76 + rkey: value.name, 77 + record, 78 + }, 56 79 }); 57 80 }, 58 81 })); ··· 67 90 <Button 68 91 variant="none" 69 92 class="hover:text-green-300 hover:transition-colors" 93 + disabled={!agent()} 70 94 {...p} 71 95 > 72 96 mirror a new repo
+1
app/src/env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 + /// <reference types="@atcute/atproto" /> 2 3 /// <reference types="@atcute/bluesky" /> 3 4 /// <reference types="@atcute/tangled" />
+13 -1
crates/knotty-knot/src/main.rs
··· 1 1 mod error; 2 2 mod keypair; 3 + mod repodid; 3 4 mod router; 4 5 mod state; 5 6 mod xrpc; ··· 42 43 /// Path to the secp256k1 signing key file (loaded on startup, must exist) 43 44 #[arg(long, env = "KNOT_KEY_PATH")] 44 45 pub key_path: PathBuf, 46 + 47 + /// PLC directory URL for did:plc genesis submission 48 + #[arg(long, env = "KNOT_PLC_URL", default_value = "https://plc.directory")] 49 + pub plc_url: String, 45 50 } 46 51 47 52 #[derive(Clone, Debug, Args)] ··· 85 90 86 91 let storage_path = Path::new(&args.repo_path); 87 92 88 - let state = KnotState::new(&did, &keypair, &owner, KNOT_HOSTNAME, storage_path); 93 + let state = KnotState::new( 94 + &did, 95 + &keypair, 96 + &owner, 97 + KNOT_HOSTNAME, 98 + storage_path, 99 + &args.plc_url, 100 + ); 89 101 90 102 tracing::info!( 91 103 hostname = %state.hostname,
+2 -3
crates/knotty-knot/src/repodid.rs
··· 43 43 } 44 44 45 45 impl PreparedDid { 46 - /// Persist the signing key to `{storage_path}/{did}.key`. 47 - pub fn store_key(&self, storage_path: &std::path::Path) -> miette::Result<()> { 48 - let path = storage_path.join(format!("{}.key", self.did)); 46 + /// Persist the signing key to a file. 47 + pub fn store_key<C: AsRef<std::path::Path>>(&self, path: C) -> miette::Result<()> { 49 48 std::fs::write(path, self.signing_key.to_bytes()) 50 49 .into_diagnostic() 51 50 .wrap_err("writing signing key to disk")?;
+5
crates/knotty-knot/src/state.rs
··· 16 16 pub owner: Did<'static>, 17 17 pub hostname: &'static str, 18 18 pub storage_path: &'static Path, 19 + pub plc_url: &'static str, 20 + pub http_client: reqwest::Client, 19 21 } 20 22 21 23 impl KnotState { ··· 25 27 owner: &Did<'_>, 26 28 hostname: &str, 27 29 storage_path: &Path, 30 + plc_url: &str, 28 31 ) -> Self { 29 32 let service_url = format!("https://{}", did); 30 33 let did_doc = build_did_doc(&did, &service_url, &keypair.public_key_multibase()); ··· 35 38 did_doc, 36 39 hostname: hostname.to_owned().leak(), 37 40 storage_path: Box::leak(Box::new(storage_path.to_path_buf())), 41 + plc_url: plc_url.to_owned().leak(), 42 + http_client: reqwest::Client::new(), 38 43 } 39 44 } 40 45 }
+81 -9
crates/knotty-knot/src/xrpc/knotty/create.rs
··· 1 - use axum::extract::State; 2 - use jacquard_axum::{ExtractXrpc, service_auth::ExtractServiceAuth}; 3 - use knotty_lexicons::dev_drawbu::knotty::create::CreateRequest; 1 + use std::process::{Command, Stdio}; 4 2 5 - use crate::{error::KnotError, state::KnotState}; 3 + use axum::{Json, extract::State}; 4 + use jacquard::IntoStatic; 5 + use jacquard::types::string::Did; 6 + use jacquard_axum::{ExtractXrpc, service_auth::ExtractServiceAuth}; 7 + use knotty_lexicons::dev_drawbu::knotty::create::{CreateOutput, CreateRequest}; 8 + 9 + use crate::{error::KnotError, repodid, state::KnotState}; 6 10 7 11 #[axum::debug_handler(state = KnotState)] 8 12 pub async fn handler( 9 - State(_state): State<KnotState>, 10 - ExtractServiceAuth(_auth): ExtractServiceAuth, 11 - ExtractXrpc(_req): ExtractXrpc<CreateRequest>, 12 - ) -> Result<(), KnotError> { 13 - Err(KnotError::not_implemented()) 13 + State(state): State<KnotState>, 14 + ExtractServiceAuth(auth): ExtractServiceAuth, 15 + ExtractXrpc(req): ExtractXrpc<CreateRequest>, 16 + ) -> Result<Json<CreateOutput<'static>>, KnotError> { 17 + let actor_did = auth.did(); 18 + tracing::info!(actor = %actor_did, name = %req.name, "creating mirror repo"); 19 + 20 + if req.name.is_empty() { 21 + return Err(KnotError::invalid_request("name is required")); 22 + } 23 + if req.source.is_empty() { 24 + return Err(KnotError::invalid_request("source is required")); 25 + } 26 + 27 + let knot_service_url = format!("https://{}", state.hostname); 28 + 29 + let prepared = repodid::prepare(&knot_service_url).map_err(|e| { 30 + tracing::error!(error = %e, "failed to prepare repo DID"); 31 + KnotError::internal(e) 32 + })?; 33 + 34 + let repo_did = Did::new_owned(&prepared.did).map_err(|e| { 35 + tracing::error!(error = %e, did = %prepared.did, "failed to derive did"); 36 + KnotError::internal(format!("derived invalid did {e}")) 37 + })?; 38 + 39 + tracing::info!(did = %prepared.did, "prepared repo DID"); 40 + 41 + prepared 42 + .submit(state.plc_url, &state.http_client) 43 + .await 44 + .map_err(|e| { 45 + tracing::error!(error = %e, did = %prepared.did, "failed to submit genesis op to PLC"); 46 + KnotError::internal(e) 47 + })?; 48 + 49 + tracing::info!(did = %prepared.did, actor = %actor_did, "repo DID registered"); 50 + 51 + std::fs::DirBuilder::new() 52 + .recursive(true) 53 + .create(&state.storage_path) 54 + .map_err(|e| { 55 + tracing::error!(path = %state.storage_path.to_string_lossy(), "failed to create storage directory"); 56 + KnotError::internal(e) 57 + })?; 58 + 59 + let path = state.storage_path.join(&repo_did.to_string()); 60 + 61 + Command::new("git") 62 + .args(&["init", "--bare"]) 63 + .arg(&path) 64 + .env_clear() 65 + .env("GIT_CONFIG_NOSYSTEM", "true") 66 + .env("GIT_CONFIG_GLOBAL", "/dev/null") 67 + .env("PATH", std::env::var("PATH").expect("PATH is not set")) 68 + .stdin(Stdio::piped()) 69 + .stdout(Stdio::piped()) 70 + .stderr(Stdio::piped()) 71 + .status() 72 + .map_err(|e| { 73 + tracing::error!(error = %e, path = %path.to_string_lossy(), "failed to run git init"); 74 + KnotError::internal(e) 75 + })?; 76 + 77 + prepared.store_key(path.join("signing.key")).map_err(|e| { 78 + tracing::error!(did = %prepared.did, error = %e, "failed to store repo signing key"); 79 + KnotError::internal(e) 80 + })?; 81 + 82 + Ok(Json(CreateOutput { 83 + repo_did: repo_did.into_static(), 84 + extra_data: Default::default(), 85 + })) 14 86 }