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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1494 lines · 87.3 KB Raw History

@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. 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.