chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# @pdsjs/core
## 2.5.2
### Patch Changes
- Publishes to npm the changes 2.5.1 carried, which never reached it: the
release workflow refused while changesets for private packages sat unconsumed.
Private packages now take their changesets on versioning, though they still
never publish.
## 2.5.1
### Patch Changes
- 542c4dc: Firehose `update` and `delete` ops carry `prev`, the record CID they replaced.
Sync 1.1 consumers invert each commit against `prevData` and need it to do so;
without it jetstream's verifier fails the commit, so edits and deletes never
reached anything reading jetstream.
## 2.5.0
### Minor Changes
- f79dd82: Feature packages mount on the server as extensions. `PersonalDataServer`
takes an `extensions` option: factories core calls with an `ExtensionHost`,
which carries the storage ports, the space ports when spaces are on, and
late-binding `getDid`, `xrpc`, `createCommit`, `getSigner`,
`reapOrphanedBlobs`, `accountApi` and the server's composed `writeGuard`.
An extension contributes routes, a handler for paths under prefixes it
declares, a handler for hostnames other than the server's, a write guard,
a commit tap, account feature flags and rail counts, and declares the record
collections it owns: the storage on its host refuses a record call naming
any other, a commit refuses an op naming any other, and a self-addressed
`xrpc` call is anonymous whatever credentials the request carried. Core
merges the pieces: a route core already serves is refused,
a prefix core serves under or that overlaps another extension's is
refused, route handlers are called unbound, write guards compose over the
union of their collections and apply to space writes as well as public
ones, and every commit tap runs in its own try/catch. The types are
`ExtensionFactory` and `PdsExtension` in `@pdsjs/core/ports`.
Every feature package is on the seam, each from its `/extension` export:
`createGitExtension({ http, browse })`, `createDriveExtension({ upload })`,
`createSitesExtension({ domain, apexName, queries })`,
`createSpacesExtension()`, `createOciExtension()` and
`createNpmExtension()`. Each serves its own `/account/api` routes through
the host, so those routes, their helpers and their tests move out of core's
account handler into the packages. The git and drive packages build the
space admin methods they use over the host's storage from `@pdsjs/spaces`,
now an optional peer of each. `@pdsjs/deno` mounts the git and sites
extensions whenever those packages are installed, the same as
`@pdsjs/node`, and the sites extension mounts with or without a domain so
the installs listing is always served.
For a deployment nothing changes: `createServer` on node and deno takes the
same `spaces` and `experimental` options, the Worker reads the same
`PDS_ENABLE_SPACES` and `PDS_EXPERIMENTAL_*` vars, and every endpoint and
account API path answers as before. What changes is the constructor of
`PersonalDataServer` itself, for a platform that embeds core directly: the
feature-wiring options `spaceRoutes`, `spaceBrowser`, `spaceAdmin`,
`siteHandler`, `siteBrowser`, `siteInstaller`, `queryRoutes`,
`gitBrowser`, `driveBrowser`, `driveWriter`, `gitHttpEnabled`,
`ociEnabled` and `npmEnabled` are gone, replaced by `extensions`;
`spaceStorage` stays, and core builds the space browser over it. Those
options were the seam between core and its own adapters rather than a
supported embedding surface, which is why this ships as a minor.
- 93816a1: A space governs reads and writes separately, tracking
[atproto PR #5187](https://github.com/bluesky-social/atproto/pull/5187).
`createSpace` and `updateSpace` take a `readPolicy` and a `writePolicy`,
`getSpace` answers with both, and `com.atproto.simplespace.putMember` sets a
member's `read` and `write` rights, which `listMembers` reports. The read
policy decides who may hold a space credential. The write policy decides whose
`notifyWrite` the authority records, so a writer the space does not admit stays
out of the writer set and no syncer pulls their repo. The authority passes
`access` to a managing app's `checkUserAccess`, and omits `clientId` on a write
check: that notice comes from a PDS, not an app.
The earlier spellings still answer. A single `policy` sets both sides,
`addMember` adds a member who reads and writes, and `getSpace` sends the two
unions, the single one, and the `#spaceConfig` object together. A space created
before the split keeps its policy on both sides, and an existing member keeps
both rights.
A delegation token and a client attestation are each spent once. Both must
carry a `jti`, and an authority that has honoured one refuses it again, so a
captured credential exchange cannot be replayed.
A client attestation is verified rather than read. The authority resolves the
`iss` to its `client-metadata.json`, fetches the key set that document
publishes, and checks the signature against the key its `kid` names. An
`appAccess` allow list rests on that check, and until now stated a rule the
authority could not enforce.
`SpaceStoragePort` replaces `addMember` and `isMember` with `putMember` and
`getMember`, `listMembers` answers with `members` rather than `dids`, and
`SpaceRow` carries `readPolicy`, `readManagingApp`, `writePolicy` and
`writeManagingApp`. Both SQLite adapters add the columns to a database they
already hold. `@pdsjs/core/oauth` exports `fetchClientMetadata`,
`resolveClientJwk` and `verifyEs256WithJwk`, which the OAuth token endpoint and
the space authority both use.
- 3d475f8: Permissioned data tracks the October 1 spaces alpha of the reference
implementation (`0.0.0-spaces-alpha-20261001173819`, atproto
`permissioned-data-alpha` at `79d6307e1`).
A space credential is bound to its holder's key with an RFC 9421 HTTP message
signature instead of DPoP. The application signs the `getSpaceCredential`
request's `Authorization` header with a fresh P-256 key, named as a did:key in
`keyid`, and the authority binds the credential to it as `cnf.kid`. A request
presenting the credential uses the `Atproto-Space` scheme and an
`Atproto-Space-Audience` header naming the account it is addressed to, and
signs both. A host refuses a signature made for another account
(`BadSpaceAudience`) or by another key (`BadSpaceSignature`). DPoP credentials,
the `dpopJkt` field and the `DPoP` presentation scheme are no longer accepted.
`@pdsjs/spaces/http-signature` signs and verifies both shapes, and the git
remote helper and the spaces inspector sign with it.
A credential lives ten minutes by default and never more than an hour, and must
carry a finite `iat`, an `exp` after it and a `jti`. A repo host serves
`com.atproto.space.notifyCredentialRevoked`: a space's authority names up to 100
credential ids, and the host refuses those credentials with
`CredentialRevoked` for an hour and ten seconds.
The authority sequences the writer set. Each accepted `notifyWrite` gets a
`spaceRev` that increases across the space, and the notice forwarded to a
syncer carries `repoRev`, `spaceRev` and `prevSpaceRev`. A repeated or older
repo rev is not forwarded again, and a repo rev more than five minutes ahead is
refused with `FutureRev`. `listRepos` entries carry `repoRev` and `spaceRev`,
are ordered by `spaceRev`, and page on a space-revision cursor that every
non-empty page ends on. A writer recorded before this release is sequenced at
its repo rev. The authority accepts a notice addressed to
`<authority>#atproto_space_host` as well as to its bare DID, and a body naming
`rev` instead of `repoRev`.
A repo host stores each write notice it owes an authority before sending it,
and retries a transient failure with backoff for up to a day, keeping only the
newest notice per space. Extensions may declare a `tick`, which core runs
through `runExtensionTicks`: Node and Deno every ten seconds, Cloudflare at its
alarm, which an extension brings forward with the host's new `scheduleWakeup`.
`createSpace` and `listSpaces` take `spaceType`, and still read `type`.
`SpaceStoragePort` changes: `putSpaceWriter` takes a `spaceRev`, writer rows
carry `repoRev` and `spaceRev`, and the port gains `getSpaceWriter`,
`getSpaceRevHead`, the revocation methods and the notice outbox methods. The
schema adds `space_writer.space_rev`, `space_credential_revocation` and
`space_notify_outbox`, migrated in place.
- 078c2df: The S3 backup target sends an object past 8 MiB as a multipart upload, and
accepts a blob as a stream. A single PUT carrying a whole blob is what a
provider answers HTTP 413 to, and one such blob failed the entire snapshot:
the blob loop runs before the repo CAR and the manifest, so the run wrote
nothing at all. Reading the blob whole also put a video-sized buffer in an
isolate that has 128 MB.
A backup now streams each blob from the store to the target one part at a
time, so neither side ever holds more than a part. `BackupTargetPort` gains
an optional `putStream`, and a run counts the bytes as they pass, which keeps
its heartbeat beating across the parts of one large blob.
- d5f111c: The Bluesky video service can write a processed video back to the PDS. Two
things stood between it and `uploadBlob`.
`com.atproto.server.getServiceAuth` honors the requested `exp`, up to an hour
ahead. A token minted for a video upload has to outlive the transcode, which
takes longer than the minute every token used to last. An expiry in the past,
or more than an hour out, answers `BadExpiration`.
`uploadBlob` accepts a service auth token as a `Bearer` credential, alongside
the session and OAuth credentials every route takes. The token must name
`com.atproto.repo.uploadBlob` in its `lxm`, address this server in its `aud`,
and carry the hosted account's DID as its `iss`, and its signature verifies
against the account's signing key. No other route takes one.
### Patch Changes
- d66fcff: The default blob upload limit is 50 MiB. An upload streams to storage, so
the limit bounds cost rather than memory, and 50 MiB stays under the 100 MB
request body a Cloudflare Worker accepts on every plan. `PDS_BLOB_UPLOAD_LIMIT`
still overrides it.
git-remote-atproto uploads a bundle in 8 MiB chunks. A server that refuses
a chunk as too large gets the push again at half the size, down to 64 KiB,
so a reference PDS at 5 MB still takes it. Existing bundles keep their parts
until a repack rewrites them.
- 05a11b4: A CORS preflight allows the headers the browser asked for. The Bluesky app
sends `x-bsky-is-beta-user`, which the fixed allow-list did not name, so the
browser blocked every proxied `app.bsky.*` call and bsky.app could not read an
account hosted here. The preflight now answers `Access-Control-Request-Headers`
with the same names it was given, so a client header this server has never seen
still gets through and a new one costs no release. The AppView proxy forwards
every `x-bsky-*` header a client sends, rather than the one it named.
- e0b38cd: `dev.pdsjs.git.deleteRepo` takes a repository: the record, the config record
beside it, and the bundle blobs neither of them holds any more, freed at once
rather than at the hourly orphan sweep. The write guard reads the config for
its protected branches before either record goes, so a repository holding one
refuses with the branch named, which is the refusal the guard already had
words for.
A private repository keeps its space, which is keyed by the repository name
and is reused by a repository created under that name later. Its member list
goes with the repository, so the next repository of that name admits nobody
its owner did not add.
`SpaceAdminPort` gains `deleteRecord`, over the `delete` action the space
writer already applies.
- cc4577b: `POST /account/api/git/merge` puts a pull request's work on a repository's
canonical branch, in either merge or squash mode.
Merging is the one act in this model that needs authority over the repository,
so it runs on that account's own server under the session cookie the account
page already answers to. An atproto `repo:` scope names a collection and its
actions and cannot name one record, so asking a browser page for
`repo:dev.pdsjs.git.repo` would hand a token in IndexedDB create, update and
delete over every repository the account holds. The cookie is the narrower
grant, and the repository browser reaches the endpoint because it is served
from the account's own site.
`createGitMerger` is the seam. The pack format is the git package's and the
authority over the record is the PDS's, so the platform builds the merger from
storage it owns and injects it as `gitMerger`, the way it already builds the
write guard. Core carries no dependency on the git package.
A merge advances the canonical branch to a commit whose first parent is its
current tip, so it is a fast-forward by construction and cannot force-push or
delete a protected branch. The write of the repository record consults the
server's composed write guard, the git rules among them, so the same rule a
bearer-token write is held to holds a merge, which is the backstop should the
merge ever produce anything but a fast-forward. The new bundle carries a pack
index, so the chain stays readable through ranged blob reads rather than
downloading whole.
The pull request page gains Merge and Squash for the account that owns the
repository. A branch main has moved under is told to rebase rather than
offered a button that cannot work.
- 7c06024: Add `git-remote-atproto pr create` to publish a pull request record from the
current checkout. The record lives at the key the branch's ref derives, and
creating refuses when a record already holds that key, so a second create
never clobbers the words already there.
- 6935a55: Add `git-remote-atproto pr merge` to put the current branch's pull request on
the repository's canonical branch, either as a merge commit or a squash.
Merging is the repository owner's act, so a fork merge runs with the owner's
own session; the command refuses when that session is not stored, rather than
letting the server answer with a token error.
- 15c69af: Add `git-remote-atproto pr update` to refresh the current branch's pull
request with a new title, description and draft word, and the tip commit at
HEAD. The record lives at the key the branch derives, so an update rewrites
it in place rather than appending, with a compare-and-swap against the
version read.
A pull request is one record at `dev.pdsjs.git.pull/<repo>:<ref>` (the
record key the branch's ref derives) rather than a newest-wins stack of
appended records. The reader, the CLI and the UI all agree on that key, so
an update is always the current word.
- 411b47b: The git extension serves its reads and writes as XRPC methods under
`dev.pdsjs.git.*`, each described by a lexicon: `listRepos`, `getRepo`,
`getTree`, `listFiles`, `getFile`, `getRaw`, `listCommits`, `getCommit`,
`getLastCommit`, `listChecks`, `getAuthors`, `createRepo`,
`updateSettings` and `requestCheck`. A read of the bundle chain keeps the last four repositories
opened and downloads a chain without object indexes whole only up to
`eagerLimit`, answering `RepositoryTooLarge` past it. A read of
the public repo is anonymous and answers under the record cid as its ETag; a
read of a space repository and every write take the owner's account session,
or for a write a bearer token with the `repo:` scopes of the collections it
writes. `community.lexicon.service.describe` lists the methods a server
serves. The `/account/api/git` routes and `/git/<account>/<repo>/last-commit`
are gone; the methods replace them.
An extension host gains `readAccountSession` and `readJsonBounded`, so a
route that answers in its own shape can take the account session alongside a
bearer token.
The account application's repositories pages call these methods, with the
raw file view served through `getRaw`.
- 861663f: Startup no longer adopts a ref whose latest check is still `running`. A run
cut off mid-flight leaves its check record half written, and adopting the ref
told the daemon that ref was handled, so nothing ever finished the record and
the check read `running` for good. The runner's own check records outlive its
state file, so startup reads them: a ref left mid-run stays unadopted and runs
again on the next event for that repository.
- 154e7d7: Pushes write an object index beside each git bundle, and a reader that finds one opens the repository from the index blobs alone: trees, files, history and diffs fetch only the objects they show, as ranged blob reads. A chain without indexes downloads whole as before. The PDS CORS allowance now includes the Range header, so a page on another origin can make those reads against getBlob. The repository browser opens an indexed repository near-instantly either way.
- 10c91f6: A repository can name the packages it builds. The `dev.pdsjs.git.config`
record grows a `packages` list of `{registry, name}` entries, which
`linkedPackagesOf` reads and the write guard checks; a package record names no
repository and atproto has no reverse index, so this list is what connects the
two. The account page's repository settings offer the npm packages and
container images the account has published, and `POST /account/api/git/settings`
takes the chosen list. `@pdsjs/npm` and `@pdsjs/oci` export their lexicon
module under `./lexicon`, so a browser reads a package record key without
pulling in the registry server.
- 77eb9db: The account page grows a Packages section when the npm registry is on: the
hosted packages with their dist-tags, versions, sizes and copyable install
commands, served by `GET /account/api/npm/packages` from the
dev.pdsjs.npm.package records. The rail counts the packages the way it
counts container images.
- 76f5187: The account page grows a Registry section when the OCI registry is on: the
hosted container images with their tags, sizes and a copyable pull command,
served by `GET /account/api/oci/repos` from the dev.pdsjs.oci.repo records.
The rail counts the images the way it counts git repositories.
- b51250e: A blob the backup target refuses costs the snapshot that blob and nothing
else. The blob copy runs before the repo CAR and the manifest, so a single
failure there used to end the run with nothing written at all, and every
scheduled run after it died on the same blob.
A failed blob is named in the manifest's `missingBlobs`, with the reason in
the new `blobErrors`, and the run carries a `blobsFailed` count that the
backups page shows against the run. Blobs are content-addressed and copied
only when the target does not already hold them, so the next run tries a
failed blob again. A target that refuses everything still fails the run: the
repo CAR goes to the same target a few lines later.
- 799286b: A pushed authorization request may name its DPoP key with the `dpop_jkt`
parameter rather than a proof, and one that names it neither way is accepted.
RFC 9449 §10.1 allows both shapes and the reference authorization server takes
both, so clients in the wild send them: trezy.pet pushes without a proof and
received `invalid_dpop_proof`, which ended the login before the consent screen.
A proof still carries the nonce and replay checks, and a `dpop_jkt` that
contradicts it is refused. A request that named no key binds one at the token
endpoint, which requires a proof as it always has.
- 4adc49e: A permission set's space entry now grants its `manage` verbs, not only its
record actions. An application that asks for `include:<set>` and then creates
its own space was refused twice over: its client-side gate read the granted
scope and found no `manage=create`, and `com.atproto.simplespace.createSpace`
checked the same absent grant. A `manage` op this server cannot read drops
itself and leaves the record actions, because an absent `manage` param grants
no verb at all.
- 9149663: A migrated account announces its identity until a relay hears it. An AppView
takes an account's signing key from its own identity store, filled by the
`#identity` event on the firehose, and the `forceRefresh` its token verifier
asks for is not propagated. So the announcement `activateAccount` makes at the
end of a migration reaches nobody when no relay has subscribed yet, and nothing
retries it: every service token the account mints is then rejected against the
key it used to have, and feeds, notifications and profiles fail with
`jwt signature does not match jwt issuer`.
The hourly maintenance tick now calls `maybeAnnounceIdentity`. It asks the
configured relay whether that relay reads this server, and announces again
while the answer is no, so the event lands once a subscription exists. It stops
as soon as the relay reports it is reading. `activateAccount` also asks for the
crawl before it announces, rather than after.
- fd401a4: The account rail scrolls its section list. A short window no longer cuts off
the sections below the fold: the links scroll on their own while the account
header and the sign-out hold their place at the top and bottom.
## 2.4.3
### Patch Changes
- d307f57: The daemon reads each watched repository's refs at startup when its state
file says nothing about them. Without that, a daemon whose state was lost
read every ref in the record as new on the next event and started a run for
each, so every tag ran its release workflow again. The first run now comes
from a ref that moves after startup, which is what starting at the live head
means. A repository whose record cannot be read is still watched, and its
first event still runs it.
- 2d0d653: The runner image carries the docker client and buildx, and the entrypoint
puts the daemon's user in the group of a mounted docker socket. A deployment
that mounts one can then run workflows that build, run and push container
images, which previously needed a forge. The socket is the whole grant: a
step commanding the host's daemon is root on that host, so mount it only
where every contributor to every watched repository is trusted that far.
Without the mount nothing changes and no step can reach a daemon.
## 2.4.2
### Patch Changes
- c235e96: Workflow files may be YAML. A step is a shell script, and a block scalar
carries one as written where a JSON string carries it as a single line of
escaped newlines. The parser is YAML 1.2, which subsumes JSON, so existing
`.json` workflows and the `.pdsjs/ci.json` fallback read unchanged. The
workflows directory now reads `.yml`, `.yaml` and `.json`, and passes over
anything else, so a README can sit beside the workflows it describes.
## 2.4.1
### Patch Changes
- fa1558c: The runner reads one workflow per file from `.pdsjs/workflows/*.json`, falling
back to `.pdsjs/ci.json` when the directory is absent. Every workflow naming
the moved ref runs and publishes its own check; the latest-check record holds
one entry per workflow under the unchanged key, and the account checks
endpoint flattens the entries, reading records from before entries existed as
one entry. Workflows declare secrets by name, and the daemon grants them per
repository from the file `PDSJS_CI_SECRETS` names; the runner's own
credentials no longer reach any step's environment. The runner image is based
on trixie so a repository under test can load prebuilt native addons that
need glibc 2.38.
## 2.4.0
### Minor Changes
- ee6d696: A release channel setting on the account page's Server software card. Stable
follows tagged releases; canary follows the newest build of the main branch,
published to canary/ on the artifacts site by CI on every push. The update
check asks the channel's own source, and the update link carries the channel
and the worker script name, so the update page installs the right build and
can find a worker behind a custom domain (new PDS_SCRIPT_NAME var, written by
wizard deploys and refreshed on every update).
- 54bec84: A drive: files and folders kept in the account's own repo, under Labs on the
account page. Files upload through `/account/api/drive/upload`, split across
blobs when they exceed the server's upload limit, and download whole or by byte
range. Folders are records of their own, so an empty one stays and a rename
leaves every file's key alone. A drive can live in the public repo or in a
permissioned space, where its blobs are private.
Off unless composed in: `experimental.drive` on the Node adapter (on when
`@pdsjs/drive` is installed), `PDS_EXPERIMENTAL_DRIVE` on Cloudflare.
### Patch Changes
- f716d9d: The account page can ask a repository's runner to check a branch again.
`POST /account/api/git/rerun` writes a `dev.pdsjs.git.checkRequest` record into
the account's own repo, which the runner reads off the firehose, and a "Run
again" button sits beside the check on the repository page. The check card now
appears for a repository whose runner has published nothing yet, which is the
one someone most wants to start a run on. A repository in a space has no public
clone URL, so the endpoint refuses one.
- 4c334b3: Repository settings writes carry over the config record fields they do not
set. A protected branch change kept only `createdAt` and dropped everything
else the record held, so any other setting was lost on the next write.
The settings endpoint also reads and writes a `runner` DID, naming the account
whose check records describe the repository. Send `null` to clear it.
`GET /account/api/git/checks` reads the latest check on each branch from that
runner's PDS, which the server resolves from the DID. Checks live in the
runner's repo, so the account page has no other way to reach them. A repository
that names no runner, or a runner that does not answer, reads as no checks.
- 6b79149: Video-ready blob hosting. Blob uploads and downloads stream: `uploadBlob` hashes and stages bytes chunk by chunk instead of buffering the body, and `getBlob` answers byte ranges from a streamed read, so a 300 MB video fits through a Durable Object and seeks in a browser. The blob port gains optional `createWriter` and `getStream`; the fs, R2, S3 and Deno stores implement both, and each platform injects its native SHA-256. Drive stores a file as one blob of its own type when the store streams, takes large files across several requests through upload sessions, and offers a Link dialog with the blob ref any record can embed. The Worker serves public blobs through the edge cache, and a site's `/.blobs/` route serves ranges and now applies the same space-privacy filter as `getBlob`. A new `videos.mjs` example app is a gallery over the drive's videos. The node server accepts a `blobUploadLimit` option (`PDS_BLOB_UPLOAD_LIMIT` in the example), which Bluesky video posting needs.
- 8616855: com.atproto.repo.listRecords orders the way the reference PDS does: newest
record key first, oldest first under `reverse=true`. It listed oldest
first and dropped the parameter, so a client paging a TID-keyed collection
under a page cap kept the oldest records and lost the recent ones. The
repository browser reads a runner's check history that way.
- af8b1fd: A loopback OAuth client is registered by its client_id's `redirect_uri` and
`scope` parameters, as the specification says it is. The generated metadata read
the client_id as its own sole redirect URI, so every loopback client whose
callback has a path — which is every one that has a callback — was refused at
PAR with "redirect_uri not registered for client". A client_id carrying no such
parameter keeps its old reading.
## 2.3.0
### Minor Changes
- 5125583: Show image files in the repository browser. The git browser reports an image
file's media type and serves its bytes from `/account/api/git/raw`, and the
account page displays the picture where it previously named a binary file.
### Patch Changes
- 232a280: The sign-in page names the server's one account instead of asking for an
identifier. The handle (or the DID, when no handle is set) renders as text
above a single password field and posts through a hidden field. The editable
identifier input remains only on a server with no identity yet. Both the
account app and the server-rendered fallback follow the same shape.
- c541c98: The account password can be changed from the security page. The configured
password (PDS_PASSWORD) seeds a fresh server; a change stores a hash in
actor storage and wins from then on. Setting a different configured
password afterwards is the operator's reset: it wins over the stored hash,
which is the recovery path when the password is lost. The change endpoint
takes the current password even inside a signed-in session. Open sessions
and app passwords stay valid through a change.
- f8e3dff: Private repositories in the account page, and a blob privacy fix. The
repositories page lists space-held repos with a Private badge, browses their
files, and manages the space's members; a New repository dialog creates the
space behind a private repo and shows the push commands. The public
`com.atproto.sync.getBlob` and `sync.listBlobs` endpoints no longer serve or
list a blob that only space records reference; `com.atproto.space.getBlob`
remains the authenticated path to those.
- bdad8c4: Branch protection, enforced server-side. Repository settings on the account
page write a `dev.pdsjs.git.config` record beside the repo record; a write
guard built by the git package and injected by the platforms refuses a repo
record write that force-pushes, deletes, or drops a protected branch,
checking ancestry against the bundle chain. Settings also edit the
description and default branch. The guard rides a new RecordWriteGuardPort
consulted by the public and space record write paths.
- 51d6402: Expand a permission set's space permissions, so a shared space is reachable.
A set naming `resource: "space"` granted nothing: the expander read `repo` and
`rpc` and dropped everything else. An app that asks for space record access
through a published set, as `app.bulleted.spaceAccess` does, was left with only
whatever literal `space:` scope it also requested. A literal scope that names no
`authority` means `authority=self`, so a space another account hosts matched
nothing and `getDelegationToken` refused a member their own space.
The space type is checked against the set's authority, and a wildcard type is
refused. Collections are not checked: they belong to the space type's
declaration, which another authority may own. A set is read for its record
actions alone, never for `manage`, so space management stays something the
consent screen names.
A grant is frozen when it is approved, so an app already authorized needs to be
authorized again before the set expands.
- d8cae21: Hold resolved DID documents, so a space request stops reading the directory.
Every space credential names its issuer, and verifying one reads that issuer's
key. plc.directory sends no `Cache-Control`, so nothing below the resolver held
the answer and every space request paid a round trip to it.
`createCachedDidResolver` holds an answer for an hour, an unresolvable DID for a
minute, and serves what it holds for up to a day when the directory stops
answering. The platform packages build one at startup and share it between the
PDS and the space routes.
A held document can name a key its issuer has rotated away from. Verification
now reads past the held answer and tries once more when a signature fails,
which is how `getSigningKey` gained its `forceRefresh` argument. The service
token that authorizes `com.atproto.server.createAccount` always reads past it:
adopting a DID is worth a fresh read, and it happens once per account.
- d8cae21: Take the JWT `alg` from the signing key's curve, on both sides of the wire.
An account with a secp256k1 key signed delegation tokens, space credentials and
`notifyWrite` service auth under a hardcoded `alg: ES256` header. A verifier
resolves the issuer's key, reads K256, and refuses the token before it checks
the signature. The platform packages now declare the curve on the signer they
hand `@pdsjs/spaces`, and secp256k1 keys sign ES256K.
Verification refuses a header naming an algorithm the issuer's key cannot
produce, which the reference implementation has always done. This covers space
tokens, inbound space service auth, and the service token that authorizes
`com.atproto.server.createAccount`. The signature itself verifies under the
resolved key either way, so a mismatched header used to pass here and fail
everywhere else.
- 24c9e1e: `/init` on an uninitialized server now requires the configured password when
one is set. A deployment that sets the password before first boot closes the
window between deploy and bootstrap, where the endpoint would otherwise adopt
any caller's identity. With no password configured the bootstrap stays open,
matching the existing setup flow. `scripts/setup.js` sends `PDS_PASSWORD`
from the environment when present.
- 035450d: The account page reports the release the server runs and whether a newer one
is published. `/account/api/version` asks the npm registry server-side, cached
in memory for six hours, and the settings page and home screen surface the
answer. A deployment that names an update page (`updateUrl` in core,
`PDS_UPDATE_URL` on Cloudflare) gets a direct "Update this server" link; other
deployments see the release and its notes.
- 3920c52: Commit history on the repository page. The git browser parses the commit
objects the bundle chain already holds and walks them from a ref in git log
order; the account page shows a History view beside Files with message,
author, age, and short sha, for public and space repositories alike.
- 585139b: Reach a space only from a scope that names it.
`allowsSpace` and `allowsSpaceManage` treated full access as space access, and
`atproto` counts as full access. That scope is in every OAuth authorization
request, so every application signed into an account held every space that
account could reach, at every authority, for every action, without asking for
any of it. The reference implementation refuses a space even to
`transition:generic`, a token that predates permissioned data.
Space access now comes from a `space:` scope naming the space, or from the
account's own password session, which holds the account rather than a grant
over part of it.
An application already authorized keeps whatever its grant froze at approval.
One that relied on full access has to be authorized again, against a client
that asks for the spaces it needs.
## 2.2.0
### Minor Changes
- dfce920: Keep the account page signed in while it is in use, instead of asking for the
password on a fixed schedule.
The session cookie carried a 12 hour expiry set at sign-in, and nothing moved
it. A console open all day was signed out mid-use, and a visit the next morning
always started with the password form.
The cookie now lasts a week and any account page request renews it once a day of
its life is spent, so the session ends a week after the last visit rather than a
week after the sign-in. `PersonalDataServer.fetch` attaches the replacement,
beside the DPoP nonce it already refreshes for OAuth clients, so the pages, the
JSON API and the form posts all carry the session forward. A response that sets
the cookie itself, sign-in and sign-out, keeps its own value.
- d679bf1: Offer to combine Apps rows that are one app split across two names.
An app appears twice when the domain it signs in from is not the authority its
records are named by: a rebrand (tangled.sh records, tangled.org sign-in) or a
client hosted on a subdomain (is.currents.\* records, api.currents.is sign-in).
The Apps screen now detects that signature — a session-only row and a
record-only row sharing a name label, with the session's granular scopes, if it
announces any, required to cover the record row's authority — and shows an
inline offer to combine them. Scope coverage alone never pairs rows, so a
third-party client writing another app's lexicons is not mistaken for that app.
Confirming folds the rows into one entry, kept under the name the app is known
by: the apex when the client is its subdomain, the live client host after a
rebrand. The combined row's detail page says where the other half lives
("Stores records as sh.tangled.\*", "Signs in from api.currents.is") and a
Separate action undoes the fold; declining an offer is remembered too. The
choice is stored server-side in a new optional actor-storage document
(`getAccountUiState`/`setAccountUiState`, implemented for SQLite and the
Cloudflare Durable Object) via `POST /account/api/apps/alias`, and the alias
map rides on `GET /account/api/apps`. On storage without the document the
Apps screen simply never offers.
- f800d5a: A Files page in the account UI: every blob the account holds, with the
records that use it.
The page opens with a storage bar — bytes by media kind, one fixed hue per
kind, doubling as a filter — and chips for kind, app, and "not used", all
combinable with a Newest/Largest sort. Filters run server-side with keyset
pagination and live in the URL. Filtered to images, the list becomes a
thumbnail grid with details in a dialog. On phones the chip rows scroll
sideways with sort pinned in view.
Dates mean when the content was made, not when the bytes arrived: each
blob→record link now stores the record's own timestamp (any top-level
ISO-dated field, earliest wins, window-checked so a birthday or a scheduled
future date never counts), falling back to the record's TID time and then to
arrival here. A file only dated by arrival reads "Stored". On a migrated
repo this keeps the original post order, where arrival time would collapse
everything onto the migration date.
Behind it, `ActorStoragePort` grows optional `listBlobDetails` and
`getBlobFacets` methods, implemented for better-sqlite3 and Durable Object
SQLite from one shared builder (`@pdsjs/core/blob-sql`), and
`linkBlobToRecord` takes the record time. The `record_blobs` table self-heals:
`relinkBlobRecords()` rebuilds links and times from record content on CAR
import, on the daily blob cleanup, and on demand via the page's "Recheck
usage" action (POST /account/api/blobs/relink) — so an imported repo's files
stop reading as unused. The overview's blob count uses the exact aggregate
instead of the capped page-through when the storage supports it.
- b368c19: New package: host git repositories natively in an atproto account. Ships a
`dev.pdsjs.git.repo` lexicon and a `git-remote-atproto` remote helper that
stores repository history as chunked git bundles in PDS blob storage, with
compare-and-swap ref updates through putRecord. Clone, fetch, push, force
push, tags, deletions, and automatic bundle-chain repacking all work against
any stock PDS with no server changes.
The package also ships a read-only git smart HTTP handler so stock git can
clone these repositories without the helper. It advertises refs from the
record and serves fetches by merging the bundle chain's packfiles without
inflating objects, so it runs fine on Cloudflare Workers. Opt in with
`experimental: { git: { http: true } }` in the Node adapter or
`PDS_EXPERIMENTAL_GIT_HTTP = "true"` in the Worker's vars; clone URLs look
like `https://<pds-host>/git/<handle-or-did>/<repo>`.
Server adapters gain an `experimental` option group, exempt from semver:
anything in it may change shape in any release. It holds one entry per
feature experiment, `git: { http, browse }` and
`sites: { domain, apexName, queries }` (the Deno adapter takes the same
`sites` group), replacing the flat `siteDomain`, `siteApexName`, and
`enableQueries` props. Experimental env vars carry the same signal as a
prefix: `PDS_EXPERIMENTAL_GIT_HTTP`, `PDS_EXPERIMENTAL_GIT_BROWSE`,
`PDS_EXPERIMENTAL_SITE_DOMAIN`, `PDS_EXPERIMENTAL_SITE_APEX_NAME`, and
`PDS_EXPERIMENTAL_QUERIES`. The git package itself versions independently
at 0.x until it stabilizes.
The account page grows a Repositories section under the Beta heading, next
to Sites: each hosted repository with its branches, tags, size, and last
push, linking to a repository page with copyable clone commands, a ref
picker, and a browsable file tree with text previews. Browsing is served by
a pure-JS pack object reader (no git binary server-side) exposed through
`/account/api/git/repos`, `/git/tree`, and `/git/file` endpoints, with
clone URLs including the plain-git HTTP one when the platform serves it.
- 6e47bc6: Support `include:` permission-set scopes end to end.
An OAuth request may now carry `include:<nsid>[?aud=…]`, naming a lexicon
whose main def is a permission-set. The authorization request resolves each
set through the lexicon resolver and refuses the request with `invalid_scope`
when one cannot be resolved, since a consent screen should not ask for access
it cannot describe. Approval expands the set into the concrete `repo:`/`rpc:`
scopes it granted at that moment, so tokens, refresh records, and request-time
enforcement carry plain scopes and never depend on a lexicon that may change
or vanish later; a set's updates take effect on re-consent.
Expansion enforces the reference implementation's containment rules: a set may
only grant NSIDs under its own authority, wildcards are never grantable, and
rpc permissions must inherit their audience from the include scope.
The consent page's permission data now also carries rpc grants (previously
invisible), email access (shown by the legacy HTML page but dropped from the
account-app serialization), the raw scope string, and each permission set's
title and detail with their language variants.
- 72965f8: Installable apps: a repo-hosted site, published once, installed anywhere.
A developer publishes a `dev.pdsjs.app.manifest` record naming their app
and pinning its site record by CID, with an icon, the query definitions the
app reads, declared settings ({key, label, type, default, options}, data
and never code), a suggested subdomain, and an `external` flag for apps
that talk beyond the user's own PDS. The pinned record key is an artifact
address with no naming meaning.
Installing copies the app into the user's own repo: every blob is fetched
from the developer's PDS and hashed locally against the CID the record
names, the site record copies verbatim so its CID stays the one the
manifest pinned, query defs and a `dev.pdsjs.app.install` record land with
it in one commit. The install record snapshots the name, icon, settings
schema, and chosen values, so the account page needs no cross-server read
and the app survives its developer's repo disappearing. A drifted pin
refuses to install; a manifest requesting scopes refuses until grants
exist.
Two ways in: `pdsjs-site install <at-uri>` over stock XRPC, or the account
page's install sheet, which previews the manifest before anything happens.
Installed apps list with their icon and name, carry a generated settings
form, move to a new subdomain without touching their content, and show an
update action when the developer publishes a new manifest version; updates
keep the user's settings and subdomain. Apps discover their host from
their own origin at load, so one published file serves every installer's
data.
- 09e110d: Show a check on the Records list for collections whose lexicon is published via
the protocol.
A lexicon is "published" when its NSID resolves through the protocol — DNS
authority (`_lexicon.<domain>`) → DID → the `com.atproto.lexicon.schema` record.
The resolver already walked exactly that chain for record validation, so it gains
an `isPublished(nsid)` method (negatively cached, so an unpublished NSID doesn't
re-hit DNS on every check) and core exposes it at
`GET /account/api/lexicon-status?collections=…`. The account UI loads that
separately from the overview, so the collection list still renders instantly and
a green check fills in beside each NSID whose lexicon resolves — on both the app's
Records list and the header of a collection's records page. Clicking the check
opens a popover that explains, in plain language, what a published record type
means (a click popover rather than a hover tooltip so it works on touch and can
hold actions). From there a "View schema" button opens a dialog that lays out the
record's fields in plain terms — each field's name, a friendly type, whether it's
required, and its description — rather than raw JSON, since this is for people
reading their own data. The schema is served by
`GET /account/api/lexicon-schema?collection=…` (backed by a new `getLexicon(nsid)`
on the resolver).
- 098af68: Materialized queries: a `dev.pdsjs.query.def` record names a live view over
one collection of the repo, with equality filters and one sort field, and
`GET /xrpc/dev.pdsjs.query.run` returns the whole view as JSON in a single
request. Reading a thousand-record collection through `listRecords` costs a
serial cursor walk; through a query it is one round trip. The engine keeps a
decoded copy of each queried collection in memory, replayed from storage on
first use and maintained from every commit, so results reflect a write
immediately. Off unless `PDS_EXPERIMENTAL_QUERIES` (the `experimental.sites.queries` option on
Node and Deno) is set.
Behind it, `@pdsjs/core` gains the app platform's live-data seams, both
inert until a platform package fills them: an optional `onCommit` callback
called after each successful commit (an error there is logged and never
fails the write), and a `queryRoutes` table composed like `spaceRoutes`.
Site hostnames also now answer `/.blobs/<cid>` with that blob from the repo,
immutable and edge-cacheable, sandboxed by CSP, media mimes only. A
client-rendered site can query its data live and show repo media without a
manifest entry per file.
- 62dc25a: Stop rejecting writes over a third-party lexicon fetched off the network.
`com.atproto.repo.createRecord` defines its `validate` flag as "false to skip
Lexicon schema validation, true to require it, or leave unset to validate only
for known Lexicons". The reference PDS reads "known" as a hardcoded map of ~20
record types it bundles, with a standing `@TODO` for resolving schemas
dynamically — so it never fails a write because of a lexicon it had to go fetch.
pds.js does resolve them, and was enforcing every one it found, which made it the
only PDS in the ecosystem to reject records the rest of the network accepts.
That is a real failure mode rather than a theoretical one: a publisher's lexicon
routinely lags the client writing the records, so grain.social galleries were
rejected because the published `social.grain.photo` still required `alt` while
grain's client omits it when blank.
A resolved lexicon is now advisory by default — it reports `valid` when the
record conforms and `unknown` when it doesn't, instead of a 400. Rejection is
reserved for collections on the `enforce` list, which defaults to the same set
the reference PDS validates strictly, plus any schema handed to the resolver up
front. Those are still resolved from the network rather than bundled, so there's
no vendored copy to fall out of date; all 36 lexicons in that set's ref closure
currently match the reference's own byte for byte. `assertValid` takes a `strict`
option, wired to the caller's `validate: true`, that requires validation to
happen and enforces whatever resolves.
Two resolution bugs fell out of this. A ref that failed to resolve left the
schema set incomplete, and the resulting "Lexicon 'x' not found" surfaced as an
invalid record — so a DNS or directory hiccup turned into a write the client
could never land; that now reports `unknown` too, while a record broken
independently of the ref is still rejected. And authority lookup hardcoded a
two-segment domain, which cannot reach an NSID whose authority is deeper:
`com.atproto.lexicon.schema` publishes only at `_lexicon.lexicon.atproto.com`.
Lookup now tries the spec form — every segment but the last, reversed — before
falling back to the two-segment domain, which authorities serving a wildcard
still answer.
- 224cb48: Scheduled backups, with a Backups page in the account UI.
A backup is a snapshot in a new `BackupTargetPort` — a directory
(`createFsBackupTarget`), an S3-compatible bucket (`createS3BackupTarget`), or
on Cloudflare an R2 bucket bound as `BACKUPS`. Each snapshot holds the full
signed repo CAR (the same bytes `com.atproto.sync.getRepo` serves) and a
manifest, written last so an interrupted snapshot never counts; blobs are
stored content-addressed at `blobs/<cid>` and shared across snapshots, so a
run only uploads what the target doesn't already hold. Retention keeps the
newest N snapshots and reaps blobs no kept manifest references.
Scheduling rides the maintenance tick each runtime already had: the hourly
interval on Node and Deno, and the Durable Object alarm on Cloudflare, which
now fires hourly to match. Settings and run history live in the existing
`metadata` table as one JSON document, read through new optional
`getBackupState`/`setBackupState` methods on `ActorStoragePort`.
The signing key stays out of snapshots unless opted in: entering a passphrase
on the Backups page seals a copy under PBKDF2 → AES-256-GCM (`key.enc` in each
snapshot), and the page warns when the key has rotated since it was sealed.
The page also offers one-click manual runs, run history, schedule and
retention settings, and a direct `.car` download that works even with no
target configured.
- 5a39d42: Sign with secp256k1 account keys, the curve the reference PDS gives every
account.
Signing was P-256 only. That is spec-legal, but services that verify service-auth
JWTs on secp256k1 alone reject it — a Go feed generator answers a bare `400`, so
`app.bsky.feed.sendInteractions` silently never lands and a feed keeps serving
posts the reader has already seen. WebCrypto cannot sign secp256k1, so the
implementation is injected as a `Secp256k1SignerPort`, mirroring the verifier
port that already existed on the verification side: core stays free of the
dependency and the platform packages supply `@noble/curves`.
A stored key now describes its own curve, so an existing bare 32-byte key still
reads as P-256 and every P-256 deployment is unaffected. Where a signer is
injected, new accounts are minted on secp256k1, and the JWT `alg` (`ES256K`),
JWKS entry, `did:key` multicodec, published verification method, and DPoP
self-verification all follow the key's curve rather than assuming one.
Rotating an existing account's key is worth planning: services that have cached
the DID document keep verifying against the old key until they re-resolve it,
so expect proxied calls to fail until an `#identity` event reaches them.
- 67387fe: Add a Settings page with an appearance setting.
The account app grows a Settings section whose first setting is appearance:
match the device, light, or dark. The choice is stored in the account's UI
state document (POST /account/api/settings/appearance), so it follows the
account across browsers and devices, and rides along in the overview payload
and the signed-in page bootstrap — the consent screen's too — so the first
paint already uses the right palette. An explicit choice pins the palette
through a data-theme attribute on the root element; matching the device
leaves it to prefers-color-scheme, as before. On a server whose storage
cannot hold UI state the setting is shown but disabled.
- 9ceb6ad: Describe what this server serves, bring permissioned data back in line with the
reference implementation, and answer unregistered XRPC methods the way the
protocol specifies.
`community.lexicon.service.describe` is served: one unauthenticated query
answering with this service's roles and every XRPC method it routes. Optional
endpoints cannot be discovered any other way, since atproto publishes no method
list, and a client that has to call a space method and read the failure cannot
tell an absent feature from a broken one. The answer is derived from the route
table rather than kept beside it, so it cannot drift and it names the space
methods exactly when spaces are enabled.
`createSpace` joins the owner to the space it creates. A member-list space, the
default, refuses a credential to an account that is not a member, so an owner
who never called `addMember` on themselves could not read the space they had
just made.
Proposal 0016 renamed things after implementations had shipped, so several
requests have two correct spellings and this server answers to both:
- `com.atproto.space.getSpace` alongside `com.atproto.simplespace.getSpace`.
- `com.atproto.space.getRepoState` alongside `com.atproto.space.getLatestCommit`.
- On `createSpace` and `updateSpace`, a nested `config` object holding a string
`policy` (or the older `mintPolicy`) and a sibling `managingApp`, alongside
the top-level `policy` and `appAccess` unions. Both fields are optional again:
absent, the host defaults of `member-list` and `#open` apply, which is what
the earlier lexicon documents. `createSpace` accepts an optional `did`, which
must name the caller.
- `getSpace` answers with the union fields and the `#spaceConfig` object at
once, so a client reads whichever it knows.
- On `applyWrites`, a write named by an `action` string alongside one tagged
with a `$type` union member. A create may leave `rkey` empty for the server
to mint one.
An unrouted `/xrpc/<nsid>` returned a `text/plain` 404, which a client cannot
tell apart from a transport failure. It now returns 501 with a
`MethodNotImplemented` error body. atproto publishes no list of supported
methods, so calling an NSID and reading its error is the only way a client can
discover an optional endpoint such as a space method.
Space changes, following atproto PR #5187 as of 2026-08-07:
- `getSpace` moves to `com.atproto.simplespace.getSpace` and answers with
`policy` and `appAccess` at the top level. It also authorizes the caller
first, taking the hosted account's session or a space credential.
- `createSpace`, `updateSpace` and `getSpace` carry both policies as
`com.atproto.simplespace.defs` unions. A variant the host does not implement
is refused with `UnsupportedPolicy` or `UnsupportedAppAccess`.
- `createSpace` drops its `did` field. The space is anchored on the
authenticated user.
- `registerNotify` takes a service identifier rather than a URL and resolves
the delivery endpoint from that service's DID document. It answers with an
`expiresAt` a day out, and a lapsed registration stops receiving
notifications.
- `unregisterNotify` is implemented.
- `getBlob` and `listBlobs` are implemented, over the account's ordinary blob
store. `getBlob` serves only a blob the named space references, where the
reference implementation serves any blob the account holds.
`createSpaceRoutes` now requires `blobs`, `linkBlob` and `unlinkBlobs` in its
context, and refuses to build without them: absent the link callbacks a space
record's blobs look unreferenced, and orphan cleanup deletes them.
A deployment that already created the space tables must drop
`space_credential_recipient`, whose `last_issued_at` column is now `expires_at`.
Registrations are re-established by the syncers that hold them.
- e628859: Static sites served from the repo, in a new `@pdsjs/sites` package.
A site's files are ordinary blobs and a site is one `dev.pdsjs.site.deploy`
record holding the path→blob manifest, so a deploy is a stock atproto write:
it rides the firehose, survives migration, and lands in backups with
everything else. `pdsjs-site deploy ./dist --site home` uploads only the files
whose CID changed and writes the record.
Serving is by hostname. The record key is a DNS label, so `blog` serves at
`blog.<site domain>` and the site named by `PDS_EXPERIMENTAL_SITE_APEX_NAME` (default
`home`) serves at the apex. Responses carry an ETag derived from the file's
CID, so conditional requests answer 304, and a missing path falls back to
`index.html` for single-page apps.
Caching is per file: `pdsjs-site deploy --cache-control "[glob:]value"` sets
the Cache-Control each matched path serves with, and content addressing makes
`immutable` safe for fingerprinted assets. On Cloudflare, site responses are
also held in the edge cache for their `max-age`, so repeat requests are
answered at the visitor's edge location instead of crossing to the Durable
Object's region. The PDS's own hostname never enters that cache.
The feature is off until a site domain is configured: `PDS_EXPERIMENTAL_SITE_DOMAIN` on the
Worker, `experimental: { sites: { domain } }` on the Node and Deno `createServer`. Without it no
hostname is inspected and dispatch is untouched. `@pdsjs/core` gains two
optional ports, `SiteHandlerPort` and `SiteBrowserPort`, and never imports the
sites package itself; a platform package builds both and passes them in. On
Node and Deno `@pdsjs/sites` is an optional peer dependency, so a deployment
that does not want sites does not install it.
Site content is only ever served on the site domain, never on the PDS's own
hostname. That keeps repo-controlled HTML on a separate origin from the
account session cookie.
Every servable site hostname also answers `/.blobs/<cid>` with that blob from
the repo, immutable and sandboxed, so a client-rendered site can show repo
media without a manifest entry per file. `www` with no site named `www`
deployed redirects to the apex.
The account interface grows a Sites page listing what is deployed, with a
delete that unlinks the record's blobs and reaps the orphans. It appears only
when the server composes the feature in, and it warns when the PDS's own
hostname shadows the apex site.
### Patch Changes
- 261d4f4: Return to the sign-in screen when the account session has lapsed, instead of
stranding the console on a "Not signed in" error.
The console loads its data from `/overview`. When the account cookie no longer
maps to a live session — it expired, or the account was reset under a new DID in
local dev — that request 401s and every screen rendered the bare error. The
cookie is `HttpOnly`, so the page could not clear it on its own and kept
erroring on reload.
A 401 from the overview now clears the cookie server-side via the existing
sign-out endpoint and reloads onto `/account`, where a signed-out request is
served the sign-in screen. The sign-out button and this recovery path share one
helper.
- dfcf1cc: Stop app-password sessions from being refused every write.
An app-password session carries the scope `com.atproto.appPass` (or
`com.atproto.appPassPrivileged`), which names no granular permissions. Repo, blob
and rpc checks read those scope strings and found nothing to allow, so an app
password was refused record writes, blob uploads, and proxied calls to the
AppView. That is the opposite of what an app password is for, and it made
third-party clients using one unusable.
The reference PDS asserts granular repo, blob and rpc permissions for OAuth
credentials only, and bounds an app password by other means: it cannot mint or
list app passwords, and it never sees the account email. Both of those bounds
already held here and still do.
Space scopes are deliberately left out of this. The reference has no equivalent to
compare against, so an app password does not reach permissioned data by this
route.
- 99c414f: Show a subtle record-activity sparkline behind each row on the Apps page.
The overview endpoint folds a creation-time histogram into the single scan it
already does for collection counts: each record is bucketed by the timestamp its
rkey (a TID) encodes, over the trailing year, and returned as `recordActivity`.
The Apps page sums those buckets per app and draws them as a smoothed area
sparkline behind the row — full history on one shared 365-day axis, with no
firehose page cap and no extra request. `tidToMs`, the inverse of the timestamp
half of `createTid`, decodes the rkey timestamps. The local dev seed now
backfills a year of activity across several apps, so a fresh `just dev` shows the
feature with real ridges rather than two flat rows.
- 261d4f4: Lead each Apps row with when its app was last active, and keep long app names
from breaking the row.
The row used to describe an app by its stored data — `N records · M collections`
— with the last-active time appended only when the app held an OAuth session. An
app with data but no session showed no recency at all, and a long enough app name
pushed the session badge onto a second line because the name never truncated.
Rows now sort and label by when the app was last active: the newest of its
session accesses and its record writes, so a data-only app reads as active by
when it last wrote, not only apps that hold a session. The description is just
`Active X ago`; the record and collection counts move off the list, where the
app's own page and History already carry that detail. `/overview` returns a
`lastRecordAt` per collection to make this possible — a precise time captured in
the same pass that builds the activity histogram, unbounded by its window. The
long app name now truncates with an ellipsis while the session badge stays pinned
inline.
- 218f25c: Count writes to a shared space as app activity on the Apps page.
An Apps row sorts and labels by when its app was last active. That time came
from the app's OAuth sessions and its records in the repo, so an app that keeps
its data in a permissioned space read as older than it is, and one that keeps
all of its data there read as never active at all.
`/account/api/spaces` now returns a `lastRecordAt` per space collection, from
the same rkey TID the repo's own collections take theirs from. The Apps page
reads it: a row's last-active time is the newest of its session accesses, its
record writes and the writes to the spaces it manages. A space belongs to the
app whose lexicon defines its type or one of its collections, the same rule the
app's own page uses to list its spaces.
- 1b8cd1e: Reword finished rows in the Backup history list. "2 of 1,263 blobs" read like
an incomplete run when it really meant only 2 blobs were new since the last
snapshot, so finished runs now say "2 new blobs, 1,263 total" and lead with
"Finished x ago" instead of "Started x ago".
- f2df586: Live backup progress, and a heartbeat lock that survives interruption.
A running backup now checkpoints its counters (and a heartbeat timestamp)
into the stored run every ten seconds; the Backups page polls while a run is
live, so the history row ticks upward instead of sitting at zero for the
whole first copy. The heartbeat doubles as the run lock: a run whose isolate
died mid-copy stops beating, loses its claim within minutes, and is shown —
then recorded — as interrupted, so an aborted run can never wedge future
backups until a restart.
- e761cdb: Stop orphaned-blob cleanup from deleting blobs a migrated account still
references.
`loadRepositoryFromCar` imports records without populating the `record_blobs`
link table, so `getOrphanedBlobs` reported every blob an imported repo
referenced as an orphan. The daily cleanup alarm (and the immediate cleanup
after a record delete) then deleted them — silently destroying a migrated
account's blobs about a day after `importRepo`.
Cleanup now cross-checks the blobs actually referenced by record content
(`collectReferencedBlobs`) before deleting, so a stale or empty link table can
never reap live data. `importRepo` also now registers blob→record links for the
records it imports, keeping the link table accurate going forward.
- fd33b13: Authorize, rather than deny, when Enter is pressed in the consent password
field.
A form without a named default button submits through its first submit button.
On the consent screen that is Deny, which sits left of Authorize. Typing the
password and pressing Enter, the one gesture the autofocused field invites, sent
`access_denied` back to the app.
Enter in the password field now submits through the Authorize button, so the
field's constraint check and the approve path both run as they do on a click.
- d88b21d: Show a redirect screen while the client's page loads after an OAuth decision.
Approving left the consent form on screen, buttons live, for as long as the
client's site took to answer. Nothing said the request had been decided, so a
slow client read as a dead button.
The account app now posts the decision with `Accept: application/json`. The
authorization endpoint answers that with the redirect URL rather than a 302, so
the app can render the outcome — the app's tile, "Authorized" or "Not
authorized", and a manual link — and navigate from there. Passkey approvals
reach the same screen. A wrong password comes back as a 401 the form shows in
place, instead of a re-rendered page. A plain form post still gets the 302,
which is what the server-rendered consent page uses.
- 8ce9621: Redesign the consent screen's permissions as summary cards.
A request like ten full-control collections plus one create grant used to
render thirty action chips; it now reads as one card per app: favicon, the
domain, and a sentence built from record nouns ("Full control of galleries,
photos, and comments."). Exact NSIDs sit behind a Details toggle, grouped
under one faint action heading instead of per-row chips, and the raw scope
tokens behind a Technical details toggle. Email, upload, rpc, and space
grants get their own cards fronted by icon chips, and a permission set a
request includes is presented by its author's title and detail, picking the
translation that best fits the reader's languages.
The app page's session "N permissions" dialog now opens these same cards,
parsing the granted scope string client-side, in place of the raw scope
badges it used to show.
- faceaf7: Expire stale password/app-password logins instead of listing them forever.
Each legacy `createSession` writes a session record, but the records carried no
expiry, so a client that re-logs in rather than refreshing (many app-password
feed generators and bots do) piled up a permanent record every time — inflating
"N logins have access right now" with logins whose refresh tokens had long since
died. Refresh tokens now last 90 days, matching the reference PDS (they were
24h). Sessions store the refresh token's own expiry in a new SQLite `expires_at`
column, and the read path prunes any login that has outlived it as it lists
them. A row from before
this field is aged by the earlier 24h lifetime, so existing stale logins retire
themselves the next time the page loads (safely: an expired token can no longer
refresh, so removing its record signs no one out).
A fresh login also retires the same client's prior session — keyed by what
opened it (the account password, or an app password's name) and the device it
ran on — so a client that signs in every run instead of refreshing keeps one
live session per credential per device rather than one per run, while two
genuinely different devices stay separate. Without this, the 90-day lifetime
would let re-login clients accumulate far more than the old 24h did.
"Where you're signed in" and the account overview count now reflect only live
logins, and the list is capped to the five most recent with a "Show all" toggle.
- bc5314a: Expose the `DPoP-Nonce` (and `WWW-Authenticate`) response headers via CORS.
DPoP is a two-step handshake: the server answers the first proof with a
`use_dpop_nonce` error and a `DPoP-Nonce` header, and the client retries with
that nonce. Browser clients can only read the header if it is listed in
`Access-Control-Expose-Headers`, which it was not — so browser-based OAuth apps
failed to log in on `use_dpop_nonce` (native apps, without CORS, were fine).
- eeb7976: Stop `com.atproto.sync.getRepo` from serving a repository with blocks missing
from it.
`buildFullRepoCar` walks the commit, then every MST node, then every record, and
throws `Missing block` for one it cannot read. The recursive calls were inside a
`try` whose `catch` discarded any error, so only a missing _root_ reached the
caller. An absent MST node or record left the walk instead, and the endpoint
answered 200 with a CAR that held part of the repository. A relay accepts that as
the whole one. The same walk builds the repo half of a backup snapshot, so a
snapshot could record `ok` over an incomplete `repo.car`.
The walk now follows `data`, `l`, `e[].v` and `e[].t` on the commit and on MST
nodes only, and treats a record as a leaf. Before, it probed those field names on
every block it read, including records, so a record with a field named `data`,
`l` or `e` sent it after bytes that are not a block CID.
- 44bd2b5: Name the request in the dispatch catch-all's error log so a 500 is diagnosable
on its own.
The top-level handler swallows any thrown error into a `console.error('Handler
error:', err)` and a clean 500. That line carried only the error, so finding
which route broke — and for whom — meant cross-referencing the log against the
request URL in the surrounding Cloudflare tail event.
The catch now logs the HTTP method, the endpoint (the XRPC nsid, or the raw path
for non-XRPC routes), and the authenticated DID when one resolved — e.g.
`Handler error: POST com.atproto.repo.createRecord did=did:plc:…: <error>`. The
DID is captured as auth resolves on both the app.bsky proxy and normal route
paths, and the `did=` segment is omitted when the caller was unauthenticated.
Response behavior is unchanged.
- 5a39d42: Require the account's credentials to re-initialize a server through `/init`.
`/init` adopts a DID and its signing key, and took no credentials at all. On an
empty server that is the bootstrap path and stays open — there is no account to
authenticate as yet. But once a DID is adopted, the same unauthenticated request
would overwrite the identity and signing key of a running server, so anyone who
knew the URL could repoint an account at their own. It now authenticates once a
DID exists, and rejects before reading the body.
- 7898894: Name common non-browser HTTP clients in the sessions list. `formatUserAgent`
now maps agents like `node`, `curl`, `okhttp`, `Go-http-client`, and
`python-requests` to clean labels (Node.js, curl, OkHttp, Go, Python) the way it
already does for browsers, instead of showing the raw token. Unknown agents keep
their trimmed raw value.
- 378e2bb: Record the device (User-Agent) that opened each OAuth session and surface it in
the Apps list, so two sessions of the same app can be told apart. The
User-Agent is captured when the session is authorized (at consent) — not at the
token endpoint, which for a confidential client is its own server rather than
the user's device — preserved across refreshes, and returned by
`listConnectedApps`; the account UI renders a short "Browser · OS" label.
- 4363389: Track when each OAuth session was last active and order the Apps list by it.
A session's `updatedAt` is stamped at authorization and advanced on every
refresh, so it reflects recent background activity even for read-only apps that
write no records — mirroring the reference PDS, where access tokens are verified
statelessly and "last accessed" tracks the refresh, not each request.
`listConnectedApps` now returns `lastAccessedAt` and sorts sessions
most-recently-active first; the account UI sorts apps the same way (falling back
to record count for apps that only hold stored data), shows an "Active <time>
ago" label on each Apps-list row, and a "Last active" line on each session in the
app's detail page. Tokens written before this field carry no `updatedAt`, so
`createdAt` stands in.
The Apps-list row badge now always reads "N session(s)" rather than special-casing
a lone session as "signed in", and the "Your apps" nav count reflects the number
of apps shown — distinct authority domains across records and sessions — instead
of the raw session count, so the count and the list agree.
- 011e28d: Extend the OAuth refresh-token lifetime to 2 weeks.
Refresh tokens expired 24 hours after login, forcing app users to
re-authorize daily. The lifetime is now 2 weeks, matching the atproto
reference authorization server's default (SESSION_LIFETIME / REFRESH_LIFETIME).
- 4169189: Omit `cursor` on the final page of `com.atproto.repo.listRecords` and
`com.atproto.sync.listBlobs` instead of returning `cursor: null`.
The lexicon types `cursor` as an optional string. Spec-strict clients such as
`@atproto/lexicon` reject `null` with `XRPCInvalidResponseError`, failing every
`listRecords`/`listBlobs` call against the PDS even though the record data is
valid. The reference PDS emits an `undefined` cursor that `JSON.stringify` drops,
so the field is simply absent on a final page — these endpoints now match that
wire format.
- ee386d7: Return `201 Created` from the PAR endpoint on success, per RFC 9126 §2.2.
The pushed authorization request endpoint returned `200 OK` on success. Spec-strict
clients such as indigo (Go) check for `201` specifically, treat any other status as a
failure, and then try to read an `error` field from the success body — surfacing a
confusing `%!s(<nil>)` error and blocking login. The official `@atproto/oauth-client`
accepts any 2xx, which is why the bug only affected some clients.
- 8cbae75: Proxy `atproto-proxy` requests to any service, not just the AppView and chat.
Proxying was a strict allowlist of the two first-party services, so a client
proxying to a feed generator, labeler, or any other service got
`InvalidRequest: Unknown proxy service`. Those two keep their fast path
(configured URLs); every other service is now resolved from its DID document's
matching `service` entry and proxied there. This is safe for arbitrary DIDs: the
minted service-auth token's audience is that service's DID, so it is only usable
by the exact service the client named, for the single method being called.
- 4363389: Polish the app's Records list: render collection NSIDs in a bold sans-serif
rather than monospace, and drop the "Repository record" subtitle from a
collection's records page (the space name still shows for permissioned spaces).
- 4a9943d: Refresh account UI data when you return to the tab. The query client set
`refetchOnWindowFocus: false`, so the only things that ever refetched were a
component remounting with stale data or a write invalidating its own keys.
Records and files written from another app never showed up while the page sat
open, and a full reload was the only way to see them.
Dropping the flag restores the default, which also re-enables refetching after
a reconnect. The 30s `staleTime` still gates it, so link navigation within the
app keeps serving from cache exactly as before.
- 9abf6d6: Keep a spent refresh token redeemable for two hours, so a client that refreshes
twice is not signed out.
Rotation used to retire the old token the instant it issued the replacement.
Any client with several requests in flight refreshes more than once — and a
refresh whose response is lost leaves the client holding a token the server has
already retired — so a single race ended the session for good, with no way back
short of signing in again. A rotation now names its successor and keeps the
spent record for a grace window, its expiry shortened to match; redeeming that
token again is answered with the same replacement, so both callers converge on
one live token. The grace only ever brings an expiry forward, so a token near
the end of its 90 days gains nothing from being rotated. Past the window it is
refused with `ExpiredToken`. This matches the reference PDS, which keeps the
same two-hour window and the same sticky successor.
Spent records are held for the grace window but are not logins in their own
right: they are hidden from "Where you're signed in" and the Apps page, they do
not count toward the overview total, and they are pruned once the window
closes. Revoking a session follows the chain backwards and removes them too, so
a replayed token cannot put a revoked session back.
Two refreshes that reach the server together used to be able to name different
successors, forking one login into two records — both usable, but "sign out"
on either left the other running on the same device. Naming a successor is now
conditional on no other rotation having named one, through a new optional
`spendSession` on the storage port; the rotation that loses adopts the
successor already recorded. Every store does this in a single statement.
OAuth refresh tokens are left alone: the reference authorization server has no
grace window there, treating a replayed token as a theft signal rather than a
race, so pds.js keeps rotating them the way it did.
Two related fixes:
- Revoking an app password now ends the logins it opened. Deleting the
credential stopped it opening new sessions but left existing ones refreshing
for their full 90 days, contradicting the page's own wording. Matches the
reference PDS, which deletes the refresh tokens tied to that app password.
- Only a fresh login collapses a client's earlier session. Rotation ran the
same dedupe, so a client whose User-Agent changed between signing in and
refreshing — an app updating itself — could sign out a different device that
reported the new string.
- 9524139: Enforce the repo scope on `com.atproto.repo.putRecord` and
`com.atproto.repo.applyWrites`.
`createRecord` and `deleteRecord` check that the caller's scope allows the action
on the collection being written. The other two write endpoints did not, so an
OAuth token granted a narrowed `repo:` scope could reach any collection by
choosing a different endpoint. A token holding only `repo:app.bsky.feed.like`
could write an `app.bsky.feed.post` record through `putRecord`, or create,
overwrite and delete records in any collection through `applyWrites`.
Both checks now match the reference PDS. `putRecord` is an upsert, so it requires
`create` and `update` both. `applyWrites` checks each operation's own action
against its own collection, and does so for the whole batch before writing, so a
batch the scope only partly covers changes nothing.
- 8d62485: Refresh the `DPoP-Nonce` on every resource response and send a
`WWW-Authenticate` challenge on OAuth 401s, so app sessions no longer re-login
every hour.
The server only minted a `DPoP-Nonce` at the OAuth endpoints (PAR, token). A
DPoP nonce lives ~5–10 minutes, but resource/XRPC responses never handed back a
fresh one, so after an hour of ordinary API traffic a client's cached nonce was
stale. When its access token expired and it tried to refresh, the token
endpoint answered `use_dpop_nonce` and — with no `WWW-Authenticate` on the
earlier resource 401s to signal a plain token refresh — clients such as Grain
fell back to the full authorization flow, forcing a visible re-login roughly
every hour.
The resource path now attaches a fresh `DPoP-Nonce` to every response for a
DPoP-authorized request (keeping the client's nonce fresh from normal traffic),
and returns `WWW-Authenticate: DPoP error="invalid_token"` when the access token
is expired or invalid, telling a spec-compliant client to refresh rather than
re-authorize. This mirrors the reference PDS resource verifier.
- f1c6639: Report `invalid_token` (not the generic `AuthenticationRequired`) in the JSON
body of an OAuth resource 401, so clients that branch on the body error refresh
instead of re-authorizing.
The prior fix added a `WWW-Authenticate: DPoP error="invalid_token"` header to
resource 401s, but some clients — such as Grain — decide whether to refresh by
inspecting the response body's `error` field, not that header. The body still
said `AuthenticationRequired`, which those clients don't recognize as
refreshable, so they fell back to the full authorization flow and showed the
consent screen roughly every hour.
The two resource 401 paths now put the OAuth error code (`invalid_token`, the
same signal already carried in the `WWW-Authenticate` challenge) in the body for
a failed DPoP token, falling back to `AuthenticationRequired` only when there was
no token at all. This mirrors the reference PDS OAuth resource verifier, which
throws `invalid_token` at HTTP 401 for expired or otherwise invalid access
tokens.
- fd54c7d: Show the device and a relative sign-in time on each row of "Where you're signed
in". The session's User-Agent (already returned by the sessions endpoint) now
renders as a "Chrome · macOS" style line, so logins under the same credential
are told apart, and "Signed in 2026-08-05" becomes "Signed in 2h ago".
- 1852faf: Add a "Sign out all" action to the header of "Where you're signed in". A new
`POST /account/api/sessions/revoke-all` ends every password/app-password login
for the account at once; the account page runs on its own cookie, not one of
those records, so the caller stays signed in there. A confirmation dialog
explains the limits of signing out — an app that still holds the account
password can sign back in, and access already granted lingers until its token
can no longer refresh — and points to changing the password to remove access for
good. OAuth apps are unaffected; they're managed on the Apps page.
## 2.1.0
### Minor Changes
- 8ac38c1: Host first-run onboarding and harden inbound account migration.
An empty server exposes `/account/onboarding/create` to provision a fresh
did:plc identity (gated on `PDS_PASSWORD`), and keeps the onboarding wizard
reachable while no account exists or one is mid-migration (deactivated).
Migration hardening:
- `getRecommendedDidCredentials` preserves the DID's existing rotation keys, so
a migration or handle change never silently drops a recovery key.
- Service tokens are proxied for the AppView and chat services (strict
allowlist), minting service-auth JWTs only for authenticated callers.
- `findBlobRefs` handles CAR-imported CID refs (raw `Uint8Array`), and
`getRecord`/`listRecords` render CIDs via `toDisplayValue`, so
`listMissingBlobs` reports every blob a record references.
- `activateAccount` and `submitPlcOperation` announce an `#identity` firehose
event (and `deactivateAccount` an `#account` event), so relays and AppViews
re-resolve the new signing key after a move — without it, migrated accounts'
service tokens are rejected with "could not verify jwt signature".
- The Cloudflare Worker honors `PDS_BLOB_UPLOAD_LIMIT` for oversized (e.g.
video) blobs.
The bundled account app gains the matching first-run wizard: create a new
account, or migrate an existing one here through the ordered steps (resolve,
sign in with 2FA, create, copy repo, copy blobs, copy preferences, catch up,
move identity, activate and retire the old one). Blob copying is stall-aware and
retried per blob, skipping and reporting any the source cannot serve; a
pre-identity catch-up re-imports until the destination matches the source's
latest commit; and an optional secp256k1 recovery key is minted in the browser,
shown once to save, and written into the PLC operation.
- f5335a0: Add `com.atproto.identity.updateHandle`: rename the account handle by submitting
a did:plc operation that repoints the DID document's `alsoKnownAs`, persisting
the new handle, and emitting an `#identity` firehose event so consumers
re-resolve.
The new handle must actually point back to the account. The server's own address
is taken as-is (it serves its own `/.well-known/atproto-did`); a subdomain of it
is refused, since a single-user PDS answers only at its own host; any other
domain must already resolve to the DID via a `_atproto` DNS TXT record or the
HTTPS well-known before it is adopted.
- bd768b9: Support confidential OAuth clients (`private_key_jwt`).
The authorization server previously only supported public clients, so
confidential atproto apps (Tangled, statusphere, …) that authenticate with a
`client_assertion` JWT rejected it at metadata validation. The AS metadata now
advertises `private_key_jwt` (with ES256) and
`require_pushed_authorization_requests: true`, matching the atproto profile, and
the `client_assertion` is verified per RFC 7523 at PAR, the authorization_code
token exchange, and refresh: the client's key is resolved from its `jwks` or
`jwks_uri`, the ES256 signature is checked, and `iss`/`sub` must be the
`client_id`, `aud` must name this server, and the assertion must be unexpired,
recent, carry a `kid`, and present a non-replayed `jti`. Public clients keep
working on PKCE + DPoP.
## 2.0.0
### Minor Changes
- 62c6521: Add app passwords: `createAppPassword`, `listAppPasswords`, and
`revokeAppPassword`, with `createSession` accepting them alongside the account
password.
Sessions opened with an app password carry a restricted scope and cannot manage
app passwords, so a leaked one cannot mint its own replacements. The restriction
survives `refreshSession` — a refresh reissues at the same access level rather
than widening to full access.
Only the hash is stored, derived with PBKDF2 rather than the reference
implementation's scrypt, which exists in neither WebCrypto nor Workers.
`SharedStoragePort` gains four methods; both the SQLite and Cloudflare adapters
implement them against a shared test suite.
### Patch Changes
- 13e3049: Security: `createSession` rejected no logins when no account password was
configured.
The check read `if (this.password && password !== this.password)`, so an unset
password short-circuited the comparison and **any** string — including the empty
one — opened a full-access session. The only remaining gate was that the
identifier match the account handle, which is public. An attacker could write
and delete records, upload blobs, and act as the account on the network.
Logins are now refused until a password is configured.
A PDS deployed without one accepted logins and will now refuse them: set the
`password` option (`PDS_PASSWORD`) to restore access. That is a behaviour
change, but it ships as a patch deliberately — a security fix nobody can adopt
without planning a major upgrade is a security fix that goes untaken. There is
no opt-out, because the previous behaviour has no safe use.
## 1.0.2
### Patch Changes
- Report the actual package version from `/xrpc/_health`. The constant is now
rewritten during `version-packages` instead of by hand, so it cannot lag the
release again — 1.0.1 shipped reporting 1.0.0.
## 1.0.1
### Patch Changes
- de2ceed: Report the correct version from `/xrpc/_health`. The constant was hardcoded and
had gone stale, reporting 0.7.0 while the package was 0.8.0. A test now asserts
it matches the package version, so it cannot drift again.
## 1.0.0
### Minor Changes
- 3b7895c: Add permissioned data (proposal 0016) and publish under the `@pdsjs` scope.
`@pdsjs/spaces` is new: permissioned repos, space credentials, LtHash set
hashing, deniable commit signatures, and the space HTTP routes. It is opt-in —
a PDS only serves space endpoints when it is passed `spaceRoutes`. Verified
against the reference implementation (`bluesky-social/atproto` PR #5187) in both
directions.
`@pdsjs/core` gains `SpaceStoragePort`, the shared space schema, `space:` OAuth
scope parsing, and consent-screen rendering for space grants.
All packages move from the unpublished `@pds` scope to `@pdsjs`.