# @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 `#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/:` (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///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:` 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:///git//`. 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:[?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 ` 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.`) → 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/` 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/` 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/` 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.` 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/` 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:…: `. 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