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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
218 lines · 10.1 KB Raw History
# @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 only | `atproto space:*?authority=*&skey=*&action=read` |
| Read and write | the 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

```sh
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

```sh
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.