diff --git a/docs/src/content/docs/architecture/data-flow.mdx b/docs/src/content/docs/architecture/data-flow.mdx index 84f52e46e..adaeed0bb 100644 --- a/docs/src/content/docs/architecture/data-flow.mdx +++ b/docs/src/content/docs/architecture/data-flow.mdx @@ -132,15 +132,14 @@ helper; you need one that mints the token through your PDS. Either way, you must be the repository's owner or a collaborator and not banned on the knot. After the knot accepts the pack it: -1. writes or updates one `sh.tangled.git.ref` record per changed ref, - carrying your DID and push options; -2. rewrites the `org.tangled.git.refs/self` snapshot; -3. emits a signed `org.tangled.git.pushEvent` frame on its firehose; -4. prints push messages: an acknowledgement, an "Open pull request" link +1. rewrites the `org.tangled.git.refs/self` snapshot, the one record + with every ref and the default branch; +2. emits a signed `org.tangled.git.pushEvent` frame on its firehose; +3. prints push messages: an acknowledgment, an "Open pull request" link for new branches, and workflow diagnostics. [Data Model](/architecture/data-model/#git-state-as-records) describes -the three outputs. +the record outputs. ## Reading @@ -185,7 +184,7 @@ succeeded. For deletions, web polls for up to five seconds. | Bobbin | Hydrant `/stream` | Every Tangled collection it indexes | Updates indexes, releases waiting `awaitRecord` calls | | Gitmirror | Hydrant `/stream` | `org.tangled.git.refs` | Fetches the repo from its knot | | Deliberi | Hydrant's Jetstream-style feed | Manifests, tickets, comments, stars, follows, @mentions | Notifications and email digests | -| Spindle | Each knot's firehose, directly | `sh.tangled.git.ref`, collaborator invites | Push pipelines, collaborator sync | +| Spindle | Each knot's firehose, directly | `org.tangled.git.refs`, collaborator invites | Push pipelines, collaborator sync | | Spindle | Its Jetstream and embedded hydrant | `sh.tangled.repo`, `spindle.member`, `repo.collaborator`, `repo.pull`, `pull.status` | Attaching repos, members, pull-request pipelines | [Security Model](/architecture/security/) covers what each of these diff --git a/docs/src/content/docs/knots/index.md b/docs/src/content/docs/knots/index.md index abc3763d2..e610f21ad 100644 --- a/docs/src/content/docs/knots/index.md +++ b/docs/src/content/docs/knots/index.md @@ -115,16 +115,15 @@ are no server-side hooks. | -------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | | [Web](/components/other-services/#web) | in | `org.tangled.repo.createRepo`, ACL procedures, `com.atproto.repo.*` record writes (aud = repo DID), `sh.tangled.repo.pull.merge` | | [Hydrant](/components/bobbin/#hydrant) | out | Follows `subscribeRepos` for `#commit`, `#identity` and `#account` frames | -| Spindle | out | Follows `subscribeRepos` for `sh.tangled.git.ref` record ops; clones over HTTPS | +| Spindle | out | Follows `subscribeRepos` for `org.tangled.git.refs` record ops; clones over HTTPS | | [Gitmirror](/components/gitmirror/) | in/out | Fetches packs; pushes merges and branch deletes with your forwarded token | | PLC directory | out | Submits genesis and update ops for repo DIDs, with retries | | User PDSes | out | Reads identities and SSH keys | -After every push, the knot writes one `sh.tangled.git.ref` record per -changed ref and updates `org.tangled.git.refs/self` with the full ref -set, then emits a knot-signed `org.tangled.git.pushEvent` frame (repo, -pusher, ref edits, push options) on the firehose. Each record written on -behalf of a user carries `x-tngl-editor`, the DID that actually acted. +After every push, the knot rewrites the single `org.tangled.git.refs` +record (rkey `self`) with a snapshot of every ref and the default +branch, then emits a knot-signed `org.tangled.git.pushEvent` frame +(repo, pusher, ref edits, push options) on the firehose. `org.tangled.knot.describeKnot` returns the knot's DID, version and capabilities: `tangled1.0`, `knot-acl`, `repo-did-input`, @@ -137,8 +136,8 @@ bitmaps and a geometric repack every six hours, with prune and reflog grace periods (`[maintenance]`). **Health:** `curl https:///xrpc/_health` returns the version, or -503 if the LFS store is unwritable. `/xrpc/sh.tangled.owner` returns the -first admin, and `/.well-known/did.json` the knot's DID document. +503 if the LFS store is unwritable. `/xrpc/org.tangled.knot.describeKnot` +describes the knot, and `/.well-known/did.json` the knot's DID document. **Admin CLI.** `knot-server admin` posts to the knot's own `https://` with Basic auth. It only works when diff --git a/docs/src/content/docs/knots/migrating-to-knot-2.md b/docs/src/content/docs/knots/migrating-to-knot-2.md index 1b03f96d1..9bafb8c64 100644 --- a/docs/src/content/docs/knots/migrating-to-knot-2.md +++ b/docs/src/content/docs/knots/migrating-to-knot-2.md @@ -346,12 +346,13 @@ apply unchanged, with `systemctl status knot-rs` in place of `knot`. ```sh curl -s https://knot.example.com/xrpc/_health -curl -s https://knot.example.com/xrpc/sh.tangled.owner +curl -s https://knot.example.com/xrpc/org.tangled.knot.describeKnot ssh-keyscan -t ed25519 knot.example.com ``` The health endpoint should return `{"version":"knot 2.0.0"}`, -`sh.tangled.owner` should return your own DID, and the keyscan (add +`org.tangled.knot.describeKnot` should return the knot's DID and +version, and the keyscan (add `-p 2222` if the knot isn't on port 22) should match the fingerprint your users already have. Then clone a repository anonymously, and push to a repository you own. diff --git a/docs/src/content/docs/reference/knot-config.md b/docs/src/content/docs/reference/knot-config.md index bd514e37e..176330f0f 100644 --- a/docs/src/content/docs/reference/knot-config.md +++ b/docs/src/content/docs/reference/knot-config.md @@ -53,7 +53,7 @@ hosts. Git data stays readable. | Key | Env var | Default | Description | | --------------------------------- | -------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `hostname` | `KNOT_HOSTNAME` | required | Public hostname. The knot's identity becomes `did:web:`, served at `/.well-known/did.json`. | -| `admins` | `KNOT_ADMINS` | required | Admin DIDs. The first entry is the knot owner, returned by `sh.tangled.owner`. As an env var, comma-separated. | +| `admins` | `KNOT_ADMINS` | required | Admin DIDs. The first entry is the knot owner. As an env var, comma-separated. | | `listen_addr` | `KNOT_LISTEN_ADDR` | `[::]:5555` | HTTP listener for XRPC, smart HTTP git, LFS and the homepage. | | `listen_header_timeout_ms` | `KNOT_LISTEN_HEADER_TIMEOUT_MS` | `10000` | Time allowed to receive request headers. | | `listen_idle_timeout_ms` | `KNOT_LISTEN_IDLE_TIMEOUT_MS` | `60000` | Idle keep-alive connection timeout. | @@ -140,7 +140,7 @@ hosts. Git data stays readable. | ------------------------------ | ---------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `max_body_bytes` | `KNOT_XRPC_MAX_BODY_BYTES` | `65536` | General XRPC request body limit. | | `max_response_bytes` | `KNOT_XRPC_MAX_RESPONSE_BYTES` | `5242880` | General XRPC response size limit. | -| `max_archive_bytes` | `KNOT_XRPC_MAX_ARCHIVE_BYTES` | `1073741824` | Upper bound on one archive across `sh.tangled.repo.archive`, `git archive --remote` and the smart HTTP archive route. | +| `max_archive_bytes` | `KNOT_XRPC_MAX_ARCHIVE_BYTES` | `1073741824` | Upper bound on one archive across `org.tangled.git.getArchive`, `git archive --remote` and the smart HTTP archive route. | | `tree_last_commit_budget_ms` | `KNOT_XRPC_TREE_LAST_COMMIT_BUDGET_MS` | `300` | Time budget for finding last-commit info in tree listings. | | `blob_last_commit_budget_ms` | `KNOT_XRPC_BLOB_LAST_COMMIT_BUDGET_MS` | `2000` | Time budget for finding last-commit info for a blob. | | `languages_budget_ms` | `KNOT_XRPC_LANGUAGES_BUDGET_MS` | `1000` | Time budget for language statistics. | diff --git a/docs/src/content/docs/reference/records.md b/docs/src/content/docs/reference/records.md index 967f24d6e..81c95a953 100644 --- a/docs/src/content/docs/reference/records.md +++ b/docs/src/content/docs/reference/records.md @@ -81,7 +81,7 @@ Location key: | NSID | Stored in | Record key | Purpose | | ----------------------------- | ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `org.tangled.git.refs` | Repo account | `self` | Snapshot of every ref and the default branch, rewritten by the knot after each push. Gitmirror syncs from it. | +| `org.tangled.git.refs` | Repo account | `self` | Snapshot of every ref and the default branch, rewritten by the knot after each push. Gitmirror syncs from it. Spindle watches it for push triggers. | | `org.tangled.git.pushEvent` | Not a record | — | Signed push frame `{repo, pusher, edits, options}` on the knot's `com.atproto.sync.subscribeRepos`. Signed with the repository key so relays can renumber `seq`. | | `org.tangled.git.receivePack` | Not a record | — | No-op procedure that exists as the OAuth permission scope for git push. | @@ -113,7 +113,7 @@ stack. Records under `org.tangled.*` replace them over time. | NSID | Stored in | Written by | Read by | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `sh.tangled.repo` | User PDS (owner) | Web repository settings (edits an existing record), migrator | Spindle (the `spindle` hostname field decides which spindle runs a repository's CI) | -| `sh.tangled.git.ref` | Repo account | Knot, one record per ref after each push, with pusher and push options | Spindle (push triggers) | +| `sh.tangled.git.ref` | — | Obsolete; replaced by the `org.tangled.git.refs/self` snapshot | Not used by current services | | `sh.tangled.repo.issue` | User PDS | Web, when the repository's knot lacks the `knot-social-writes` capability | Not indexed by bobbin (hydrant filters `org.tangled.*`) | | `sh.tangled.feed.comment` | User PDS | Web, same fallback | Not indexed by bobbin | | `sh.tangled.repo.artifact` | User PDS | CI release recipes | Bobbin | diff --git a/knot2/README.md b/knot2/README.md index 2f05b09d7..6f97f411b 100644 --- a/knot2/README.md +++ b/knot2/README.md @@ -51,7 +51,7 @@ cp example.toml config.toml The most important values have no default and thus the knot won't start without them: - `server.hostname`: Public hostname, which will also be the knot's identity as `did:web:`, so it oughtta be a public, good, solid, representative name. -- `server.admins`: List of atproto DIDs, where the first one is the knot's "full owner", which is the DID the operator registers the knot under & what `sh.tangled.owner` returns. Every other admin has the same powers, just no claim to the fancy title, the way tangled is *currently* set up. Admin & minions. +- `server.admins`: List of atproto DIDs, where the first is the knot's "full owner", which is the DID the operator registers the knot under. Every other admin has the same powers, just no claim to the fancy title, the way tangled is *currently* set up. Admin & minions. - `server.ssh_host_key_file`: Path for the SSH host key. - `repo.scan_path`: Path to the directory that gets the actual git repos. - `secrets.sealed_key_file`: Path to the sealed key store. @@ -287,11 +287,11 @@ ACME here uses the TLS-ALPN-01 challenge, so the knot has to be the reciever (in ```sh curl -s https://knot.oyster.cafe/xrpc/_health -curl -s https://knot.oyster.cafe/xrpc/sh.tangled.owner +curl -s https://knot.oyster.cafe/xrpc/org.tangled.knot.describeKnot curl -s https://knot.oyster.cafe/.well-known/did.json ``` -`_health` reports the version, and if LFS is on it returns an error status when the LFS store isn't writable. `sh.tangled.owner` returns the first DID in `server.admins`, which is what the Tangled appview reads to confirm the operator is who they say they are when they register the knot. `did.json` is the knot's own `did:web` document, served at the hostname it was configured with. +`_health` reports the version, and if LFS is on it returns an error status when the LFS store isn't writable. `org.tangled.knot.describeKnot` describes the knot, returning its DID, version and capabilities. `did.json` is the knot's own `did:web` document, served at the hostname it was configured with. If any of the above endpoints doesn't pong, check out the stderr logs, `podman logs -f knot-oyster` in my case. diff --git a/knot2/crates/knot-xrpc/src/social.rs b/knot2/crates/knot-xrpc/src/social.rs index 6821fe43e..fc2bf98ef 100644 --- a/knot2/crates/knot-xrpc/src/social.rs +++ b/knot2/crates/knot-xrpc/src/social.rs @@ -3359,8 +3359,7 @@ pub(crate) async fn merge_pull( http::StatusCode::CONFLICT, "ClosedTicket", format!( - "the patch at {} rides a closed ticket, and a closed ticket isn't \ - merged", + "the patch at {} is on a closed ticket", address.at_uri(ledger.repo), ), )); diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index 6666e3d4f..23c0bc66c 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -26,14 +26,12 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, ActorId, AdmissionPolicy, AtUri, AuthorName, ContributionPolicy, Did, Email, - KnotHostname, KnotId, KnotServiceUrl, Nsid, OriginUrl, OwnerDid, RecordCollection, + AccountDid, ActorId, AdmissionPolicy, AtUri, AuthorName, BranchName, ContributionPolicy, Did, + Email, KnotHostname, KnotId, KnotServiceUrl, Nsid, OriginUrl, OwnerDid, RecordCollection, RecordRkey, RepoDid, RepoName, RepoRkey, ServiceDid, UnixSeconds, }; use crate::XrpcState; -use crate::collaborators::{add_collaborator, remove_collaborator}; -use crate::members::{add_member, remove_member}; const KNOT_HOST: &str = "knot.nel.pet"; const ADMIN_HOST: &str = "admin.nel.pet"; @@ -41,23 +39,27 @@ const MEMBER_HOST: &str = "member.nel.pet"; const STRANGER_HOST: &str = "stranger.nel.pet"; const PASSERBY_HOST: &str = "passerby.nel.pet"; -const ADD_MEMBER: &str = "sh.tangled.knot.addMember"; -const REMOVE_MEMBER: &str = "sh.tangled.knot.removeMember"; -const BAN: &str = "sh.tangled.knot.ban"; -const UNBAN: &str = "sh.tangled.knot.unban"; const CREATE: &str = "org.tangled.repo.createRepo"; -const RESERVE: &str = "sh.tangled.repo.reserveKey"; -const DELETE: &str = "sh.tangled.repo.delete"; -const RENAME: &str = "sh.tangled.repo.rename"; -const SET_POLICY: &str = "sh.tangled.repo.setContributionPolicy"; -const ADD_COLLAB: &str = "sh.tangled.repo.addCollaborator"; -const REMOVE_COLLAB: &str = "sh.tangled.repo.removeCollaborator"; -const SET_DEFAULT: &str = "sh.tangled.repo.setDefaultBranch"; -const DELETE_BRANCH: &str = "sh.tangled.repo.deleteBranch"; -const MERGE: &str = "sh.tangled.repo.merge"; -const FORK_SYNC: &str = "sh.tangled.repo.forkSync"; -const DESCRIBE_REPO: &str = "sh.tangled.repo.describeRepo"; -const HIDDEN_REF: &str = "sh.tangled.repo.hiddenRef"; +const RESERVE: &str = "org.tangled.repo.reserveKey"; +const DELETE: &str = "org.tangled.repo.deleteRepo"; +const DELETE_BRANCH: &str = "org.tangled.git.deleteBranch"; +const FORK_SYNC: &str = "org.tangled.repo.forkSync"; +const MANIFEST: &str = "org.tangled.repo.manifest"; +const MEMBER: &str = "org.tangled.knot.member"; +const BLOCK: &str = "org.tangled.knot.block"; +const COLLABORATOR: &str = "org.tangled.graph.collaborator"; + +fn member_collection() -> RecordCollection { + RecordCollection::new(MEMBER).unwrap() +} + +fn block_collection() -> RecordCollection { + RecordCollection::new(BLOCK).unwrap() +} + +fn collaborator_collection() -> RecordCollection { + RecordCollection::new(COLLABORATOR).unwrap() +} type Responder = Box Result + Send + Sync>; type SharedState = Arc, Arc>>; @@ -461,7 +463,11 @@ fn world_responder( Some(sec1) => Ok(HttpResponse { status: StatusCode::OK, headers: http::HeaderMap::new(), - body: did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), sec1, &doc_url), + body: did_web_doc( + &Did::new_owned(format!("did:web:{host}")).unwrap(), + sec1, + &doc_url, + ), }), None => Ok(HttpResponse { status: StatusCode::NOT_FOUND, @@ -673,7 +679,9 @@ impl World { } fn respond_pds_record(&self, rkey: &RecordRkey, status: StatusCode) { - self.pds_records.lock().insert(rkey.as_str().to_string(), status); + self.pds_records + .lock() + .insert(rkey.as_str().to_string(), status); } fn publish_repo_doc(&self, host: &str, multikey: &str) { self.repo_docs @@ -815,7 +823,11 @@ fn doc_responder( body: if post { Bytes::new() } else { - did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), &sec1, &pds) + did_web_doc( + &Did::new_owned(format!("did:web:{host}")).unwrap(), + &sec1, + &pds, + ) }, }) }) @@ -845,7 +857,11 @@ fn plc_stage(fake: Arc>, sec1: Vec) -> Responder { if host != "plc.directory" { return Ok(reply( StatusCode::OK, - did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), &sec1, &pds), + did_web_doc( + &Did::new_owned(format!("did:web:{host}")).unwrap(), + &sec1, + &pds, + ), )); } let mut fake = fake.lock(); @@ -944,22 +960,105 @@ async fn minted_repo(fake: PlcFake) -> SweptRepo { } } -fn subject_body(repo: Option<&RepoDid>, subject: &AccountDid) -> serde_json::Value { - match repo { - None => json!({ "subject": subject.as_str() }), - Some(repo) => json!({ "repo": repo.as_str(), "subject": subject.as_str() }), - } +fn grant_body( + repo: &RepoDid, + collection: &RecordCollection, + subject: &AccountDid, +) -> serde_json::Value { + json!({ + "repo": repo, + "collection": collection, + "rkey": subject, + "record": { + "$type": collection, + "createdAt": "2026-09-11T00:00:00Z", + }, + }) } async fn offer(world: &World, repo: Option<&RepoDid>, subject: &AccountDid) -> StatusCode { - let body = subject_body(repo, subject); match repo { - None => as_admin(world, add_member, &nsid(ADD_MEMBER), body).await, - Some(_) => as_member(world, add_collaborator, &nsid(ADD_COLLAB), body).await, + None => offer_grant(world, &world.admin, &member_collection(), subject).await, + Some(repo) => offer_collaborator_as(world, &world.member, repo, subject).await, } +} + +async fn offer_grant( + world: &World, + actor: &Actor, + collection: &RecordCollection, + subject: &AccountDid, +) -> StatusCode { + let knot_repo = world.state.knot_repo.clone(); + call( + world, + crate::social::create_record, + actor, + &nsid(CREATE_RECORD), + grant_body(&knot_repo, collection, subject), + ) + .await + .status() +} + +async fn offer_block(world: &World, actor: &Actor, subject: &AccountDid) -> StatusCode { + offer_grant(world, actor, &block_collection(), subject).await +} + +async fn revoke( + world: &World, + actor: &Actor, + collection: &RecordCollection, + subject: &AccountDid, +) -> StatusCode { + let knot_repo = world.state.knot_repo.clone(); + call( + world, + crate::social::delete_record, + actor, + &nsid(DELETE_RECORD), + json!({ "repo": knot_repo, "collection": collection, "rkey": subject.as_str() }), + ) + .await .status() } +async fn offer_collaborator_as( + world: &World, + actor: &Actor, + repo: &RepoDid, + subject: &AccountDid, +) -> StatusCode { + on_repo( + world, + crate::social::create_record, + actor, + &nsid(CREATE_RECORD), + repo, + grant_body(repo, &collaborator_collection(), subject), + ) + .await + .0 +} + +async fn revoke_collaborator( + world: &World, + actor: &Actor, + repo: &RepoDid, + subject: &AccountDid, +) -> StatusCode { + on_repo( + world, + crate::social::delete_record, + actor, + &nsid(DELETE_RECORD), + repo, + json!({ "repo": repo, "collection": COLLABORATOR, "rkey": subject.as_str() }), + ) + .await + .0 +} + async fn create_repo_helper(world: &World, name: &str) -> RepoDid { assert_eq!( as_member( @@ -979,9 +1078,15 @@ async fn create_repo_helper(world: &World, name: &str) -> RepoDid { } async fn create_status(world: &World, actor: &Actor, value: serde_json::Value) -> StatusCode { - call(world, crate::repos::create_repo, actor, &nsid(CREATE), value) - .await - .status() + call( + world, + crate::repos::create_repo, + actor, + &nsid(CREATE), + value, + ) + .await + .status() } async fn reserve_status(world: &World, actor: &Actor, did: &RepoDid) -> StatusCode { @@ -1011,35 +1116,45 @@ async fn reserve_repo_key(world: &World, did: &RepoDid) -> String { key } -async fn rename_repo_as( +fn manifest_collection() -> RecordCollection { + RecordCollection::new(MANIFEST).unwrap() +} + +fn manifest_rkey() -> RecordRkey { + RecordRkey::new("self").unwrap() +} + +async fn live_manifest( world: &World, - actor: &Actor, repo: &RepoDid, - rkey: &RepoRkey, -) -> StatusCode { - call( +) -> (serde_json::Value, crate::out::CidString) { + let (status, served) = record_at( world, - crate::repos::rename_repo, - actor, - &nsid(RENAME), - json!({ "repo": repo, "rkey": rkey, "name": rkey }), + repo, + &at_of(repo, manifest_collection(), &manifest_rkey()), + None, ) - .await - .status() + .await; + assert_eq!(status, StatusCode::OK, "the manifest serves: {served}"); + (served["value"].clone(), cid_of(&served)) } -async fn set_policy_response( +async fn put_manifest_as( world: &World, actor: &Actor, repo: &RepoDid, - policy: knot_types::ContributionPolicy, -) -> axum::response::Response { - call( + edit: impl FnOnce(&mut serde_json::Value), +) -> (StatusCode, serde_json::Value) { + let (mut record, from) = live_manifest(world, repo).await; + edit(&mut record); + put_as( world, - crate::repos::set_contribution_policy, actor, - &nsid(SET_POLICY), - json!({ "repo": repo, "contributionPolicy": policy }), + repo, + &manifest_collection(), + manifest_rkey(), + json!({ "swapRecord": from }), + record, ) .await } @@ -1050,9 +1165,37 @@ async fn set_policy_as( repo: &RepoDid, policy: knot_types::ContributionPolicy, ) -> StatusCode { - set_policy_response(world, actor, repo, policy) - .await - .status() + put_manifest_as(world, actor, repo, |record| { + record["contributionPolicy"] = json!(policy); + }) + .await + .0 +} + +async fn rename_repo_as( + world: &World, + actor: &Actor, + repo: &RepoDid, + rkey: &RepoRkey, +) -> StatusCode { + put_manifest_as(world, actor, repo, |record| { + record["declaration"]["name"] = json!(rkey.as_str()); + }) + .await + .0 +} + +async fn set_default_branch_as( + world: &World, + actor: &Actor, + repo: &RepoDid, + branch: &BranchName, +) -> StatusCode { + put_manifest_as(world, actor, repo, |record| { + record["defaultBranch"] = json!(branch); + }) + .await + .0 } #[tokio::test] @@ -1101,17 +1244,18 @@ async fn a_repository_takes_the_knots_default_policy_and_its_owner_or_the_operat ); assert_eq!( set_policy_as(&world, &world.admin, &repo, ContributionPolicy::Anyone).await, - StatusCode::OK, - "Operator answers for text this knot serves; policy is the operator's to move" + StatusCode::FORBIDDEN, + "settings are the manifest's, and the manifest belongs to the repository's owner alone" ); assert_eq!( policy_now(), - Resolved::Ready(Some(ContributionPolicy::Anyone)) + Resolved::Ready(Some(ContributionPolicy::Collaborators)), + "a refused settings write doesn't move the policy" ); assert_eq!( - set_policy_as(&world, &world.admin, &repo, ContributionPolicy::Anyone).await, + set_policy_as(&world, &world.member, &repo, ContributionPolicy::Anyone).await, StatusCode::OK, - "setting the same policy again doesn't write change. It still answers 200" + "re-sending the current policy doesn't write a change. It still returns 200" ); } @@ -1120,31 +1264,18 @@ async fn a_second_write_inside_one_account_s_burst_answers_429_named_by_bucket() let world = World::with_write_burst(1); world.grandfather(MEMBER_HOST); let repo = create_repo_helper(&world, "anemone").await; - let response = set_policy_response( - &world, - &world.member, - &repo, - ContributionPolicy::Collaborators, - ) + let (status, body) = put_manifest_as(&world, &world.member, &repo, |record| { + record["contributionPolicy"] = json!(ContributionPolicy::Collaborators); + }) .await; assert_eq!( - response.status(), + status, StatusCode::TOO_MANY_REQUESTS, - "create spent member's one token: next write is over the burst" + "create spent member's one token: next write is over the burst: {body}" ); - let body = axum::body::to_bytes(response.into_body(), 4096) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["bucket"], "actor-writes"); + assert_eq!(body["bucket"], "actor-writes"); assert_eq!( - set_policy_as( - &world, - &world.admin, - &repo, - ContributionPolicy::Collaborators - ) - .await, + create_status(&world, &world.admin, json!({ "name": "anemone2" })).await, StatusCode::OK, "The limit is the writer's, leaving another account's token untouched" ); @@ -1545,7 +1676,7 @@ async fn versions_ordered( ) }); json_of(into_response( - crate::social::list_cob_versions( + crate::social::list_record_versions( world.state(), crate::query::ValidatedQuery(crate::social::VersionsQuery { uri: uri.clone(), @@ -1701,17 +1832,29 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { message_contains(&minted, &["mints the record key"]); futures::stream::iter([ - ("cafe.oyster.gripe", Some("3lubrptx57d22".to_owned())), - ("cafe.oyster.gripe", None), - ("org.tangled.knot.member", Some("did:plc:whelk".to_owned())), ( - "org.tangled.graph.collaborator", + "cafe.oyster.gripe", + Some("3lubrptx57d22".to_owned()), + StatusCode::BAD_REQUEST, + vec!["isn't a collection this knot takes"], + ), + ("cafe.oyster.gripe", None, StatusCode::BAD_REQUEST, vec![]), + ( + "org.tangled.knot.member", Some("did:plc:whelk".to_owned()), + StatusCode::BAD_REQUEST, + vec!["the write is for", "but the record's repo is"], ), ]) - .for_each(|(collection, rkey)| { + .for_each(|(collection, rkey, want, fragments)| { let (world, repo) = (&world, &repo); async move { + let record = match collection { + "org.tangled.knot.member" => { + json!({ "$type": collection, "createdAt": "2026-09-11T00:00:00Z" }) + } + other => retype(ticket_record(repo, "no", ""), other), + }; let rejected = failed( create_as( world, @@ -1719,16 +1862,31 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { repo, &RecordCollection::new(collection).unwrap(), rkey.map(|rkey| RecordRkey::new(rkey).unwrap()), - retype(ticket_record(repo, "no", ""), collection), + record, ) .await, - StatusCode::BAD_REQUEST, + want, ); - message_contains(&rejected, &["isn't a collection this knot takes"]); + message_contains(&rejected, &fragments); } }) .await; + let refused = create_as( + &world, + &world.stranger, + &repo, + &RecordCollection::new(COLLABORATOR).unwrap(), + Some(RecordRkey::new("did:plc:whelk").unwrap()), + json!({ "$type": COLLABORATOR, "createdAt": "2026-09-11T00:00:00Z" }), + ) + .await; + assert_eq!( + refused.0, + StatusCode::FORBIDDEN, + "a collaborator grant is the repository owner's to give: {refused:?}" + ); + let wrong_type = failed( create_as( &world, @@ -1870,7 +2028,12 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { .await, StatusCode::FORBIDDEN, ); - assert_eq!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.len(), 1); + assert_eq!( + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()) + .await + .len(), + 1 + ); let same = ok(ticket.edit(&world.stranger, &uri, &v2, "kelp, again").await); assert_eq!( @@ -1938,7 +2101,11 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { let (status, gone) = record_at(&world, &repo, &uri, None).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(gone["error"], "RecordNotFound"); - assert!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty()); + assert!( + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()) + .await + .is_empty() + ); assert!( block_served(&world, &repo, &v1).await, "The block tree still keeps the prose git can't delete" @@ -2153,7 +2320,9 @@ async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_histo "The deleted version's own CID mustn't resurrect it." ); assert!( - records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty(), + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()) + .await + .is_empty(), "listRecords must drop the row." ); let proof = sync_record_blocks(&world, &repo, &uri).await; @@ -2562,18 +2731,11 @@ async fn admission_screens_repo_creation() { #[tokio::test] async fn blocklist_lifecycle() { let world = World::open(); - let subject = format!("did:web:{STRANGER_HOST}"); + let subject = account(STRANGER_HOST); let make = || json!({ "name": "anemone" }); assert_eq!( - as_admin( - &world, - crate::blocklist::ban, - &nsid(BAN), - json!({ "subject": subject }) - ) - .await - .status(), + offer_block(&world, &world.admin, &subject).await, StatusCode::OK ); assert!(matches!( @@ -2587,14 +2749,7 @@ async fn blocklist_lifecycle() { ); assert_eq!( - as_admin( - &world, - crate::blocklist::unban, - &nsid(UNBAN), - json!({ "subject": subject }) - ) - .await - .status(), + revoke(&world, &world.admin, &block_collection(), &subject).await, StatusCode::OK ); assert!(matches!( @@ -2608,26 +2763,12 @@ async fn blocklist_lifecycle() { ); assert_eq!( - as_admin( - &world, - crate::blocklist::ban, - &nsid(BAN), - json!({ "subject": format!("did:web:{ADMIN_HOST}") }) - ) - .await - .status(), + offer_block(&world, &world.admin, &account(ADMIN_HOST)).await, StatusCode::FORBIDDEN, "an admin cannot be banned" ); assert_eq!( - as_stranger( - &world, - crate::blocklist::ban, - &nsid(BAN), - json!({ "subject": format!("did:web:{MEMBER_HOST}") }) - ) - .await - .status(), + offer_block(&world, &world.stranger, &account(MEMBER_HOST)).await, StatusCode::FORBIDDEN, "a non-admin cannot ban" ); @@ -2636,7 +2777,6 @@ async fn blocklist_lifecycle() { #[tokio::test] async fn member_lifecycle() { let world = World::new(); - let subject = json!({ "subject": format!("did:web:{MEMBER_HOST}") }); let member = account(MEMBER_HOST); assert_eq!(offer(&world, None, &member).await, StatusCode::OK); @@ -2652,14 +2792,7 @@ async fn member_lifecycle() { ); assert_eq!( - as_admin( - &world, - crate::members::remove_member, - &nsid(REMOVE_MEMBER), - subject.clone() - ) - .await - .status(), + revoke(&world, &world.admin, &member_collection(), &member).await, StatusCode::OK ); assert_eq!( @@ -2668,14 +2801,7 @@ async fn member_lifecycle() { "removed member is gone on the very next read" ); assert_eq!( - as_admin( - &world, - crate::members::remove_member, - &nsid(REMOVE_MEMBER), - subject - ) - .await - .status(), + revoke(&world, &world.admin, &member_collection(), &member).await, StatusCode::OK ); } @@ -2693,9 +2819,11 @@ async fn redeem(world: &World, actor: &Actor, code: &str) -> StatusCode { actor, // derived, never spelled twice: these tests call the handler directly, so // a token minted for the wrong nsid would still agree with itself - &nsid(crate::invite_codes::REDEEM_ROUTE - .strip_prefix("/xrpc/") - .expect("xrpc route paths are prefixed")), + &nsid( + crate::invite_codes::REDEEM_ROUTE + .strip_prefix("/xrpc/") + .expect("xrpc route paths are prefixed"), + ), json!({ "code": code }), ) .await @@ -2783,17 +2911,18 @@ async fn redeeming_twice_as_an_existing_member_is_a_no_op() { } #[tokio::test] -async fn add_member_auth_outcomes() { +async fn member_offer_auth_outcomes() { struct Case { headers: HeaderMap, - body: serde_json::Value, + subject: &'static str, status: StatusCode, why: &'static str, } let world = World::new(); + let knot_repo = world.state.knot_repo.clone(); let lowercase = { - let token = mint(&world.admin, &nsid(ADD_MEMBER)); + let token = mint(&world.admin, &nsid(CREATE_RECORD)); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, @@ -2803,26 +2932,26 @@ async fn add_member_auth_outcomes() { }; let cases = vec![ Case { - headers: bearer(&mint(&world.member, &nsid(ADD_MEMBER))), - body: json!({ "subject": "did:web:olaren.dev" }), + headers: bearer(&mint(&world.member, &nsid(CREATE_RECORD))), + subject: "did:web:olaren.dev", status: StatusCode::FORBIDDEN, - why: "a non-admin cannot add a member", + why: "a non-admin can't offer membership", }, Case { headers: HeaderMap::new(), - body: json!({ "subject": format!("did:web:{MEMBER_HOST}") }), + subject: "did:web:olaren.dev", status: StatusCode::UNAUTHORIZED, why: "a request without a token is unauthorized", }, Case { - headers: bearer(&mint(&world.admin, &nsid(ADD_MEMBER))), - body: json!({ "subject": "not-a-did" }), + headers: bearer(&mint(&world.admin, &nsid(CREATE_RECORD))), + subject: "not-a-did", status: StatusCode::BAD_REQUEST, - why: "an invalid DID is rejected at decode by the newtype Deserialize, never reaching a handler", + why: "a record key that isn't a DID is rejected before the grant runs", }, Case { headers: lowercase, - body: json!({ "subject": "did:web:olaren.dev" }), + subject: "did:web:olaren.dev", status: StatusCode::OK, why: "the bearer scheme is matched case-insensitively per RFC 7235", }, @@ -2830,18 +2959,33 @@ async fn add_member_auth_outcomes() { let world_ref = &world; futures::stream::iter(cases) - .for_each(|case| async move { - let status = into_response( - crate::members::add_member( - world_ref.state(), - case.headers, - crate::Method::from_nsid(ADD_MEMBER), - body(case.body), + .for_each(|case| { + let knot_repo = knot_repo.clone(); + async move { + let payload = match AccountDid::new(case.subject) { + Ok(subject) => grant_body(&knot_repo, &member_collection(), &subject), + Err(_) => json!({ + "repo": knot_repo, + "collection": member_collection(), + "rkey": case.subject, + "record": { + "$type": member_collection(), + "createdAt": "2026-09-11T00:00:00Z", + }, + }), + }; + let status = into_response( + crate::social::create_record( + world_ref.state(), + case.headers, + crate::Method::from_nsid(CREATE_RECORD), + body(payload), + ) + .await, ) - .await, - ) - .status(); - assert_eq!(status, case.status, "{}", case.why); + .status(); + assert_eq!(status, case.status, "{}", case.why); + } }) .await; } @@ -2857,13 +3001,18 @@ async fn a_transient_upstream_identity_failure_is_a_503_named_upstream_unavailab }); let (_dir, state) = build_state(responder, true); let admin = actor(1, ADMIN_HOST); - let token = mint(&admin, &nsid(ADD_MEMBER)); + let token = mint(&admin, &nsid(CREATE_RECORD)); + let knot_repo = crate::knot_repo(&state.knot_did).unwrap(); let response = into_response( - crate::members::add_member( + crate::social::create_record( State(state), bearer(&token), - crate::Method::from_nsid(ADD_MEMBER), - body(json!({ "subject": format!("did:web:{MEMBER_HOST}") })), + crate::Method::from_nsid(CREATE_RECORD), + body(grant_body( + &knot_repo, + &member_collection(), + &account(MEMBER_HOST), + )), ) .await, ); @@ -2886,23 +3035,11 @@ async fn concurrent_first_member_adds_converge_to_a_single_cob_object() { use knot_git::Repo; let world = World::new(); - let method = nsid(ADD_MEMBER); - let (left, right) = tokio::join!( - as_admin( - &world, - crate::members::add_member, - &method, - json!({ "subject": "did:web:witchcraft.systems" }) - ), - as_admin( - &world, - crate::members::add_member, - &method, - json!({ "subject": "did:web:isabelroses.com" }) - ), - ); - assert_eq!(left.status(), StatusCode::OK); - assert_eq!(right.status(), StatusCode::OK); + let first = account("witchcraft.systems"); + let second = account("isabelroses.com"); + let (left, right) = tokio::join!(offer(&world, None, &first), offer(&world, None, &second),); + assert_eq!(left, StatusCode::OK); + assert_eq!(right, StatusCode::OK); let meta = Repo::open(world.state.meta_path.clone()).unwrap(); assert_eq!( @@ -2949,14 +3086,15 @@ async fn re_adding_a_member_under_warming_appends_no_redundant_change() { ) .unwrap(); - let token = mint(&admin, &nsid(ADD_MEMBER)); + let token = mint(&admin, &nsid(CREATE_RECORD)); + let knot_repo = crate::knot_repo(&state.knot_did).unwrap(); assert_eq!( into_response( - crate::members::add_member( + crate::social::create_record( State(Arc::clone(&state)), bearer(&token), - crate::Method::from_nsid(ADD_MEMBER), - body(json!({ "subject": subject.as_str() })), + crate::Method::from_nsid(CREATE_RECORD), + body(grant_body(&knot_repo, &member_collection(), &subject)), ) .await ) @@ -3087,10 +3225,7 @@ async fn create_and_reserve_reject_bad_identities() { "a did:web whose document publishes a different key fails the control proof" ); assert!( - world - .layout - .open(&unpublished) - .is_err(), + world.layout.open(&unpublished).is_err(), "the repo was never created on disk" ); @@ -3173,14 +3308,20 @@ async fn a_byo_did_web_repo_is_accepted_and_its_key_is_returned() { ); assert_eq!( - as_member( + on_repo( &world, - crate::collaborators::add_collaborator, - &nsid(ADD_COLLAB), - json!({ "repo": did, "subject": "did:web:witchcraft.systems" }) + crate::social::create_record, + &world.member, + &nsid(CREATE_RECORD), + &repo_did, + grant_body( + &repo_did, + &collaborator_collection(), + &account("witchcraft.systems") + ), ) .await - .status(), + .0, StatusCode::OK ); assert_eq!( @@ -3361,12 +3502,12 @@ async fn one_account_cannot_exhaust_the_global_reservation_budget() { RepoDid::new("did:web:m1.olaren.dev").unwrap(), ]) .for_each(|did| async move { - assert_eq!( - reserve_status(world_ref, &world_ref.member, &did).await, - StatusCode::OK - ); - }) - .await; + assert_eq!( + reserve_status(world_ref, &world_ref.member, &did).await, + StatusCode::OK + ); + }) + .await; assert_eq!( reserve_status( &world, @@ -3432,7 +3573,6 @@ async fn collaborator_lifecycle() { world.grandfather(MEMBER_HOST); let repo_did = create_repo_helper(&world, "scallop").await; let collaborator = account(STRANGER_HOST); - let subject = || json!({ "repo": repo_did, "subject": collaborator.as_str() }); let repo = Some(&repo_did); assert_eq!(offer(&world, repo, &collaborator).await, StatusCode::OK); @@ -3444,14 +3584,7 @@ async fn collaborator_lifecycle() { Resolved::Ready(true) ); assert_eq!( - as_member( - &world, - crate::collaborators::remove_collaborator, - &nsid(REMOVE_COLLAB), - subject() - ) - .await - .status(), + revoke_collaborator(&world, &world.member, &repo_did, &collaborator).await, StatusCode::OK ); assert_eq!( @@ -3463,14 +3596,7 @@ async fn collaborator_lifecycle() { "removed collaborator is gone on the very next read" ); assert_eq!( - as_member( - &world, - crate::collaborators::remove_collaborator, - &nsid(REMOVE_COLLAB), - subject() - ) - .await - .status(), + revoke_collaborator(&world, &world.member, &repo_did, &collaborator).await, StatusCode::OK ); } @@ -3482,28 +3608,20 @@ async fn repo_management_is_owner_or_collaborator_gated() { let repo_did = create_repo_helper(&world, "squid").await; assert_eq!( - as_admin( - &world, - crate::collaborators::add_collaborator, - &nsid(ADD_COLLAB), - json!({ "repo": repo_did, "subject": "did:web:isabelroses.com" }) - ) - .await - .status(), + offer_collaborator_as(&world, &world.admin, &repo_did, &account("isabelroses.com")).await, StatusCode::FORBIDDEN, "collaborator management is the repo owner's right instead of a knot admin's" ); assert_eq!( - as_stranger( + set_default_branch_as( &world, - crate::branches::set_default_branch, - &nsid(SET_DEFAULT), - json!({ "repo": repo_did, "defaultBranch": "trunk" }) + &world.stranger, + &repo_did, + &BranchName::new("trunk").unwrap() ) - .await - .status(), + .await, StatusCode::FORBIDDEN, - "a stranger cannot set the default branch" + "a stranger can't move the manifest's default branch" ); assert_eq!( as_stranger( @@ -3555,26 +3673,36 @@ async fn repo_management_is_owner_or_collaborator_gated() { &RepoRkey::new("periwinkle").unwrap(), ) .await, + StatusCode::FORBIDDEN, + "a rename is a manifest write now, and the manifest is the owner's alone" + ); + assert_eq!( + rename_repo_as( + &world, + &world.member, + &repo_did, + &RepoRkey::new("periwinkle").unwrap(), + ) + .await, StatusCode::OK, - "rename is gated by can_push, so a collaborator may rename" + "the owner renames through the manifest" ); } #[tokio::test] -async fn the_owner_sets_the_default_branch_by_repo_did() { +async fn the_owner_moves_the_default_branch_through_the_manifest() { let world = World::new(); world.grandfather(MEMBER_HOST); let repo_did = create_repo_helper(&world, "mussel").await; assert_eq!( - as_member( + set_default_branch_as( &world, - crate::branches::set_default_branch, - &nsid(SET_DEFAULT), - json!({ "repo": repo_did, "defaultBranch": "trunk" }) + &world.member, + &repo_did, + &BranchName::new("trunk").unwrap() ) - .await - .status(), + .await, StatusCode::OK ); @@ -3582,7 +3710,7 @@ async fn the_owner_sets_the_default_branch_by_repo_did() { assert_eq!( repo.default_branch().map(|name| name.as_str().to_string()), Some("refs/heads/trunk".to_string()), - "HEAD now points at the requested default branch" + "HEAD now points at the branch the manifest declared" ); } @@ -3618,28 +3746,32 @@ async fn set_default_branch_rejections() { }) .unwrap(); + let (live, from) = live_manifest(&world, &repo_did).await; + let ghost_repo = RepoDid::new("did:web:squid.oyster.cafe").unwrap(); assert_eq!( - as_member( + put_as( &world, - crate::branches::set_default_branch, - &nsid(SET_DEFAULT), - json!({ "repo": "did:web:squid.oyster.cafe", "defaultBranch": "trunk" }) + &world.member, + &ghost_repo, + &manifest_collection(), + manifest_rkey(), + json!({ "swapRecord": from }), + live, ) .await - .status(), + .0, StatusCode::NOT_FOUND, "we'll reject a repo DID that this knot doesn't host" ); assert_eq!( - as_member( + set_default_branch_as( &world, - crate::branches::set_default_branch, - &nsid(SET_DEFAULT), - json!({ "repo": repo_did, "defaultBranch": "ghost" }) + &world.member, + &repo_did, + &BranchName::new("ghost").unwrap() ) - .await - .status(), - StatusCode::NOT_FOUND, + .await, + StatusCode::BAD_REQUEST, "a populated repo rejects a default pointing at a branch that doesn't exist" ); } @@ -3745,11 +3877,16 @@ async fn delete_repo_lifecycle_and_guards() { world.publish_pds_record(&RecordRkey::new("conch").unwrap()); let delete_conch = || json!({ "repo": guarded }); assert_eq!( - as_member(&world, crate::repos::delete_repo, &nsid(DELETE), delete_conch()) - .await - .status(), + as_member( + &world, + crate::repos::delete_repo, + &nsid(DELETE), + delete_conch() + ) + .await + .status(), StatusCode::CONFLICT, - "the guard refuses while the sh.tangled.repo record is still on the owner's PDS" + "the guard rejects the delete while the repo's declaration record is still on the owner's PDS" ); assert!( world.layout.open(&guarded).is_ok(), @@ -3758,16 +3895,26 @@ async fn delete_repo_lifecycle_and_guards() { let force_conch = || json!({ "repo": guarded, "force": true }); assert_eq!( - as_member(&world, crate::repos::delete_repo, &nsid(DELETE), force_conch()) - .await - .status(), + as_member( + &world, + crate::repos::delete_repo, + &nsid(DELETE), + force_conch() + ) + .await + .status(), StatusCode::FORBIDDEN, "force is an admin-only escape hatch instead of the owner's" ); assert_eq!( - as_admin(&world, crate::repos::delete_repo, &nsid(DELETE), force_conch()) - .await - .status(), + as_admin( + &world, + crate::repos::delete_repo, + &nsid(DELETE), + force_conch() + ) + .await + .status(), StatusCode::OK, "a knot admin forces the delete past the lingering PDS record" ); @@ -3784,7 +3931,13 @@ async fn rename_alias_lifecycle() { let repo_a = create_repo_helper(&world, "alpha").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_a, &RepoRkey::new("alphanew").unwrap()).await, + rename_repo_as( + &world, + &world.member, + &repo_a, + &RepoRkey::new("alphanew").unwrap() + ) + .await, StatusCode::OK ); assert_eq!( @@ -3818,7 +3971,13 @@ async fn rename_alias_lifecycle() { let repo_b = create_repo_helper(&world, "beta").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_b, &RepoRkey::new("alpha").unwrap()).await, + rename_repo_as( + &world, + &world.member, + &repo_b, + &RepoRkey::new("alpha").unwrap() + ) + .await, StatusCode::CONFLICT, "a rename cannot take the canonical rkey of another live repo" ); @@ -3830,7 +3989,13 @@ async fn rename_alias_lifecycle() { let repo_g = create_repo_helper(&world, "gamma").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_g, &RepoRkey::new("gammanew").unwrap()).await, + rename_repo_as( + &world, + &world.member, + &repo_g, + &RepoRkey::new("gammanew").unwrap() + ) + .await, StatusCode::OK ); assert_eq!( @@ -3855,29 +4020,58 @@ async fn rename_alias_lifecycle() { ); let ghost = RepoDid::new("did:web:ghost.nel.pet").unwrap(); + let (live, from) = live_manifest(&world, &repo_a).await; + let mut renamed = live; + renamed["declaration"]["name"] = json!("kelp"); assert_eq!( - rename_repo_as(&world, &world.member, &ghost, &RepoRkey::new("kelp").unwrap()).await, + put_as( + &world, + &world.member, + &ghost, + &manifest_collection(), + manifest_rkey(), + json!({ "swapRecord": from }), + renamed, + ) + .await + .0, StatusCode::NOT_FOUND, "renaming a repo this knot doesn't host is 404 instead of 403" ); } #[tokio::test] -async fn a_rename_against_a_warming_registry_is_unavailable_not_forbidden() { +async fn a_manifest_write_against_a_warming_registry_is_unavailable_not_forbidden() { let admin = actor(1, ADMIN_HOST); let responder = doc_responder(admin.signer.public_key().as_bytes().to_vec(), || { StatusCode::OK }); let (_dir, state) = build_state(responder, false); - let token = mint(&admin, &nsid(RENAME)); + let ghost = RepoDid::new("did:web:squid.nel.pet").unwrap(); + let token = mint_for( + &admin, + &nsid(PUT_RECORD), + &ServiceDid::new(ghost.as_str()).unwrap(), + ); + let swap = crate::out::CidString::from(knot_types::dag_cbor_cid(b"warming")); assert_eq!( into_response( - crate::repos::rename_repo( + crate::social::put_record( State(Arc::clone(&state)), bearer(&token), - crate::Method::from_nsid(RENAME), - body(json!({ "repo": "did:web:squid.nel.pet", "rkey": "kelp", "name": "kelp" })), + crate::Method::from_nsid(PUT_RECORD), + body(json!({ + "repo": ghost, + "collection": MANIFEST, + "rkey": "self", + "swapRecord": swap, + "record": { + "$type": MANIFEST, + "declaration": { "owner": "did:web:squid.nel.pet", "name": "kelp" }, + "createdAt": "2026-09-11T00:00:00Z", + }, + })), ) .await ) @@ -3904,7 +4098,7 @@ async fn the_router_sheds_a_pre_auth_flood_from_one_peer() { async move { let mut request = http::Request::builder() .method("POST") - .uri(crate::members::ADD_ROUTE) + .uri(crate::service::RECEIVE_PACK_ROUTE) .body(Body::empty()) .unwrap(); request.extensions_mut().insert(ConnectInfo(peer)); @@ -3939,32 +4133,37 @@ async fn the_router_binds_each_route_to_its_matched_method_scope() { let app = crate::router(Arc::clone(&world.state)); let peer = SocketAddr::from(([203, 0, 113, 23], 5555)); + let knot_repo = world.state.knot_repo.clone(); let post = |token: String| { let mut request = http::Request::builder() .method("POST") - .uri(crate::members::ADD_ROUTE) + .uri(crate::social::CREATE_RECORD_ROUTE) .header(http::header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::from( - serde_json::to_vec(&json!({ "subject": format!("did:web:{MEMBER_HOST}") })) - .unwrap(), + serde_json::to_vec(&grant_body( + &knot_repo, + &member_collection(), + &account(MEMBER_HOST), + )) + .unwrap(), )) .unwrap(); request.extensions_mut().insert(ConnectInfo(peer)); request }; - let matched = mint(&world.admin, &nsid(ADD_MEMBER)); + let matched = mint(&world.admin, &nsid(CREATE_RECORD)); assert_eq!( app.clone().oneshot(post(matched)).await.unwrap().status(), StatusCode::OK, "a token whose lxm is the route's own method authenticates" ); - let sibling = mint(&world.admin, &nsid(REMOVE_MEMBER)); + let sibling = mint(&world.admin, &nsid(PUT_RECORD)); assert_eq!( app.oneshot(post(sibling)).await.unwrap().status(), StatusCode::UNAUTHORIZED, - "a token minted for a sibling method is rejected at the addMember route" + "a token minted for a sibling method is rejected at the createRecord route" ); } @@ -4070,26 +4269,6 @@ mod merge_endpoints { const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - const UNIFIED_PATCH: &str = concat!( - "diff --git a/reef.txt b/reef.txt\n", - "index 1111111..2222222 100644\n", - "--- a/reef.txt\n", - "+++ b/reef.txt\n", - "@@ -1 +1 @@\n", - "-old line\n", - "+new line\n", - ); - - const CONFLICTING_PATCH: &str = concat!( - "diff --git a/reef.txt b/reef.txt\n", - "index 1111111..2222222 100644\n", - "--- a/reef.txt\n", - "+++ b/reef.txt\n", - "@@ -1 +1 @@\n", - "-something else entirely\n", - "+new line\n", - ); - fn seed_main(world: &World, repo_did: &RepoDid, files: &[(&str, &str)]) -> Oid { let repo = world.layout.open(repo_did).unwrap(); let staged: Vec = files @@ -4153,295 +4332,136 @@ mod merge_endpoints { .unwrap_or(0) } - fn blob_at(repo: &knot_git::Repo, commit: Oid, path: &str) -> Vec { - let entry = repo - .entry_at(commit, &knot_types::RepoPath::new(path).unwrap()) - .unwrap() - .unwrap(); - repo.read_blob(entry.oid).unwrap() - } - - #[tokio::test] - async fn the_owner_merges_a_unified_patch_natively_and_cleans_up() { - let world = World::new(); - world.grandfather(MEMBER_HOST); - let repo_did = create_repo_helper(&world, "kelp").await; - let base = seed_main(&world, &repo_did, &[("reef.txt", "old line\n")]); - - assert_eq!( - as_member( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ - "repo": repo_did, - "branch": "main", - "patch": UNIFIED_PATCH, - "commitMessage": "Merge tide", - "commitBody": "body text", - "authorName": "bailey", - "authorEmail": "bailey@nel.pet", - }) - ) - .await - .status(), - StatusCode::OK - ); - - let repo = world.layout.open(&repo_did).unwrap(); - let tip = main_tip(&world, &repo_did); - assert_ne!(tip, base); - let commit = repo.find_commit(tip).unwrap(); - assert_eq!(commit.parents, vec![base]); - assert_eq!(commit.author.name.as_str(), "bailey"); - assert_eq!(commit.author.email.as_str(), "bailey@nel.pet"); - assert_eq!(commit.committer.name.as_str(), "Tangled"); - assert_eq!(commit.committer.email.as_str(), "noreply@tangled.sh"); - assert_eq!(commit.message, "Merge tide\n\nbody text\n"); - assert_eq!(blob_at(&repo, tip, "reef.txt"), b"new line\n"); - - let staging = std::fs::read_dir(repo.path()) - .unwrap() - .flatten() - .filter(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with(knot_git::INCOMING_PREFIX)) - }) - .count(); - assert_eq!( - staging, 0, - "a completed merge cleans up its staging directory" - ); + fn ident() -> Identity { + Identity { + name: AuthorName::new("nel"), + email: Email::new("nel@oyster.cafe"), + time: UnixSeconds::new(1_000), + offset_seconds: 0, + } } - #[tokio::test] - async fn a_native_merge_advances_the_branch_without_a_pipeline_event() { - let world = World::new(); - world.grandfather(MEMBER_HOST); - let repo_did = create_repo_helper(&world, "kelp").await; - seed_main( - &world, - &repo_did, - &[ - ("reef.txt", "old line\n"), - ( - ".tangled/workflows/ci.yml", - "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n", - ), - ], - ); - - assert_eq!( - as_member( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ - "repo": repo_did, - "branch": "main", - "patch": UNIFIED_PATCH, - "commitMessage": "Merge tide", - "authorName": "bailey", - "authorEmail": "bailey@nel.pet", - }) - ) - .await - .status(), - StatusCode::OK - ); + fn advance(world: &World, repo_did: &RepoDid, parent: Oid, path: &str, content: &str) -> Oid { + let repo = world.layout.open(repo_did).unwrap(); + let base_tree = repo.find_commit(parent).unwrap().tree; + let staged = vec![StagedChange { + path: knot_types::RepoPath::new(path).unwrap(), + action: StagedAction::Put { + content: content.as_bytes().to_vec(), + kind: EntryKind::Blob, + }, + }]; + let tree = repo.write_staged_tree(base_tree, &staged).unwrap(); + repo.write_commit(&NewCommit { + tree, + parents: vec![parent], + author: ident(), + committer: ident(), + message: "advance".to_string(), + extra_headers: Vec::new(), + }) + .unwrap() } - #[tokio::test] - async fn a_format_patch_merge_creates_one_commit_per_patch_with_change_id() { - let world = World::new(); - world.grandfather(MEMBER_HOST); - let repo_did = create_repo_helper(&world, "limpet").await; - let base = seed_main(&world, &repo_did, &[("reef.txt", "one\n")]); - - let mbox = concat!( - "From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001\n", - "From: olaren \n", - "Date: Tue, 5 Sep 2023 12:00:00 +0530\n", - "Subject: [PATCH 1/2] first step\n", - "\n", - "step one body\n", - "---\n", - " reef.txt | 2 +-\n", - "\n", - "diff --git a/reef.txt b/reef.txt\n", - "index 1111111..2222222 100644\n", - "--- a/reef.txt\n", - "+++ b/reef.txt\n", - "@@ -1 +1 @@\n", - "-one\n", - "+two\n", - "-- \n2.43.0\n\n", - "From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001\n", - "From: olaren \n", - "Date: Tue, 5 Sep 2023 13:00:00 +0530\n", - "Subject: [PATCH 2/2] second step\n", - "Change-Id: Ifeedfacecafe\n", - "\n", - "---\n", - "diff --git a/reef.txt b/reef.txt\n", - "index 2222222..3333333 100644\n", - "--- a/reef.txt\n", - "+++ b/reef.txt\n", - "@@ -1 +1 @@\n", - "-two\n", - "+three\n", - ); - - assert_eq!( - as_member( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ - "repo": repo_did, - "branch": "main", - "patch": mbox, - }) - ) - .await - .status(), - StatusCode::OK - ); - - let repo = world.layout.open(&repo_did).unwrap(); - let tip = main_tip(&world, &repo_did); - let second = repo.find_commit(tip).unwrap(); - assert_eq!(second.message, "second step\n"); - assert_eq!( - second.change_id(), - Some(knot_git::CommitChangeId::new("Ifeedfacecafe").unwrap()) - ); - assert_eq!(second.author.name.as_str(), "olaren"); - assert_eq!(second.author.email.as_str(), "olaren@olaren.dev"); - assert_eq!(second.author.time.get(), 1_693_899_000); - assert_eq!(second.author.offset_seconds, 19_800); - assert_eq!(second.committer.name.as_str(), "Tangled"); - assert_eq!(second.committer.email.as_str(), "noreply@tangled.sh"); - - let first = repo.find_commit(second.parents[0]).unwrap(); - assert_eq!(first.message, "first step\n\nstep one body\n"); - assert_eq!(first.author.time.get(), 1_693_895_400); - assert_eq!(first.parents, vec![base]); - assert_eq!(blob_at(&repo, tip, "reef.txt"), b"three\n"); + async fn patch_record(world: &World, repo: &RepoDid, base: Oid, head: Oid) -> RecordRkey { + let (ticket, _) = Ticket::on(world, repo).opened(&world.member, "kelp").await; + let written = ok(create_as( + world, + &world.member, + repo, + &RecordCollection::new("org.tangled.review.patch").unwrap(), + None, + json!({ + "$type": "org.tangled.review.patch", + "ticket": rkey_of(&ticket), + "base": { "oid": base.to_hex() }, + "head": { "oid": head.to_hex() }, + "createdAt": "2026-09-11T00:00:00Z" + }), + ) + .await); + rkey_of(&uri_of(&written)) } #[tokio::test] - async fn merge_rejections_move_nothing() { + async fn merge_check_reports_clean_conflicted_and_missing_patches() { let world = World::new(); world.grandfather(MEMBER_HOST); let repo_did = create_repo_helper(&world, "scallop").await; let base = seed_main(&world, &repo_did, &[("reef.txt", "old line\n")]); + let head = advance(&world, &repo_did, base, "reef.txt", "new line\n"); + let patch = patch_record(&world, &repo_did, base, head).await; - assert_eq!( - as_stranger( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) - ) - .await - .status(), - StatusCode::FORBIDDEN, - "a stranger cannot merge" - ); - - assert_eq!( - as_member( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) - ) - .await - .status(), - StatusCode::BAD_REQUEST, - "a merge without a commit message is rejected" - ); - assert_eq!( - main_tip(&world, &repo_did), - base, - "rejected merge mustn't move the branch" - ); - - assert_eq!( - as_member(&world, crate::merge::merge, &nsid(MERGE), json!({ "repo": repo_did, "branch": "driftwood", "patch": UNIFIED_PATCH, "commitMessage": "tide" })).await.status(), - StatusCode::BAD_REQUEST, - "merging into a branch the repo lacks is an invalid request" - ); - - let response = as_member(&world, crate::merge::merge, &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", "patch": CONFLICTING_PATCH, "commitMessage": "tide" })).await; - assert_eq!(response.status(), StatusCode::CONFLICT); - let json = json_of(response).await; - assert_eq!(json["error"], "MergeConflict"); - assert!( - json["message"] - .as_str() - .unwrap() - .starts_with("Merge failed due to conflicts"), - ); - assert_eq!( - main_tip(&world, &repo_did), + let drifted = advance( + &world, + &repo_did, base, - "a conflicted merge mustn't move the branch" + "reef.txt", + "someone else moved it\n", ); - } - - #[tokio::test] - async fn merge_check_is_open_and_reports_clean_conflicted_and_broken() { - let world = World::new(); - world.grandfather(MEMBER_HOST); - let repo_did = create_repo_helper(&world, "scallop").await; - let base = seed_main(&world, &repo_did, &[("reef.txt", "old line\n")]); - - let repo = world.layout.open(&repo_did).unwrap(); - let objects_before = file_count(&repo.objects_dir()); + let git = world.layout.open(&repo_did).unwrap(); + git.update_ref(&RefUpdate::Create { + name: RefName::new("refs/heads/side").unwrap(), + new: drifted, + }) + .unwrap(); + let objects_before = file_count(&git.objects_dir()); - let input = |patch: &str| { + let input = |branch: &BranchName, patch: &RecordRkey| { body(json!({ "repo": repo_did, - "branch": "main", + "branch": branch, "patch": patch, })) }; let clean = json_of( - crate::merge::merge_check(world.state(), input(UNIFIED_PATCH)) - .await - .unwrap(), + crate::merge::merge_check( + world.state(), + input(&BranchName::new("main").unwrap(), &patch), + ) + .await + .unwrap(), ) .await; - assert_eq!(clean["is_conflicted"], false); - assert!(clean.get("conflicts").is_none()); + assert_eq!(clean["isConflicted"], false); + assert_eq!(clean["conflicts"], serde_json::json!([])); + assert!(clean.get("message").is_none()); let conflicted = json_of( - crate::merge::merge_check(world.state(), input(CONFLICTING_PATCH)) - .await - .unwrap(), + crate::merge::merge_check( + world.state(), + input(&BranchName::new("side").unwrap(), &patch), + ) + .await + .unwrap(), ) .await; - assert_eq!(conflicted["is_conflicted"], true); + assert_eq!(conflicted["isConflicted"], true); assert_eq!(conflicted["conflicts"][0]["filename"], "reef.txt"); - assert_eq!(conflicted["conflicts"][0]["reason"], "patch doesn't apply"); + assert_eq!( + conflicted["conflicts"][0]["reason"], + "changed on the target branch too" + ); assert_eq!(conflicted["message"], "patch can't be applied cleanly"); - let broken = json_of( - crate::merge::merge_check(world.state(), input("hello world\n")) - .await - .unwrap(), + let missing = crate::merge::merge_check( + world.state(), + input( + &BranchName::new("main").unwrap(), + &RecordRkey::new("3jzfcijpj2z2a").unwrap(), + ), ) .await; - assert_eq!(broken["is_conflicted"], true); - assert!(broken["error"].as_str().is_some()); + assert_eq!( + missing.unwrap_err().into_response().status(), + StatusCode::NOT_FOUND, + "a missing patch record is a 404, not a conflict" + ); + let git = world.layout.open(&repo_did).unwrap(); assert_eq!( - file_count(&repo.objects_dir()), + file_count(&git.objects_dir()), objects_before, "merge check must write nothing into the object database" ); @@ -4475,17 +4495,17 @@ mod merge_endpoints { let rejections = [ ( - json!({ "did": format!("did:web:{MEMBER_HOST}"), "name": "periwinkle", "branch": "main", "patch": UNIFIED_PATCH }), + json!({ "did": format!("did:web:{MEMBER_HOST}"), "name": "periwinkle", "branch": "main", "patch": "3jzfcijpj2z2a" }), StatusCode::BAD_REQUEST, "we'll 400 an owner and a name instead of a repo DID", ), ( - json!({ "repo": "did:plc:limpet", "branch": "main", "patch": UNIFIED_PATCH }), + json!({ "repo": "did:plc:limpet", "branch": "main", "patch": "3jzfcijpj2z2a" }), StatusCode::NOT_FOUND, "we'll 404 a repo DID that this knot doesn't host", ), ( - json!({ "repo": "periwinkle", "branch": "main", "patch": UNIFIED_PATCH }), + json!({ "repo": "periwinkle", "branch": "main", "patch": "3jzfcijpj2z2a" }), StatusCode::BAD_REQUEST, "we'll 400 a repo that isn't a DID", ), @@ -4497,17 +4517,10 @@ mod merge_endpoints { } assert_eq!( - as_member( - &world, - crate::merge::merge, - &nsid(MERGE), - json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH, "commitMessage": "tide" }) - ) - .await - .status(), - StatusCode::OK + main_tip(&world, &repo_did), + base, + "the merge check doesn't move the branch" ); - assert_ne!(main_tip(&world, &repo_did), base); } } @@ -4594,11 +4607,26 @@ mod fork_endpoints { } fn source_url(rkey: &RepoRkey) -> String { - format!("https://{KNOT_HOST}/did:web:{MEMBER_HOST}/{}", rkey.as_str()) + format!( + "https://{KNOT_HOST}/did:web:{MEMBER_HOST}/{}", + rkey.as_str() + ) } fn described_content(world: &World, did: &RepoDid) -> Option { - crate::reads::import_content(&world.state, did).map(|(content, _)| content) + crate::imports::ImportJob::read(&world.state.layout.repo_path(did).ok()?) + .and_then(Result::ok) + .map(|job| { + match job.state { + crate::imports::ImportState::Queued | crate::imports::ImportState::Fetching => { + "fetching" + } + crate::imports::ImportState::Present => "present", + crate::imports::ImportState::Partial => "partial", + crate::imports::ImportState::Failed => "failed", + } + .to_string() + }) } async fn create_repo_from(world: &World, source: serde_json::Value, name: &str) -> RepoDid { @@ -4803,22 +4831,6 @@ mod fork_endpoints { assert_eq!(described_content(&world, &did).as_deref(), Some("present")); } - async fn described_repo(world: &World, headers: HeaderMap, did: &RepoDid) -> serde_json::Value { - json_of( - crate::reads::repo_describe_repo( - State(Arc::clone(&world.state)), - headers, - crate::Method::from_nsid(DESCRIBE_REPO), - crate::query::ValidatedQuery(crate::reads::DescribeRepoParams { - repo_did: did.clone(), - }), - ) - .await - .unwrap(), - ) - .await - } - #[tokio::test] async fn a_partial_import_can_be_recreated_and_re_stages_the_fetch() { let world = import_world(); @@ -4859,51 +4871,6 @@ mod fork_endpoints { assert_eq!(described_content(&world, &did).as_deref(), Some("present")); } - #[tokio::test] - async fn describe_repo_hides_the_import_reason_from_anonymous_callers() { - let world = import_world(); - let _ = kelp_source(&world, &[]).await; - let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; - // point the stored source at a host that never answers, so the fetch fails - rewrite_sidecar(&world, &did, |job| { - job["source"] = json!("https://barnacle.nel.pet/did:plc:nowhere/uni"); - job["refs"] = serde_json::Value::Null; - }); - crate::imports::run_one(&world.state, &did).await; - assert_eq!(described_content(&world, &did).as_deref(), Some("failed")); - - let mut malformed = HeaderMap::new(); - malformed.insert(AUTHORIZATION, HeaderValue::from_static("Basic nope")); - let error = crate::reads::repo_describe_repo( - State(Arc::clone(&world.state)), - malformed, - crate::Method::from_nsid(DESCRIBE_REPO), - crate::query::ValidatedQuery(crate::reads::DescribeRepoParams { - repo_did: did.clone(), - }), - ) - .await - .unwrap_err(); - assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED); - - let anonymous = described_repo(&world, HeaderMap::new(), &did).await; - assert_eq!(anonymous["content"], json!("failed")); - assert_eq!( - anonymous["reason"], - json!(crate::reads::GENERIC_IMPORT_REASON), - "an anonymous caller gets the stable reason, never the raw import error" - ); - - let owner_token = mint(&world.member, &nsid(DESCRIBE_REPO)); - let owner = described_repo(&world, bearer(&owner_token), &did).await; - assert_eq!(owner["content"], json!("failed")); - let raw_reason = owner["reason"] - .as_str() - .expect("the owner reads the reason verbatim"); - assert!(!raw_reason.is_empty()); - assert_ne!(raw_reason, crate::reads::GENERIC_IMPORT_REASON); - } - #[tokio::test] async fn an_import_refuses_a_plain_http_source() { let world = import_world(); @@ -5046,7 +5013,7 @@ mod fork_endpoints { let tip = advance(&source, &main_ref(), "tide.txt", "rock pool\n", 1_001); [ "refs/tags/v1", - "refs/cobs/sh.tangled.repo.collaborator/limpet", + "refs/cobs/org.tangled.graph.collaborator/limpet", "refs/hidden/feature/main", ] .into_iter() @@ -5090,26 +5057,6 @@ mod fork_endpoints { .status() } - async fn track_hidden( - world: &World, - fork_did: &RepoDid, - fork_ref: &str, - remote_ref: &str, - ) -> StatusCode { - as_member( - world, - crate::forks::hidden_ref, - &nsid(HIDDEN_REF), - json!({ - "repo": fork_did, - "forkRef": fork_ref, - "remoteRef": remote_ref, - }), - ) - .await - .status() - } - #[tokio::test] async fn a_member_forks_a_repo_hosted_on_this_knot() { let setup = forked_world().await; @@ -5255,124 +5202,6 @@ mod fork_endpoints { ); } - #[tokio::test] - async fn hidden_ref_tracks_the_upstream_branch_and_stays_hidden() { - let setup = forked_world().await; - let source = setup.world.layout.open(&setup.source_did).unwrap(); - let new_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); - - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::OK - ); - - let fork = setup.world.layout.open(&setup.fork_did).unwrap(); - let hidden = RefName::new("refs/hidden/feature/main").unwrap(); - assert_eq!(fork.find_ref(&hidden).unwrap(), Some(new_tip)); - assert!( - fork.advertised_refs() - .unwrap() - .iter() - .all(|record| !record.name.as_str().starts_with("refs/hidden/")), - "a hidden ref must stay out of the public advertisement" - ); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::OK, - "tracking an already-tracked ref is idempotent" - ); - } - - #[tokio::test] - async fn hidden_ref_resolves_a_file_origin_by_trailing_segments() { - let setup = forked_world().await; - let source = setup.world.layout.open(&setup.source_did).unwrap(); - let hidden = RefName::new("refs/hidden/feature/main").unwrap(); - - setup - .world - .layout - .open(&setup.fork_did) - .unwrap() - .set_origin_url(&OriginUrl::new(format!( - "file:///home/git/{}", - setup.source_did.as_str() - ))) - .unwrap(); - let did_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::OK - ); - assert_eq!( - setup - .world - .layout - .open(&setup.fork_did) - .unwrap() - .find_ref(&hidden) - .unwrap(), - Some(did_tip), - "a trailing repo did resolves to the source repo" - ); - - setup - .world - .layout - .open(&setup.fork_did) - .unwrap() - .set_origin_url(&OriginUrl::new(format!( - "file:///home/git/did:web:{MEMBER_HOST}/kelp" - ))) - .unwrap(); - let named_tip = advance(&source, &main_ref(), "swell.txt", "tide\n", 1_003); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::OK - ); - assert_eq!( - setup - .world - .layout - .open(&setup.fork_did) - .unwrap() - .find_ref(&hidden) - .unwrap(), - Some(named_tip), - "a trailing owner and name resolves to the source repo" - ); - } - - #[tokio::test] - async fn hidden_ref_rejects_a_stale_or_non_http_stored_origin() { - let setup = forked_world().await; - let fork = setup.world.layout.open(&setup.fork_did).unwrap(); - - fork.set_origin_url(&OriginUrl::new("file:///home/git/did:plc:whelk")) - .unwrap(); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::NOT_FOUND, - "The knot reports not found for a file origin with an unknown repo DID" - ); - - fork.set_origin_url(&OriginUrl::new("ssh://knot.nel.pet/did:plc:whelk/ghost")) - .unwrap(); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::INTERNAL_SERVER_ERROR, - "the knot reports an internal error for a stored origin scheme other than http, https, or file" - ); - - fork.set_origin_url(&OriginUrl::new("file:///kelp")) - .unwrap(); - assert_eq!( - track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, - StatusCode::INTERNAL_SERVER_ERROR, - "the knot reports an internal error for a file origin whose only path segment isn't a DID" - ); - } - #[test] fn parse_trailing_resolves_each_stored_path_shape() { let shape = |raw: &str| { @@ -5859,12 +5688,12 @@ mod grant_lists { "{from}: a second add leaves the entry alone" ); - let body = subject_body(fixture.repo(), &fixture.subject); let revoked = match fixture.repo() { - None => as_admin(world, remove_member, &nsid(REMOVE_MEMBER), body).await, - Some(_) => as_member(world, remove_collaborator, &nsid(REMOVE_COLLAB), body).await, - } - .status(); + None => revoke(world, &world.admin, &member_collection(), &fixture.subject).await, + Some(repo) => { + revoke_collaborator(world, &world.member, repo, &fixture.subject).await + } + }; assert_eq!( revoked, StatusCode::OK, @@ -5942,7 +5771,7 @@ mod grant_lists { ); } - const FIXTURE_COLLECTION: &str = "sh.tangled.git.ref"; + const FIXTURE_COLLECTION: &str = "org.tangled.track.ticket"; #[derive(serde::Serialize)] struct Fixture { @@ -6041,19 +5870,28 @@ mod grant_lists { let world = World::new(); let app = crate::router(Arc::clone(&world.state)); + let knot_repo = world.state.knot_repo.clone(); let post = |method: &'static str, subject: &AccountDid| { let mut request = http::Request::builder() .method("POST") .uri(match method { - ADD_MEMBER => crate::members::ADD_ROUTE, - _ => crate::members::REMOVE_ROUTE, + CREATE_RECORD => crate::social::CREATE_RECORD_ROUTE, + _ => crate::social::DELETE_RECORD_ROUTE, }) .header( http::header::AUTHORIZATION, format!("Bearer {}", mint(&world.admin, &nsid(method))), ) .body(Body::from( - serde_json::to_vec(&json!({ "subject": subject })).unwrap(), + serde_json::to_vec(&match method { + CREATE_RECORD => grant_body(&knot_repo, &member_collection(), subject), + _ => json!({ + "repo": knot_repo, + "collection": MEMBER, + "rkey": subject.as_str(), + }), + }) + .unwrap(), )) .unwrap(); request @@ -6064,13 +5902,13 @@ mod grant_lists { let holds = |world: &World, subject: &AccountDid| match world.state.index.member_entries() { - knot_index::Resolved::Ready(entries) => { - entries.iter().any(|(did, _)| did.as_str() == subject.as_str()) - } - knot_index::Resolved::Warming => panic!("The members projection is warming"), - }; + knot_index::Resolved::Ready(entries) => entries + .iter() + .any(|(did, _)| did.as_str() == subject.as_str()), + knot_index::Resolved::Warming => panic!("The members projection is warming"), + }; let limpet = AccountDid::new("did:plc:limpet").unwrap(); - let response = post(ADD_MEMBER, &limpet).await.unwrap(); + let response = post(CREATE_RECORD, &limpet).await.unwrap(); assert_eq!(response.status(), StatusCode::OK, "the healthy add lands"); assert!(holds(&world, &limpet)); @@ -6086,7 +5924,7 @@ mod grant_lists { .unwrap(); let whelk = AccountDid::new("did:plc:whelk").unwrap(); - let response = post(ADD_MEMBER, &whelk).await.unwrap(); + let response = post(CREATE_RECORD, &whelk).await.unwrap(); assert_eq!( response.status(), StatusCode::OK, @@ -6097,7 +5935,7 @@ mod grant_lists { "the projection refreshes even with a broken chain ref" ); - let response = post(REMOVE_MEMBER, &whelk).await.unwrap(); + let response = post(DELETE_RECORD, &whelk).await.unwrap(); assert_eq!( response.status(), StatusCode::OK, @@ -6566,7 +6404,12 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { assert!(denied["message"].as_str().unwrap().contains("author")); let deleted = ok(contribution - .erase(&world.member, &RecordCollection::new(COMMENT).unwrap(), &comment_uri, &v3) + .erase( + &world.member, + &RecordCollection::new(COMMENT).unwrap(), + &comment_uri, + &v3, + ) .await); assert!( deleted.get("uri").is_none() && deleted.get("cid").is_none(), @@ -6776,7 +6619,12 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { again["commit"]["rev"], liked["commit"]["rev"], "the second identical react should return the first commit" ); - assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 1); + assert_eq!( + records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()) + .await + .len(), + 1 + ); let rocket = ok(issue.react(&world.stranger, "🚀").await); let rocket_uri = uri_of(&rocket); @@ -6785,7 +6633,12 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { let members_uri = uri_of(&members); assert_ne!(members_uri, like_uri); assert_ne!(members_uri, rocket_uri); - assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 3); + assert_eq!( + records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()) + .await + .len(), + 3 + ); let put = failed( put_as( @@ -6807,7 +6660,12 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { message_contains(&put, &["never edited"]); let erased = ok(contribution - .erase(&world.member, &RecordCollection::new(REACTION).unwrap(), &like_uri, &like_v1) + .erase( + &world.member, + &RecordCollection::new(REACTION).unwrap(), + &like_uri, + &like_v1, + ) .await); assert!(erased.get("uri").is_none()); let (status, gone) = record_at(&world, &repo, &like_uri, None).await; @@ -6906,7 +6764,11 @@ async fn comment_outlives_its_subject_on_record_reads() { records_in(&world, &repo, &RecordCollection::new(COMMENT).unwrap()).await, vec![comment_uri.as_str().to_owned()] ); - assert!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty()); + assert!( + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()) + .await + .is_empty() + ); assert_eq!( versions_page(&world, &comment_uri, None).await["versions"] .as_array() @@ -7091,7 +6953,12 @@ async fn reaction_accepts_any_live_record_as_subject() { .react(&world.member, &reaction_uri, &reaction_cid, "❤️") .await); - assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 3); + assert_eq!( + records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()) + .await + .len(), + 3 + ); assert_eq!( ok(record_at(&world, &repo, &reaction_uri, None).await)["value"]["subject"]["uri"], drifter_uri.as_str(), @@ -7176,7 +7043,9 @@ async fn reaction_dedup_wont_answer_when_policy_rejects() { let again = ok(issue.react(&world.stranger, "❤️").await); assert_eq!(again["uri"], like_uri.as_str()); assert_eq!( - records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), + records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()) + .await + .len(), 1, "the rejected second react shouldn't create a duplicate" ); @@ -7593,18 +7462,14 @@ mod pulls { ) } - async fn merge( - &self, - actor: &Actor, - rkey: &RecordRkey, - ) -> (StatusCode, serde_json::Value) { + async fn merge(&self, actor: &Actor, rkey: &RecordRkey) -> (StatusCode, serde_json::Value) { on_repo( self.world, crate::social::merge_pull, actor, - &nsid("sh.tangled.repo.pull.merge"), + &nsid("org.tangled.review.mergePatch"), self.repo, - json!({ "repo": self.repo, "rkey": rkey }), + json!({ "repo": self.repo, "patch": rkey }), ) .await } @@ -7670,7 +7535,7 @@ mod pulls { rkey_of(&uri), ); for pinned in [base, head] { - let keep = crate::git::keep_ref(repo.as_str(), &address, pinned).unwrap(); + let keep = crate::git::keep_ref(&repo, &address, pinned).unwrap(); assert_eq!( pulls.git().find_ref(&keep).unwrap(), Some(pinned), @@ -7803,7 +7668,7 @@ mod pulls { .set_state(&world.stranger, &ticket, &from, TicketState::Closed) .await); let shut = pulls.refused_merge(&rkey_of(&uri)).await; - message_contains(&shut, &["is closed, and a closed pull isn't merged"]); + message_contains(&shut, &["is on a closed ticket"]); assert_eq!(pulls.tip(), base); } diff --git a/knot2/example.toml b/knot2/example.toml index 62b45fa18..5f6bcb1bc 100644 --- a/knot2/example.toml +++ b/knot2/example.toml @@ -195,7 +195,7 @@ #max_response_bytes = 5242880 # Upper bound on bytes that a single archive spools, -# across all our surfaces: the sh.tangled.repo.archive query, +# across all our surfaces: the org.tangled.git.getArchive query, # `git archive --remote` over SSH, # and the smart HTTP archive route. # The knot will refuse writing smth that would blast an archive past this bound.