~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
README.md

@pdsjs/spaces-ui

A browser for the permissioned spaces an atproto account holds. Sign in with a handle, and every space the account has written to is readable: its records, its write log, its blobs, the members the authority admits, and the other repos that hold data in it. A space the account hosts is administrable too: its two policies, its member list, and deleting it.

It deploys to an account's own site and talks to whichever PDS the visitor signs in to, so one deployment serves anybody whose server implements permissioned data.

React, shadcn conventions and Base UI, the same stack the account pages use.

What a space is, and why one screen is not enough

A space is one dataset spread over the repos of everyone who writes to it, and each of those repos is served by its author's own PDS. Two servers therefore answer about any space:

  • The repo host serves the records one account wrote. That is the account's own PDS, and it is where listRecords, getRecord, listRepoOps, listBlobs and every write are addressed.
  • The authority serves the space itself: who may join, which clients may reach it, and the list of repos that have written to it. That is the PDS of the DID in the space URI, which is often somebody else's.

Hosts disagree about whether an authority call needs authentication. pds.js serves listRepos to anybody; pds.zat.dev and vesuvius.pyroclastic.cloud both answer 401 without a credential, and the lexicon settles it either way. So every authority call is made the same way a repo read is: the session for your own server, a space credential for anybody else's, and an anonymous read only where no credential can be had at all.

The app keeps the two apart. A screen showing your own records reads your server. The same screen for another member reads theirs.

Finding a space

listSpaces reports the spaces this account has written to, which is what a PDS tracks. A space somebody has admitted this account to but which it has never written in appears in no list anywhere, so the list page takes a space URI directly. That is the only route to such a space, and it is the same route for a space under anybody's authority.

Reaching another member's repo

Nothing about a space gives this page the right to read a repo it does not own. The proposal's exchange is what does, and the page runs all three steps:

your PDS   getDelegationToken   a token saying you delegated to this page
authority  getSpaceCredential   exchanged for a credential for the space
their PDS  listRecords, …       the credential, signed by its key

The credential is bound to a key. The page makes a P-256 key for it, names it as credentialKid when it asks for one, and signs every request afterwards with it, together with the account the request is addressed to. A credential copied out of this browser is no use to whoever copied it. It is minted on the first read of somebody else's repo and reused until it is nearly expired.

A read of a commit or a record with the credential also asks for a MAC. The page makes a fresh key share against the author's #atproto_kem key, sends it in Atproto-Space-Want-Mac, and checks the Atproto-Space-Mac that comes back before it shows the answer.

The Credential screen on any space runs the exchange on demand and shows what each server returned: both tokens decoded, which host issued each, how long each lives, and whether the credential's cnf.kid is this page's key. It is the one screen that exists for reading rather than for browsing, because an authority's refusal is otherwise a single line of error text.

Signing in

Full atproto OAuth in the page: PKCE, pushed authorization, and a DPoP key this document cannot read. There is no backend, so this browser is the only place a token can be kept, and DPoP is what makes that safe rather than merely convenient.

The key is generated non-extractable and kept in IndexedDB, which is the only store that holds a CryptoKey as a key rather than as bytes. Signing out forgets it, so the next sign-in cannot be linked to the last.

Two access levels are offered, and the difference is real rather than a display choice. The server enforces it:

scope
Read onlyatproto space:*?authority=*&skey=*&action=read
Read and writethe above plus collection=*, the three write actions, and manage=create&manage=update&manage=delete

A read-only session asking to write is refused by the PDS with Scope does not permit create, not by this page hiding a button.

collection=* is what makes the write scope usable here. A space scope naming no collection grants writes only in the collections the space type declares, and a browser that works against any space type knows no such declaration.

Resolving who you are

Both lookups an atproto client normally hands to a backend happen here over public HTTPS: a handle's _atproto TXT record through a DNS-over-HTTPS resolver, then the DID document from the directory. A handle resolved through DNS is checked against the document's own alsoKnownAs, so a TXT record naming somebody else's DID resolves to nothing rather than to them.

