chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Clone help, Atmosphere wording, and a local --private server
The clone box links to the helper setup. knotless-serve --private runs as the logged-in account on loopback only, reads private repositories from their spaces, and lists your own private repositories on your profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 4de1da8 commit 9a17950
Showing 11 changed files with 247 additions and 26 deletions
README.md+8−0
| @@ -116,6 +116,14 @@ so the conversation stays as private as the code. Spaces are in the | ||
reference PDS's permissioned-data alpha; on a PDS without them, every | ||
repository is public. | ||
| ||
A public `knotless-serve` never shows a private repository. To browse one, | ||
run your own as whoever is logged in; it listens on this machine only, and | ||
your own profile lists your private repositories: | ||
| ||
```sh | ||
knotless-serve --private # then http://127.0.0.1:2590/alice.test/secret | ||
``` | ||
| ||
## Collaborating | ||
| ||
Only an account can write to its own repo, so collaborators push through a | ||
crates/knotless-mcp/tests/mcp.rs+1−0
| @@ -291,6 +291,7 @@ fn appview(rt: &tokio::runtime::Runtime) -> (String, Sandbox) { | ||
std::time::Duration::ZERO, | ||
Some(gateway), | ||
Some(index), | ||
false, | ||
); | ||
let router = knotless_serve::router(app.clone()); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
crates/knotless-serve/src/lib.rs+37−6
| @@ -35,6 +35,7 @@ use axum::routing::{get, post}; | ||
use knotless::env::Ctx; | ||
use knotless::git::Git; | ||
use knotless::mirror::Mirror; | ||
use knotless::records::RepoRecord; | ||
use knotless::remote::Remote; | ||
use knotless::url::RepoUrl; | ||
use std::collections::HashMap; | ||
| @@ -55,6 +56,9 @@ pub struct App { | ||
pub gateway: Option<gateway::Gateway>, | ||
/// Set when this server is an appview, indexing what the firehose says. | ||
pub index: Option<Arc<index::Index>>, | ||
/// Set when this server also reads private repositories, as whoever is | ||
/// logged in: for that person alone, on their own machine. | ||
pub private: bool, | ||
/// The labels this server gives out, to those following them. | ||
pub labels: tokio::sync::broadcast::Sender<(i64, serde_json::Value)>, | ||
} | ||
| @@ -67,6 +71,8 @@ struct Entry { | ||
/// The repository's owner, key and name now, which differ from what | ||
/// was asked for after a move or a rename. | ||
found: Option<(String, String, String)>, | ||
record: RepoRecord, | ||
space: bool, | ||
synced: Option<Instant>, | ||
events: u64, | ||
} | ||
| @@ -80,11 +86,15 @@ pub struct Located { | ||
pub name: String, | ||
/// Whether it is in another account than the one asked for: it moved. | ||
pub moved: bool, | ||
/// Its record, as the PDS had it when the mirror last synced. | ||
pub record: RepoRecord, | ||
/// Whether it is private, in a space. | ||
pub space: bool, | ||
} | ||
| ||
impl App { | ||
pub fn new(ctx: Ctx, ttl: Duration) -> Arc<App> { | ||
App::build(ctx, ttl, None, None) | ||
App::build(ctx, ttl, None, None, false) | ||
} | ||
| ||
pub fn build( | ||
| @@ -92,6 +102,7 @@ impl App { | ||
ttl: Duration, | ||
gateway: Option<gateway::Gateway>, | ||
index: Option<index::Index>, | ||
private: bool, | ||
) -> Arc<App> { | ||
Arc::new(App { | ||
ctx, | ||
| @@ -101,6 +112,7 @@ impl App { | ||
watching: AtomicBool::new(false), | ||
gateway, | ||
index: index.map(Arc::new), | ||
private, | ||
labels: tokio::sync::broadcast::channel(256).0, | ||
}) | ||
} | ||
| @@ -183,9 +195,12 @@ impl App { | ||
pub async fn located(&self, authority: &str, name: &str) -> Result<Located, AppError> { | ||
let url = RepoUrl::parse(&format!("knotless://{authority}/{name}")) | ||
.map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?; | ||
let remote = Remote::connect(&self.ctx, &url) | ||
.await | ||
.map_err(AppError::upstream)?; | ||
let remote = if self.private { | ||
Remote::open(&self.ctx, &url).await | ||
} else { | ||
Remote::connect(&self.ctx, &url).await | ||
} | ||
.map_err(AppError::upstream)?; | ||
let did = remote.did().to_string(); | ||
let entry = self | ||
.repos | ||
| @@ -225,6 +240,8 @@ impl App { | ||
.await | ||
.map_err(AppError::upstream)?; | ||
entry.found = Some((remote.did().to_string(), key.to_string(), repo.rkey.clone())); | ||
entry.record = repo.value.clone(); | ||
entry.space = remote.space.is_some(); | ||
entry.git = Some(mirror.git); | ||
entry.synced = Some(Instant::now()); | ||
entry.events = events; | ||
| @@ -248,6 +265,8 @@ impl App { | ||
did: now, | ||
key, | ||
name, | ||
record: entry.record.clone(), | ||
space: entry.space, | ||
}) | ||
} | ||
} | ||
| @@ -442,6 +461,8 @@ pub struct Options { | ||
pub backfill: Option<String>, | ||
/// Labelers, by DID, whose takedowns hold here too. | ||
pub labelers: Vec<String>, | ||
/// Read private repositories too, as whoever is logged in. | ||
pub private: bool, | ||
} | ||
| ||
pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
| @@ -454,6 +475,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
index: None, | ||
backfill: None, | ||
labelers: Vec::new(), | ||
private: false, | ||
}; | ||
let mut it = args.iter().skip(1); | ||
while let Some(flag) = it.next() { | ||
| @@ -473,9 +495,10 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--index" => opts.index = Some(value()?.into()), | ||
"--backfill" => opts.backfill = Some(value()?), | ||
"--labeler" => opts.labelers.push(value()?), | ||
"--private" => opts.private = true, | ||
other => { | ||
return Err(format!( | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]..." | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]... [--private]" | ||
)); | ||
} | ||
} | ||
| @@ -485,6 +508,14 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
| ||
/// Serves until SIGTERM or SIGINT. Prints the bound address first. | ||
pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
// Its pages show private repositories to anyone who reaches them. | ||
if opts.private | ||
&& tokio::net::lookup_host(&opts.listen) | ||
.await? | ||
.any(|a| !a.ip().is_loopback()) | ||
{ | ||
anyhow::bail!("--private listens only on this machine: give --listen a loopback address"); | ||
} | ||
let gateway = match &opts.public_url { | ||
Some(url) => Some(gateway::Gateway::new(url).map_err(anyhow::Error::msg)?), | ||
None => None, | ||
| @@ -493,7 +524,7 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
Some(path) => Some(index::Index::open(path)?), | ||
None => None, | ||
}; | ||
let app = App::build(ctx, opts.ttl, gateway, index); | ||
let app = App::build(ctx, opts.ttl, gateway, index, opts.private); | ||
if let (Some(service), Some(index)) = (opts.backfill, app.index.clone()) { | ||
let ctx = app.ctx.clone(); | ||
tokio::spawn(async move { eprintln!("{}", backfilled(&ctx, &index, &service).await) }); | ||
crates/knotless-serve/src/web.rs+56−15
| @@ -22,6 +22,7 @@ use knotless::git::Git; | ||
use knotless::read; | ||
use knotless::records::{RepoRecord, RepoRef}; | ||
use knotless::remote::Remote; | ||
use knotless::session; | ||
use knotless::url::RepoUrl; | ||
use serde::Deserialize; | ||
use sha2::{Digest, Sha256}; | ||
| @@ -159,6 +160,10 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px} | ||
.repohead h1 b{font-weight:650} | ||
.repohead .about{flex:1;min-width:16rem} | ||
.repohead .about p{color:var(--mute);margin-top:.35rem} | ||
.get{max-width:100%} | ||
.get p{color:var(--mute);font-size:12.5px;margin-top:.35rem;text-align:right} | ||
.get a{color:var(--accent)} | ||
.get a:hover{text-decoration:underline} | ||
.clone{display:flex;align-items:center;gap:.5rem;border:1px solid var(--line);border-radius:8px;padding:.3rem .3rem .3rem .75rem;max-width:100%;background:var(--panel)} | ||
.clone code{overflow-x:auto;white-space:nowrap;color:var(--mute)} | ||
.tabs{display:flex;gap:.25rem;border-bottom:1px solid var(--line);margin:1.25rem 0 1.25rem;view-transition-name:tabs;overflow-x:auto;scrollbar-width:none} | ||
| @@ -258,7 +263,8 @@ main.wrap{padding-top:1.25rem} | ||
.hero p{font-size:15px} | ||
.top nav{gap:1rem} | ||
.tabs a{padding:.55rem .5rem;gap:.35rem} | ||
.clone{width:100%} | ||
.get,.clone{width:100%} | ||
.get p{text-align:left} | ||
.search{max-width:none;min-width:100%} | ||
.prose{padding:1rem 1.1rem 1.25rem} | ||
.list li{align-items:flex-start} | ||
| @@ -412,7 +418,7 @@ fn command(text: &str) -> String { | ||
| ||
fn layout(title: &str, body: &str) -> String { | ||
format!( | ||
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in atproto accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#, | ||
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in Atmosphere accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#, | ||
esc(title) | ||
) | ||
} | ||
| @@ -540,6 +546,9 @@ struct Repo { | ||
git: Git, | ||
/// The default branch. | ||
head: String, | ||
/// Its record as the PDS has it, for a private repository the index | ||
/// never sees. | ||
space: Option<RepoRecord>, | ||
} | ||
| ||
impl Repo { | ||
| @@ -565,6 +574,7 @@ impl Repo { | ||
key: found.key, | ||
git, | ||
head, | ||
space: found.space.then_some(found.record), | ||
}) | ||
} | ||
| ||
| @@ -586,15 +596,18 @@ impl Repo { | ||
forge::repo_uri(&self.did, &self.name) | ||
} | ||
| ||
/// The record, as the index has it. | ||
/// The record, as the index has it, or the space for a private one. | ||
fn record(&self, app: &App) -> Option<RepoRecord> { | ||
if self.space.is_some() { | ||
return self.space.clone(); | ||
} | ||
let value = app.index.as_ref()?.repo(&self.uri()).ok()??; | ||
serde_json::from_value(value).ok() | ||
} | ||
| ||
/// Its open pulls and issues, as the index knows them. | ||
fn open_items(&self, app: &App) -> Option<(usize, usize)> { | ||
let entries = app.index.as_ref()?.forge(&self.did, &self.key).ok()?; | ||
/// Its open pulls and issues. | ||
async fn open_items(&self, app: &App) -> Option<(usize, usize)> { | ||
let entries = assembled(app, self).await.ok()?; | ||
let record = self.record(app).unwrap_or_default(); | ||
let items = forge::assemble(&record, &self.did, &entries); | ||
let open = |kind: &str| { | ||
| @@ -633,12 +646,12 @@ impl Repo { | ||
let clone = format!("git clone knotless://{}/{}", self.authority, self.name); | ||
write!( | ||
out, | ||
r#"</div><div class="clone"><code>{}</code>{}</div></div><nav class="tabs">"#, | ||
r#"</div><div class="get"><div class="clone"><code>{}</code>{}</div><p>Needs the knotless git helper. <a href="/#connect">Set it up</a></p></div></div><nav class="tabs">"#, | ||
esc(&clone), | ||
copy(&clone) | ||
) | ||
.unwrap(); | ||
let (pulls, issues) = self.open_items(app).unwrap_or_default(); | ||
let (pulls, issues) = self.open_items(app).await.unwrap_or_default(); | ||
let count = |n: usize| { | ||
if n > 0 { | ||
format!(r#"<span class="count">{n}</span>"#) | ||
| @@ -760,7 +773,7 @@ async fn home( | ||
.unwrap_or("localhost"); | ||
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#; | ||
let mut body = format!( | ||
r#"<section class="hero"><h1>Git that lives in your atproto account.</h1><p>Each repository is a few records on your own PDS: no git server to run, nothing to install on the PDS. Clone and push with plain git, and let agents read, commit, review and merge over MCP.</p></section> | ||
r#"<section class="hero"><h1>Git that lives in your Atmosphere account.</h1><p>Each repository is a few records on your own PDS: no git server to run, nothing to install on the PDS. Clone and push with plain git, and let agents read, commit, review and merge over MCP.</p></section> | ||
<section class="steps" id="connect"> | ||
<div class="step"><h3><b>1</b>Install and log in</h3><p>From a checkout of knotless. Use an app password.</p>{}{}</div> | ||
<div class="step"><h3><b>2</b>Push</h3><p>Any repository, to any account on any PDS.</p>{}{}</div> | ||
| @@ -811,7 +824,8 @@ async fn home( | ||
} | ||
| ||
/// An account: who it is, as its Bluesky profile says if it has one, and | ||
/// its public repositories, read from its PDS. | ||
/// its public repositories, read from its PDS. Serving `--private`, the | ||
/// account logged in sees its private ones too. | ||
async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) -> Result<Response> { | ||
let at = RepoUrl { | ||
authority: authority.clone(), | ||
| @@ -831,6 +845,17 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) -> | ||
.as_str() | ||
.filter(|n| !n.trim().is_empty()); | ||
let mut repos = remote.list_repos().await.map_err(AppError::upstream)?; | ||
let mut private = Vec::new(); | ||
if app.private | ||
&& let Ok(me) = session::me(&app.ctx).await | ||
&& me.did == did | ||
{ | ||
let mine = Remote::private_repos(&app.ctx, &me) | ||
.await | ||
.map_err(AppError::upstream)?; | ||
private = mine.iter().map(|r| r.rkey.clone()).collect(); | ||
repos.extend(mine); | ||
} | ||
repos.sort_by(|a, b| b.value.created_at.cmp(&a.value.created_at)); | ||
let mut body = format!( | ||
r#"<section class="profile">{}<div><h1>{}</h1>"#, | ||
| @@ -866,9 +891,14 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) -> | ||
esc(&from) | ||
) | ||
}); | ||
let tag = if private.contains(&r.rkey) { | ||
r#" <span class="tag">private</span>"# | ||
} else { | ||
"" | ||
}; | ||
write!( | ||
body, | ||
r#"<li>{}<div class="grow"><a class="title" href="{href}">{}</a><div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#, | ||
r#"<li>{}<div class="grow"><a class="title" href="{href}">{}</a>{tag}<div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#, | ||
svg(icon::REPO), | ||
esc(&r.value.name), | ||
esc(r.value.description.as_deref().unwrap_or("No description")), | ||
| @@ -1455,8 +1485,19 @@ async fn issues( | ||
items(app, authority, name, "issue", q).await | ||
} | ||
| ||
/// Everything the index knows about the repository's pulls and issues. | ||
fn assembled(app: &App, repo: &Repo) -> Result<Vec<forge::Entry>> { | ||
/// Everything known about the repository's pulls and issues: from the | ||
/// index, or for a private one from its members' repos in the space. | ||
async fn assembled(app: &App, repo: &Repo) -> Result<Vec<forge::Entry>> { | ||
if repo.space.is_some() { | ||
let url = RepoUrl { | ||
authority: repo.did.clone(), | ||
name: repo.name.clone(), | ||
}; | ||
let target = forge::Target::open(&app.ctx, &url) | ||
.await | ||
.map_err(AppError::upstream)?; | ||
return Ok(target.entries(&app.ctx).await.map_err(AppError::upstream)?); | ||
} | ||
let index = app.index.as_ref().ok_or(Shown( | ||
StatusCode::NOT_FOUND, | ||
"This server keeps no index, so it knows no pulls or issues.".into(), | ||
| @@ -1493,7 +1534,7 @@ async fn items( | ||
q: StateQuery, | ||
) -> Result<Response> { | ||
let repo = Repo::open(&app, &authority, &name).await?; | ||
let entries = assembled(&app, &repo)?; | ||
let entries = assembled(&app, &repo).await?; | ||
let record = repo.record(&app).unwrap_or_default(); | ||
let all: Vec<Item> = forge::assemble(&record, &repo.did, &entries) | ||
.into_iter() | ||
| @@ -1578,7 +1619,7 @@ async fn item( | ||
)); | ||
} | ||
}; | ||
let entries = assembled(&app, &repo)?; | ||
let entries = assembled(&app, &repo).await?; | ||
let record = repo.record(&app).unwrap_or_default(); | ||
let uri = format!("at://{did}/{collection}/{rkey}"); | ||
let Thread { item, comments } = | ||
crates/knotless-serve/tests/forge.rs+1−1
| @@ -35,7 +35,7 @@ fn appview() -> Appview { | ||
let gw = Gateway::new(&base).unwrap(); | ||
let did = gw.did.clone(); | ||
let index = Index::open(&s.path("index.db")).unwrap(); | ||
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index)); | ||
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index), false); | ||
let router = router(app.clone()); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
let ws = net().pds.replace("http://", "ws://"); | ||
crates/knotless-serve/tests/gateway.rs+1−1
| @@ -34,7 +34,7 @@ fn gateway() -> Server { | ||
let base = format!("http://localhost:{}", listener.local_addr().unwrap().port()); | ||
let gw = Gateway::new(&base).unwrap(); | ||
let did = gw.did.clone(); | ||
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), None); | ||
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), None, false); | ||
let router = router(app.clone()); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
Server { | ||
crates/knotless-serve/tests/jetstream.rs+1−0
| @@ -112,6 +112,7 @@ fn the_index_fills_from_jetstream() { | ||
Duration::ZERO, | ||
None, | ||
Some(index), | ||
false, | ||
); | ||
rt.spawn(knotless_serve::jetstream::watch( | ||
app.clone(), | ||
crates/knotless-serve/tests/serve.rs+11−0
| @@ -275,12 +275,21 @@ fn binary_lifecycle() { | ||
.output() | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
// Private repositories are served to this machine alone. | ||
let out = s | ||
.command(exe, s.dir.path()) | ||
.args(["--private", "--listen", "0.0.0.0:0"]) | ||
.output() | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("loopback")); | ||
| ||
let mut child = s | ||
.command(exe, s.dir.path()) | ||
.args([ | ||
"--listen", | ||
"127.0.0.1:0", | ||
"--private", | ||
"--ttl", | ||
"1", | ||
"--firehose", | ||
| @@ -353,6 +362,8 @@ fn arguments() { | ||
Some("wss://jetstream.us-east.bsky.network") | ||
); | ||
assert_eq!(o.public_url, None); | ||
assert!(!o.private); | ||
assert!(args(&["--private"]).unwrap().private); | ||
let o = args(&["--public-url", "https://knot.example"]).unwrap(); | ||
assert_eq!(o.public_url.as_deref(), Some("https://knot.example")); | ||
let o = args(&["--labeler", "did:web:a", "--labeler", "did:web:b"]).unwrap(); | ||
crates/knotless-serve/tests/web.rs+65−3
| @@ -17,14 +17,18 @@ struct Site { | ||
} | ||
| ||
fn serve(indexed: bool) -> Site { | ||
let s = Sandbox::new(); | ||
serve_as(Sandbox::new(), indexed, false) | ||
} | ||
| ||
/// A server reading as `s`, private repositories too if `private`. | ||
fn serve_as(s: Sandbox, indexed: bool, private: bool) -> Site { | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let listener = rt | ||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) | ||
.unwrap(); | ||
let base = format!("http://{}", listener.local_addr().unwrap()); | ||
let index = indexed.then(|| Index::open(&s.path("index.db")).unwrap()); | ||
let app: Arc<App> = App::build(ctx(&s), Duration::ZERO, None, index); | ||
let app: Arc<App> = App::build(ctx(&s), Duration::ZERO, None, index, private); | ||
let router = router(app.clone()); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
if indexed { | ||
| @@ -141,6 +145,7 @@ fn browsing_a_repository() { | ||
">docs</a>", | ||
"Lay out the tree</a>", | ||
"git clone knotless://", | ||
r#"<a href="/#connect">Set it up</a>"#, | ||
"<h1>Browsed</h1>", | ||
"<script>alert(1)</script>", | ||
&format!(r#"href="{repo}/blob/main/docs/guide.md""#), | ||
| @@ -487,10 +492,67 @@ fn pulls_and_issues() { | ||
// Without an index, there is no knowing. | ||
let bare = serve(false); | ||
let front = bare.page("/"); | ||
assert!(front.contains("Git that lives in your atproto account.")); | ||
assert!(front.contains("Git that lives in your Atmosphere account.")); | ||
assert!(front.contains("claude mcp add knotless -- knotless-mcp")); | ||
assert!(!front.contains("Find a repository")); | ||
let (status, body) = bare.status(&format!("{repo}/pulls")); | ||
assert_eq!(status, 404); | ||
assert!(body.contains("keeps no index")); | ||
} | ||
| ||
#[test] | ||
fn private_repositories_on_this_machine() { | ||
let public = serve(false); | ||
if !net().spaces { | ||
// A PDS without spaces holds no private repositories to list. | ||
let s = person(account(), &public); | ||
let profile = format!("/{}", s.account.handle); | ||
let listed = serve_as(s, false, true).page(&profile); | ||
assert!(listed.contains("No public repositories yet."), "{listed}"); | ||
return; | ||
} | ||
let owner = person(account(), &public); | ||
let reader = person(account(), &public); | ||
let url = owner.url("hidden"); | ||
ok(&owner, &["create", &url, "--private"]); | ||
let work = owner.work("w", 1); | ||
owner.commit_file(&work, "notes.md", "# Notes\n"); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
ok(&owner, &["share", &url, &reader.account.handle]); | ||
let issue = ok(&reader, &["issue", &url, "Private trouble"]); | ||
let repo = format!("/{}/hidden", owner.account.handle); | ||
| ||
// A member's own server shows it, pulls and issues read from the space. | ||
let local = serve_as(reader, false, true); | ||
let page = local.page(&repo); | ||
assert!(page.contains(">notes.md</a>"), "{page}"); | ||
let issues = local.page(&format!("{repo}/issues")); | ||
assert!(issues.contains("Private trouble"), "{issues}"); | ||
assert!( | ||
issues.contains(r#"Issues<span class="count">1</span>"#), | ||
"{issues}" | ||
); | ||
let (did, _, rkey) = knotless::forge::parse_uri(&issue).unwrap(); | ||
assert!( | ||
local | ||
.page(&format!("{repo}/issue/{did}/{rkey}")) | ||
.contains("Private trouble") | ||
); | ||
| ||
// The owner's own server lists it on their profile; others' do not. | ||
let profile = format!("/{}", owner.account.handle); | ||
let mine = serve_as(owner, false, true); | ||
let listed = mine.page(&profile); | ||
assert!( | ||
listed.contains(r#">hidden</a> <span class="tag">private</span>"#), | ||
"{listed}" | ||
); | ||
assert!(!local.page(&profile).contains(">hidden</a>")); | ||
assert!(!public.page(&profile).contains(">hidden</a>")); | ||
| ||
// A server that reads public repositories only, or reads as an outsider, | ||
// finds nothing there. | ||
assert_ne!(public.status(&repo).0, 200); | ||
let outsider = person(account(), &public); | ||
assert_ne!(serve_as(outsider, false, true).status(&repo).0, 200); | ||
} | ||
crates/knotless/src/remote.rs+57−0
| @@ -511,6 +511,63 @@ impl Remote { | ||
self.list_all(nsid::REPO).await | ||
} | ||
| ||
/// `me`'s private repositories: their repo records in each space of | ||
/// theirs. A space a rename left holds only a redirect, so none. None at | ||
/// all on a PDS without spaces. | ||
pub async fn private_repos( | ||
ctx: &Ctx, | ||
me: &session::Session, | ||
) -> Result<Vec<Stored<RepoRecord>>> { | ||
#[derive(Deserialize)] | ||
struct Out { | ||
spaces: Vec<View>, | ||
cursor: Option<String>, | ||
} | ||
#[derive(Deserialize)] | ||
struct View { | ||
uri: String, | ||
} | ||
let identity = identity::resolve(ctx, &me.did).await?; | ||
let xrpc = Xrpc::new(ctx.http.clone(), &identity.pds); | ||
let mut all = Vec::new(); | ||
// An empty cursor is the first page. | ||
let mut cursor = Some(String::new()); | ||
while let Some(at) = cursor { | ||
let params = [ | ||
("spaceType", space::SPACE_TYPE), | ||
("did", me.did.as_str()), | ||
("limit", "100"), | ||
("cursor", at.as_str()), | ||
]; | ||
let out: Out = match xrpc | ||
.query( | ||
"com.atproto.space.listSpaces", | ||
¶ms, | ||
Some(&me.access_jwt), | ||
) | ||
.await | ||
{ | ||
Err(err) if space::refused(&err) => return Ok(all), | ||
out => out?, | ||
}; | ||
for view in &out.spaces { | ||
let name = view.uri.rsplit('/').next().unwrap_or_default(); | ||
let home = Remote { | ||
xrpc: xrpc.clone(), | ||
identity: identity.clone(), | ||
url: RepoUrl { | ||
authority: me.did.clone(), | ||
name: name.to_string(), | ||
}, | ||
space: Some(Space::owner(&view.uri, me)), | ||
}; | ||
all.extend(home.list_repos().await?); | ||
} | ||
cursor = out.cursor; | ||
} | ||
Ok(all) | ||
} | ||
| ||
/// Every record in `collection`, from the public repo or the space. | ||
pub async fn list_all<T: DeserializeOwned>(&self, collection: &str) -> Result<Vec<Stored<T>>> { | ||
let mut all = Vec::new(); | ||
docs/design.md+9−0
| @@ -180,6 +180,15 @@ space only if their writes are allowed, and members write their issues and | ||
comments there. Pushing is a separate grant, the `collaborators` of the | ||
`repo` record. Deleting a private repository deletes the space. | ||
| ||
`knotless-serve` reads public records only, so what it shows anyone may | ||
see. With `--private` it looks in spaces too, as whoever is logged in. It | ||
reads a private repository's pulls and issues from its members' repos in | ||
the space, and lists the account's own private repositories on its profile | ||
(`com.atproto.space.listSpaces`, which names only spaces the account has | ||
written to, so not others' it may read). It then refuses to listen anywhere | ||
but a loopback address, since its pages would show that person's private | ||
repositories to whoever reached them. | ||
| ||
## Gateway | ||
| ||
Only an account can write to its own repo, so a collaborator cannot push | ||