chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs 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>",
"&lt;script&gt;alert(1)&lt;/script&gt;",
&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",
&params,
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