chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# Collaboration lives in a space
**Status:** first phase built, 2026-08-27; second phase design. Successor to
`docs/plans/2026-08-24-pull-requests.md`. That plan settled what a pull request
is: a branch in one account's copy, named by `(DID, ref)`, with open and merged
read from the object graph rather than stored. This plan keeps all of that and
replaces the half that said where the copies live and how a reader finds them.
The runner's discovery indexes and its push log retire.
Two phases. The first serves the pull request list from the owner's server
with a hosted reverse index finding the records, and changes nothing about
where anything is stored. The second moves the collaboration into the
repository's space, which does the finding itself. The first is most of the
deletion and none of the risk; the second is what makes private repositories
work.
## What the page costs
The pull request list for one repository with five contributors makes 89
requests and reads five servers. Measured against the live demo stack, the
list alone is 60 calls before the browser adds its own blob probes:
| n | what |
| --- | --- |
| 22 | ranged `sync.getBlob`, one per bundle chunk of every copy |
| 20 | `repo.getRecord`, the copy's record re-read before every operation |
| 10 | `repo.listRecords`, two per account for pulls, reviews and config |
| 6 | DID documents, one per contributor |
| 2 | `app.bsky.actor.getProfiles` |
Most of that is inherent to the layout: a copy is on its author's server, so
reading five copies is five servers. What is not inherent is that the browser
does it. Every read of code went behind a `dev.pdsjs.git` method when the
extension seam landed. The collaboration reads never made that move, and they
are the only reads that still fan out from the page.
The runner has grown a second job on the way. It indexes pull records, issue
records and every ref that moves, because atproto has no reverse index and the
repository owner has no inbound address. Those three modules are an appview by
another name, kept warm by a firehose consumer that can miss.
## The space model is the pull request model
The permissioned data proposal (0016) and its implementation in
`@pdsjs/spaces` already hold the shape this branch built. Each fact below is
in the code today.
**One repo per writer, on the writer's own PDS.** `packages/git/README.md`,
under private repositories: *"Every member writes their own copy. The space
model permits no other write: a member's push goes to a `dev.pdsjs.git.repo`
record in their own repo on their own PDS, under the same space and repository
name."* Every other member's branches appear as `origin/<handle>/<branch>`.
That is `(DID, ref)`, shipped.
**The authority knows every writer and its rev.** After a space write, the
writer's PDS runs `fireNotifyWrite` (`packages/spaces/src/handlers/write.js`)
and posts `{space, repo, rev, hash}` to the authority. The authority records
the writer with `putSpaceWriter` (`packages/spaces/src/admin.js`). Discovery,
the thing the runner's index exists for, is a property of the protocol.
**A public space exists.** `policy: 'public'` authorizes any requester
(`packages/spaces/src/handlers/auth.js`). A stranger's push materialises their
copy and notifies the authority. A fork is found the same way a collaborator's
branch is.
**The browser talks to one address.** The git extension reads a space through
`host.spaceStorage`, which is gated by collection scope, not by credential. A
visitor calls a `dev.pdsjs.git` method anonymously, as for every other read.
The space's policy governs third-party syncers only.
**Cloudflare implements the port.** `packages/cloudflare/src/space.js` holds
`commitSpaceWrite`, so the production deployment is not blocked.
So the appview is the git extension on the repository owner's server, acting as
the syncer the proposal describes. It is the authority, so the notices arrive
natively. One service, because the protocol puts it there.
## One topology, public and private
One mechanism, two configurations. Which one a repository gets is a fact about
the repository, not about the design.
**A private repository is entirely in a space**, under `member-list` or
`managing-app` policy. This is what `@pdsjs/git` does today when the remote
URL names a space. The canonical copy, every contributor's copy, the pull
records, the reviews, the issues, and the checks are all space records.
Nothing is on the firehose. Nothing is served to a request with no
credential. There is no anonymous clone, which is what private means.
**A public repository is in a space too**, under `policy: 'public'`, and the
server projects its canonical record into the public repo. Anonymous
`git clone` and the firehose read the projection. The projection is the same
record value with the same blob references, written to the public repo on
every canonical write, so the bundle blobs are referenced from both and
`sync.getBlob` serves them.
The projection is needed because the space alone cannot serve an anonymous
reader. A credential for any space, public policy or not, is minted from a
delegation token whose `iss` is a user DID (`auth.js`), so a reader with no
account cannot obtain one. And smart HTTP has no space path at all
(`packages/git/src/http.js`). A public repository is one anybody can clone,
and the projection is what they clone.
One canonical, in the space, for both kinds. The alternative, a public
repository whose canonical is the public record, gives the owner one remote
and the contributors another, and makes going private a change of remote for
the owner. See the migration section.
| | private repository | public repository |
| --- | --- | --- |
| canonical copy, `main` | space | space, projected to a public record |
| anonymous clone, firehose | none | the projection |
| contributors' copies | space | space |
| `dev.pdsjs.git.pull`, the author's title and status | space | space |
| reviews and issues | space | space |
| checks and their logs | space | runner's public records |
Reviews and issues go in the space for a public repository too. Left public,
a stranger's issue still needs a runner to find. On a public space all of it
is readable by any DID, so nothing is hidden; it is pulled rather than
broadcast.
Checks follow the repository. For a public one they stay the runner's own
public records, which `listChecks` already serves. For a private one they
must not: a check record names the repository, its branches and its commits,
and the log is a blob. The runner writes them into its own writer repo in the
space. Today `requestCheck` refuses a repository in a space outright, because
a runner has no way to clone it. A runner holding a space credential does, so
CI for private repositories is new capability under this plan rather than a
thing that moves.
Merging is unchanged in shape. `mergePull` writes to the canonical copy in
the space, and the projection follows. Reachability is computed against the
canonical `main`.
Owner pushes go through the space write path, which takes an OAuth `space:`
scope or the account password and never an app password. That is the existing
rule for space writes, now applying to public repositories as well.
## Migrating between public and private
Because the canonical copy is in the space either way, a migration is a policy
flip and a projection switch. Nobody changes a remote, the owner included.
The blobs never move; which of them the public endpoints serve follows from
which records reference them.
**Public to private.**
1. Set the policy to `member-list` and add the members. A stranger who forked
keeps their copy on their own PDS, which nobody can revoke, but they leave
the writer set and their notices are refused.
2. Stop projecting and delete the public record. The bundle blobs are then
referenced only from space records, and `sync.getBlob` refuses them from
that moment.
3. The runner deletes its public check records for the repository. Its later
checks go in the space.
What this cannot do is unpublish. The firehose broadcast the history, and the
edge cache holds public blobs until they expire. Going private stops
disclosure from here on.
**Private to public.**
1. Set the policy to `public`.
2. Start projecting. The public record appears with the existing blobs, now
referenced from both sides and served.
Checks from the private period stay in the space. Later ones are public.
## Discovery is structural
Every push into a copy tells the authority who and which rev. That is better
than the runner's index in three ways:
- It is push, not poll. The pusher's PDS reports at write time.
- It self-heals. Delivery is best-effort, and correctness rests on the set
hash, so a dropped notice is caught by the next one or a sweep. A runner
that misses a firehose event has lost the pull request.
- It is attributed. The writer set is the authority's own record of who
wrote, not a third party's claim to union across runners.
In the `(DID, ref)` model there is no act of opening a pull request. A branch
in a copy that `main` does not reach is open. A push is the discoverable event.
## Derived state stays derived
Open, merged, ahead, behind and drift are computed on read, as before. The
server memoises them in memory keyed by the writers' revs, which every notice
hands it. Nothing derived is written to the space. A branch is open because
`main` does not reach it, never because a row says so. The space is a store
for what was said, not for what the server concluded.
## The push log
The UI decision is already made: version numbers and copy counts came out
(`7e247e6`, a force-push is an event, not a version number). The runner's
`versions.js` outlived that decision because two things still read it:
`replacedTips`, which marks a check or a comment as being against a commit
the branch has replaced; and the force-push line on the timeline.
`versions.js` retires. The server fetches a copy's record on every notice
anyway, so it appends `{rev, refs}` as it goes. That is the same log, fed by
the protocol instead of a firehose consumer. The stale mark reads from it.
The op log (`listSpaceOps`) carries `{rev, cid, prev}` and not old refs, so it
is not relied on for history.
Whether the force-push timeline line stays is a UI question, left open here.
It is one line with no number on it. The stale mark is a correctness signal
and stays.
## The runner is CI only
What retires from `@pdsjs/git-ci`: `pulls.js`, `issues.js`, `versions.js`,
`PDSJS_CI_PULL_SOURCES`, and the `dev.pdsjs.git.pulls`, `.issues` and
`.versions` records. 401 lines and an env var.
What the runner does instead: it registers with the space as a syncer
(`registerNotify`), takes a credential under the public policy, and checks a
copy when its notice arrives. This is the pattern `docs/permissioned-data.md`
describes for any service following a space.
## What the server serves
Two methods, beside the sixteen that exist:
- `dev.pdsjs.git.listPulls`: every copy in the writer set, each compared to
canonical `main`, with the author's pull record, the reviews against it, the
latest checks, and the drift. Open and merged in one answer.
- `dev.pdsjs.git.listIssues`: every issue statement in the space, threaded.
The browser makes three calls for the pull request list where it made 89. The
`(DID, ref)` reader in `collab.js` moves server-side largely as it is; what it
loses is `forkedPulls`, the runner-index union, and the five-server fan-out.
## Discovery is a port
Every reader of this repository's collaboration asks one question first:
which records, in which accounts, are about this repository? The answer is a
list of `(did, collection, rkey)`, and the reader follows each entry to the
author's own PDS and reads the record there. Whoever answers the question can
add noise or omit; they cannot forge a word, because the word is read from
its author.
That question is a port on the server, with two providers:
- **A hosted reverse index over the public firehose**, for a public
repository. Constellation (`constellation.microcosm.blue`) walks every
record on the firehose and indexes each link by its target, the linking
record's collection, and the JSON path to the link. One query per
collection answers the question for `dev.pdsjs.git.pull`, `.issue` and
`.review`, each of which names the repository at `subject.uri`.
- **The space's writer set**, for a repository in a space. Every writer's
copy and statement is in their writer repo, and the authority holds the
set. This is the second phase.
The browser never sees which provider answered. It calls `listPulls`.
## The first phase: a public repository, found by Constellation
Proven against production rather than read from the documentation. For
`at://did:plc:ye5hhvya5o2fwvouv3wttkkn/dev.pdsjs.git.repo/pds.js`, the source
`dev.pdsjs.git.check:subject.uri` answers 248 backlinks, each as the runner's
DID, the collection and the record key. The index walks a lexicon it has never
seen and a link two fields deep, which were the two open questions. Pull,
issue and review counts are zero there only because nobody has filed one on
production; the demo stack is local and off the firehose.
What this phase keeps: every record where it is today. Copies are public
records on their authors' PDSes, and the `(DID, ref)` reader in `collab.js`
moves to the server largely as it is. Contributors may be on any PDS.
What it skips: the device grant, the projection, and the authority reading a
copy across servers under a credential. All three belong to the second phase.
What it costs:
- **Public repositories only.** A space's writes never reach the firehose,
so the index knows nothing of a private repository. That is the second
phase's job.
- **A dependency on a hosted service with no stated SLA.** The reader's
existing fallback for an unreachable runner applies unchanged: the config's
collaborators are listed directly, and only strangers go unfound. The index
answers which accounts hold records, and that answer caches. What does not
cache is the object-graph read below: for each copy the index names, the
server reads its refs, walks its branches against main, and derives open,
merged, ahead, behind, drift and conflicts. That derivation is per request
in the first phase. The memo that caches it needs an invalidation signal,
which the second phase's notices are.
- **A stranger surfaces only through a statement.** A copy's record carries
no link to what it was copied from, so a bare push into a fork is invisible
to any index, as it is to the runner's today. An `upstream` field on
`dev.pdsjs.git.repo`, written by the helper on push and naming the
canonical record, makes the push itself the discoverable event. One
optional string; a record without it reads as it does now.
- **No push log.** The stale mark falls back on the tips the checks and the
reviews name, which is the fallback the last plan already provided for a
reader that cannot reach the runner.
Production is behind `main`: both deployed servers answer `501
MethodNotImplemented` for every `dev.pdsjs.git` method. Deploying is the
prerequisite for either phase reaching a real repository.
On a local stack the index sees nothing, since nothing local reaches the
public firehose. A stranger's fork is absent from the local demo until the
second phase, and present in production from the first.
## The unbuilt piece
The authority reading a writer's copy on the writer's PDS. Today
`browser.js` opens a space repo with `spaceReader.getRecord(space, repoName)`,
which names no writer and reads only the local authority record.
`space-remote.test.js` covers the owner's push and nothing across servers.
The credential chain exists for the CLI helper: authenticate to one's own PDS,
obtain a delegation token, exchange it at the authority for a credential bound
to a key, present it with a DPoP proof. The server walks the same chain as its
own authority. Blobs come the same way: a copy's bundle chunks are read through
`com.atproto.space.getBlob` on the writer's PDS under that credential.
This is the core of the work.
The projection needs one hook beside it. The git extension has to learn of
the authority's own writes into a public-policy space, to write the public
record. Today only an external syncer can register for notices
(`registerNotify`); the authority's notify handler is where the write is
known, and an in-process subscriber is the missing piece.
Everything else in this plan is deletion.
## What this accepts
**A contributor needs a PDS that implements 0016.** Today that is pds.js. The
reference PDS carries it as a moving PR. Say it in the README as a position:
this is the permissioned-data-native forge. The accounts contributing to
pds.js-hosted repositories today are all on pds.js; the contributor on a host
without spaces arrives when that host ships them, and needs nothing built for
them in the meantime.
**0016 is a proposal.** If it moves, `@pdsjs/spaces` tracks it and this moves
with it. The surface this plan stands on is small and the most settled part of
the proposal: per-writer repos, the writer set, `notifyWrite`, credentials.
## Signing in without a browser
A space write takes an OAuth session with a `space:` scope or the account
password, and never an app password (`packages/git/README.md`,
`isAppPasswordScope` in `packages/core/src/scope.js`). With every canonical
copy in a space, that rule reaches every push. Two clients cannot meet it
today.
The remote helper authorizes as a loopback client (`packages/git/src/oauth.js`):
it opens a local HTTP server and needs a browser on the same machine. A push
from an SSH session, a container or an agent has none. Its other path is an
app password through `git credential`, which the space refuses.
The runner signs in with `PDSJS_CI_PASSWORD`, an app password
(`packages/git-ci/src/cli.js`). As a space syncer it needs a credential, which
takes a delegation token, which takes a session with space access. An app
password session has none. The account password in an environment variable
is not the answer.
What closes it is the device authorization grant (RFC 8628), which this
server can add because it is its own authorization server:
- a device authorization endpoint that issues a device code, a user code and
a verification address;
- the `urn:ietf:params:oauth:grant-type:device_code` grant at the token
endpoint, answering `authorization_pending` until the user consents;
- a page under `/account` that takes the user code and shows the consent
screen the authorization-code flow already has.
The helper gains `login --device`: print the address and the code, poll,
store the DPoP-bound session the way it stores one today. The runner does
the same once at first start and refreshes from then on; the refresh token
and the DPoP key it is bound to already travel together in the session file.
The loopback flow stays for a machine with a browser.
## A read reaches where a DID says
A pull request or an issue names an account by DID, and the server resolves
the DID document and reads its `serviceEndpoint` to fetch the records. A
`did:web` the caller controls names any endpoint, so the server makes a
request to a URL a request chose. It is bounded: a GET of `com.atproto.repo.*`
and `dev.pdsjs.git.*` paths, the answer parsed as records. On the open
internet that reaches only what any client could. Behind a network where an
internal host answers on those paths, a deployment should resolve DIDs
through a directory it trusts and hold a resolved endpoint to public hosts.
This is the ordinary shape of a service that follows atproto identities, and
the note is here so a private deployment reads it before it ships.
## Order to build in
**First phase.** Each step lands green on its own; the last three are
deletions and nothing in them is a move mixed with a change.
1. Deploy `main` to production, so the git methods answer at all.
2. The discovery port, with the Constellation provider. `listPulls` and
`listIssues` over it, reading each copy and statement from its author's
PDS as the browser does now. The index answer caches; the object-graph
derivation over each copy does not, until the second phase gives it an
invalidation signal.
3. `@pdsjs/git-ui` reads the two methods. The fan-out, `forkedPulls` and the
runner-index union go.
4. `@pdsjs/git-ci` loses `pulls.js`, `issues.js`, `versions.js` and
`PDSJS_CI_PULL_SOURCES`.
5. `upstream` on the repo record, written by the helper on push.
**Second phase.**
6. The device grant, on the server and in the helper and the runner. Every
later step pushes into a space from somewhere without a browser.
7. The authority reads a writer's copy across PDSes, records and blobs, with a
test that pushes from a second server. This is the risk of the phase.
8. A companion space per public repository, `policy: 'public'`, named in the
repository's config. The contributor's remote URL names it.
9. The projection: the authority's canonical record in a public-policy space
written to the public repo on every change, through an in-process notice.
Anonymous clone reads it; the demo's five servers prove it.
10. The writer-set provider behind the discovery port. The ref snapshot per
notice. The memo keyed by rev. Private repositories list for the first
time.
11. `@pdsjs/git-ci` registers as a syncer and checks repositories in spaces.
12. The demo seed pushes every copy, the owner's included, to the space
remote.
13. The README position statement.