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
OAuth logins ask for what knotless does, not transition:generic
The loopback client's scope names each dev.knotless record it writes, blobs of packs, and the service calls it makes to gateways and moderation services, built from the same constants those use, plus the space scope as before. Tests log in with OAuth and do each kind of write, a delegation, a push and a commit through a gateway, and a report, and find a record of another kind refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 127 additions and 14 deletions
crates/knotless-serve/tests/forge.rs+4−1
| @@ -739,7 +739,10 @@ fn moderating() { | ||
ok(&owner, &["unblock", &troll.account.handle, &url]); | ||
listed(&reporter, &url, trolling); | ||
| ||
// Reports go to the appview's moderation service. | ||
// Reports go to the appview's moderation service, from an OAuth login | ||
// too, whose scope names the call. | ||
let handle = reporter.account.handle.clone(); | ||
assert_eq!(reporter.cli(&["login", &handle], b"").0, 0); | ||
let sent = ok( | ||
&reporter, | ||
&["report", trolling, "spam", "-m", "the same thing again"], | ||
crates/knotless-serve/tests/gateway.rs+40−0
| @@ -102,6 +102,46 @@ fn post(url: &str, token: Option<&str>, body: Vec<u8>, json: bool) -> (u16, Stri | ||
(res.status().as_u16(), res.text().unwrap()) | ||
} | ||
| ||
/// `person`, logged in with OAuth rather than a password: scoped to what | ||
/// knotless asks for. | ||
fn oauth_person(account: Account) -> Sandbox { | ||
let mut s = web(Sandbox::with(account)); | ||
s.set("KNOTLESS_PASSWORD", ""); | ||
s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), "")); | ||
let handle = s.account.handle.clone(); | ||
assert_eq!(s.cli(&["login", &handle], b"").0, 0); | ||
s | ||
} | ||
| ||
/// What an OAuth login calls a gateway for, its scope allows: delegating | ||
/// to it, and pushing and committing through it. | ||
#[test] | ||
fn oauth_logins_reach_a_gateway() { | ||
let gw = gateway(); | ||
let owner = oauth_person(account()); | ||
let collab = oauth_person(account()); | ||
let url = owner.url("scoped"); | ||
let work = owner.work("w", 2); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let (code, out) = owner.cli(&["delegate", &owner.account.handle, &gw.did], b""); | ||
assert_eq!(code, 0, "{out}"); | ||
let shared = owner.cli(&["share", &url, &collab.account.handle, "--write"], b""); | ||
assert_eq!(shared.0, 0, "{}", shared.1); | ||
let clone = collab.path("clone"); | ||
collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); | ||
let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n"); | ||
collab.git(&clone, &["push", "-q", "origin", "main"]); | ||
let (_, refs) = owner.cli(&["refs", &url], b""); | ||
assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}"); | ||
let committed = collab.cli( | ||
&[ | ||
"commit", &url, "-b", "main", "-m", "Scoped", "--put", "x", "-", | ||
], | ||
b"x\n", | ||
); | ||
assert_eq!(committed.0, 0, "{}", committed.1); | ||
} | ||
| ||
#[test] | ||
fn collaborators_push_and_commit_through_a_gateway() { | ||
let gw = gateway(); | ||
crates/knotless/src/oauth.rs+30−10
| @@ -8,9 +8,11 @@ use crate::env::Ctx; | ||
use crate::identity::{self, Identity}; | ||
use crate::keys; | ||
use crate::mirror::Lock; | ||
use crate::records::{PACK_MIME, nsid}; | ||
use crate::session::{self, OAuth, Session}; | ||
use crate::store; | ||
use crate::xrpc::Xrpc; | ||
use crate::{gateway, moderation}; | ||
use anyhow::{Context, Result, bail}; | ||
use base64::Engine; | ||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | ||
| @@ -21,19 +23,35 @@ use serde::de::DeserializeOwned; | ||
use serde_json::{Value, json}; | ||
use sha2::{Digest, Sha256}; | ||
use std::collections::{BTreeMap, HashMap}; | ||
use std::sync::Mutex; | ||
use std::sync::{LazyLock, Mutex}; | ||
use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
use tokio::net::TcpListener; | ||
| ||
const CALLBACK: &str = "/callback"; | ||
| ||
/// What knotless asks for: what an app password allows, and the spaces | ||
/// private repositories live in, which only a `space` scope reaches. A | ||
/// PDS without spaces drops the scope it does not know. The scope names | ||
/// every space type: naming [`crate::space::SPACE_TYPE`] alone takes its | ||
/// lexicon, which is not published yet. | ||
pub const SCOPE: &str = "atproto transition:generic \ | ||
space:*?authority=*&collection=*&manage=create&manage=update&manage=delete"; | ||
/// What knotless asks for, and nothing more: to write the records it keeps | ||
/// in an account, to upload the blobs that hold its packs, to call gateways | ||
/// and moderation services as the account, and the spaces private | ||
/// repositories live in. A PDS without spaces drops the scope it does not | ||
/// know. The space scope names every space type: naming | ||
/// [`crate::space::SPACE_TYPE`] alone takes its lexicon, which is not | ||
/// published yet. | ||
pub static SCOPE: LazyLock<String> = LazyLock::new(|| { | ||
let mut scope = vec!["atproto".to_string()]; | ||
let written = nsid::ALL.iter().chain(&nsid::FORGE).chain([&nsid::EMAILS]); | ||
scope.extend(written.map(|collection| format!("repo:{collection}"))); | ||
scope.push(format!("blob:{PACK_MIME}")); | ||
let called = [ | ||
gateway::PUSH, | ||
gateway::COMMIT, | ||
gateway::DELEGATE, | ||
gateway::AWAIT_DELEGATION, | ||
moderation::REPORT, | ||
]; | ||
scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*"))); | ||
scope.push("space:*?authority=*&collection=*&manage=create&manage=update&manage=delete".into()); | ||
scope.join(" ") | ||
}); | ||
| ||
/// The loopback client, which names its scope and redirect in its ID. The | ||
/// redirect leaves out the port, so any port the listener gets matches. | ||
| @@ -42,7 +60,7 @@ pub fn client_id() -> String { | ||
"http://localhost/", | ||
&[ | ||
("redirect_uri", format!("http://127.0.0.1{CALLBACK}")), | ||
("scope", SCOPE.to_string()), | ||
("scope", SCOPE.clone()), | ||
], | ||
) | ||
.expect("a static URL"); | ||
| @@ -451,7 +469,7 @@ pub async fn start(ctx: &Ctx, identifier: &str) -> Result<Pending> { | ||
id, | ||
identifier, | ||
&redirect_uri, | ||
SCOPE, | ||
&SCOPE, | ||
) | ||
.await?; | ||
Ok(Pending { | ||
| @@ -625,5 +643,7 @@ mod tests { | ||
"http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+" | ||
)); | ||
assert!(SCOPE.contains(" space:*?authority=*&")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.pull ")); | ||
assert!(!SCOPE.contains("transition:")); | ||
} | ||
} | ||
crates/knotless/src/records.rs+2−0
| @@ -25,6 +25,8 @@ pub mod nsid { | ||
/// what its owner hides of it and whom they keep out. A block names no | ||
/// repository when it keeps someone out of all of them. | ||
pub const FORGE: [&str; 6] = [PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK]; | ||
/// The email addresses an account's commits are authored as. | ||
pub const EMAILS: &str = "dev.knotless.emails"; | ||
} | ||
| ||
pub const PACK_MIME: &str = "application/x-git-packed-objects"; | ||
crates/knotless/tests/oauth.rs+41−0
| @@ -90,6 +90,47 @@ fn logs_in_pushes_refreshes_and_logs_out() { | ||
assert_eq!(s.cli(&["logout", &handle], b""), (0, "logged out".into())); | ||
} | ||
| ||
/// An OAuth login is scoped to what knotless writes: each kind of record | ||
/// it keeps goes through, and a record of any other kind is refused. | ||
#[test] | ||
fn an_oauth_login_writes_only_what_knotless_keeps() { | ||
let account = account(); | ||
let mut s = oauth_sandbox(account.clone(), &browser(&account, "")); | ||
s.set("KNOTLESS_INLINE_MAX", "0"); | ||
let handle = s.account.handle.clone(); | ||
assert_eq!(s.cli(&["login", &handle], b"").0, 0); | ||
let ok = |args: &[&str]| { | ||
let (code, out) = s.cli(args, b""); | ||
assert_eq!(code, 0, "{args:?}: {out}"); | ||
out.trim().to_string() | ||
}; | ||
let url = s.url("scoped"); | ||
let work = s.work("w", 2); | ||
s.git(&work, &["push", "-q", &url, "main"]); | ||
s.git(&work, &["push", "-q", &url, "main:topic"]); | ||
let issue = ok(&["issue", &url, "Scoped"]); | ||
ok(&["comment", &url, &issue, "Seen"]); | ||
ok(&["close", &url, &issue]); | ||
ok(&["hide", &url, &issue]); | ||
ok(&["pull", &url, "main", &url, "topic", "Topic"]); | ||
let other = common::account(); | ||
ok(&["block", &other.handle]); | ||
ok(&["rename", &url, "scoped2"]); | ||
ok(&["delete", &s.url("scoped2"), "--yes"]); | ||
| ||
let me = stored(&s); | ||
let ctx = Ctx::new(s.env()); | ||
let mine = knotless::url::RepoUrl::parse(&format!("knotless://{}/_", me.did)).unwrap(); | ||
let refused = rt().block_on(async { | ||
let remote = knotless::remote::Remote::connect(&ctx, &mine).await.unwrap(); | ||
let post = json!({"$type": "app.bsky.feed.post", "text": "hi", "createdAt": "2026-01-01T00:00:00Z"}); | ||
let write = knotless::remote::create("app.bsky.feed.post", "3kscoped", post); | ||
remote.apply(vec![write], &me.auth()).await | ||
}); | ||
let err = format!("{:#}", refused.unwrap_err()); | ||
assert!(err.contains("app.bsky.feed.post"), "{err}"); | ||
} | ||
| ||
#[test] | ||
fn refused_and_crossed_logins() { | ||
let account = account(); | ||
docs/design.md+10−3
| @@ -170,9 +170,16 @@ for want of the server's nonce is sent again with it. Refresh tokens are | ||
used once, so processes refresh under a lock file, and one that finds the | ||
session already refreshed takes it. Logging out revokes the tokens. | ||
| ||
The scope is `transition:generic`, what an app password allows, plus a | ||
`space` scope for private repositories, which `transition:generic` does | ||
not reach. It names every space type: naming `dev.knotless.space` alone | ||
The scope asks for what knotless does and nothing more, rather than | ||
`transition:generic`'s run of the whole account: `repo:` for each | ||
`dev.knotless.*` record it writes, `blob:` for packs, `rpc:` for the | ||
service tokens it gets to call gateways (`pushRefs`, `createCommit`, | ||
`putDelegation`, `getDelegation`) and moderation services | ||
(`createReport`), at any audience, since gateways are anyone's, and a | ||
`space` scope for private repositories. It is built from the same | ||
constants the records and calls use, so a new record kind is asked for | ||
as it is added; a session from before asks again at its next login. The | ||
space scope names every space type: naming `dev.knotless.space` alone | ||
makes the authorization server resolve its lexicon, which is not | ||
published. A PDS without spaces drops the scope it does not know. | ||