VITE_PLC_URL and VITE_DOH_URL name those two services at build time. The defaults are https://plc.directory and Cloudflare's resolver.

Pictures, without telling anyone what you are reading

An account's picture and an app's icon are decoration, and decoration is not worth a disclosure. Both are read the same way, from a PDS.

A profile is an ordinary public record in an account's own repo and the picture is a blob beside it, so both come from that account's own PDS. For a space's members and its authority that host is already being read from, which is what makes the picture free: no appview is asked which accounts this browser is looking at, and no CDN is asked for the image.

An app's icon is the same thing. A space type is an NSID, the domain it reverses to is an atproto handle, and that account has a picture like any other: my.bulletin.board is bulletin.my. A favicon would instead mean asking the app's web server, or an icon service that has seen every domain anyone ever asked it about, and either is then told which apps' spaces are being read. Resolving the handle costs one lookup through the resolver this app already uses for every handle.

What remains: an app whose account is hosted somewhere this app has no other reason to contact, which learns that somebody fetched that app's picture. That is a much smaller thing than the member list of a private space, and a deployment that wants none of it can leave the pictures out.

Neither lookup is load-bearing. A domain that is no handle, an account with no picture, or a host that does not answer all read as an initial.

A handle on a domain with no public DNS resolves nowhere, which is every .localhost account and any account mid-handle-change. Typing such a handle on the site its own account serves falls back to the DID that origin publishes, so it signs in anyway. A DID typed directly always works, which is what a local stack wants.

The field starts empty. A deployment serves whoever visits it, and filling it with the host's own handle would be wrong for everybody else.

The client registration

An atproto OAuth client is named by the URL that serves its registration, so the deploy writes client-metadata.json beside the page and the client_id is that file's address. It names the origin it is served from, which is why a deploy has to be told where the site answers.

A copy deployed for one origin cannot sign in from another. The page checks this on load and says so rather than failing at the redirect.

On loopback there is nowhere to host a registration, so the client_id carries its own in query parameters, which is what the atproto profile defines for a client running on the user's device. npm run dev needs no deploy.

Deploying

PDS_URL=https://pds.example.com \
PDS_DID=did:plc:… \
PDS_APP_PASSWORD=… \
SPACES_ORIGIN=https://spaces.example.com \
npm run deploy --workspace @pdsjs/spaces-ui

SPACES_SITE names the site record, default spaces, so it answers at spaces.<your site domain>. SPACES_ORIGIN is that hostname with its scheme, and the deploy refuses to guess it.

The page is one self-contained document, for the reason git-ui's is: the site record names each file by path and the page HTML caches for minutes, so a build split across hashed assets would leave a visitor holding old HTML asking for a script the record no longer names. Fonts stay on the system stacks, because every asset is inlined and a webfont would multiply the document's size.

No dev.pdsjs.app.manifest is published. An install copies the site's blobs into somebody else's repo, and the registration among them would still name this origin, so the installed copy could not sign in. Deploying it is the way to run it on your own server.

Running it against a local stack

just dev-spaces

That brings up a local PLC, two PDSes, a space alice hosts with bob writing into it from his own server, and this app on http://localhost:5174. Sign in with the DID it prints. Two servers is the point: reading bob's repo is the whole credential exchange, and nothing short of two exercises it.

Administering a space

Everything an authority may do is offered for a space this account hosts, and for no other: the member policy, the client policy, the member list, and deleting the space. updateSpace and listMembers both refuse a caller who is not the authority, so this is what the server admits rather than what the page chooses to show.

Deleting ends the authority, not the data. It stops credentials being issued and tells every registered syncer the space is gone; each member's repo keeps the records it wrote, which is what the proposal has a repo host do.

Limits

  • The record and op lists show a first page. Older records are behind a cursor the screens do not follow yet.
  • Writes go to the signed-in account's own repo. That is the protocol's rule rather than this app's: a host refuses a write naming anybody else.
  • No blob upload, so a record naming a blob the account does not already hold cannot be written here.
  • No getRepo, so there is no CAR export of a space repo.
  • No applyWrites, so records are written one at a time.