chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# @pdsjs/cloudflare
## 2.5.2
### Patch Changes
- Updated dependencies
- @pdsjs/core@2.5.2
- @pdsjs/drive@0.1.7
- @pdsjs/npm@0.1.3
- @pdsjs/oci@0.1.3
- @pdsjs/sites@2.1.9
- @pdsjs/spaces@2.0.10
- @pdsjs/git@0.1.5
- @pdsjs/lexicon-resolver@2.5.2
- @pdsjs/blobs-s3@2.5.2
## 2.5.1
### Patch Changes
- Updated dependencies [542c4dc]
- @pdsjs/core@2.5.1
- @pdsjs/drive@0.1.6
- @pdsjs/npm@0.1.2
- @pdsjs/oci@0.1.2
- @pdsjs/sites@2.1.8
- @pdsjs/spaces@2.0.9
- @pdsjs/git@0.1.4
- @pdsjs/lexicon-resolver@2.5.1
- @pdsjs/blobs-s3@2.5.1
## 2.5.0
### Minor Changes
- 9ba00ac: The Cloudflare worker can send account mail through the Email Service
`EMAIL` binding, so an address can be confirmed and a PLC operation can be
authorized by code. The worker wires the binding into the PDS through the
`emailer` port: with `[[send_email]]` bound and a sender resolvable — a
`PDS_EMAIL_FROM` var, else `no-reply@HOSTNAME` — the account page offers
verification; without either, it stores an address that stays unconfirmed,
the same behavior as before. The From address's domain must be onboarded for
Email Sending.
### 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.
- 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.
- f777a2c: The protected-branch check reads the commits out of the chain instead of
downloading it. The guard answers one question, whether the old tip is an
ancestor of the new one, and every object in the repository was materialized
to answer it. On pds.js's own repository that costs 199 MB, which no
Cloudflare isolate has.
The lazy object store moves to `store.js`, where the write guard reads it as
the repository browser does: the bundle indexes, then ranged reads for the
commits alone. Both platform adapters give the guard a `getBlobRange`
wherever the blob store reads ranges. The same check costs 43 MB and 33 ms,
against 199 MB and 428 ms. A chain with no index, or a store with no ranges,
reads whole as before.
- 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.
- aeb61f9: Repositories in a permissioned space browse with ranged reads. The space
reader served whole blobs only, so the account page downloaded a private
repository's whole bundle chain to list a directory, while a public one read
single objects out of the chain. `SpaceReader` takes an optional
`getBlobRange`, and both platform adapters supply it wherever the blob store
reads ranges. A space repository whose bundles all carry an object index now
reads lazily, as a public one does; a reader without ranges still reads the
chain whole.
- 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`.
- e2f072c: An npm registry served from the account's own repo, as the new `@pdsjs/npm`
optional peer. `experimental.npm = true` on node, or
`PDS_EXPERIMENTAL_NPM = "true"` on Cloudflare, serves the registry protocol
under `/npm/`: stock npm publishes with the account's credentials and
anyone installs anonymously. Dist-tags, version manifests and tarballs land
in a `dev.pdsjs.npm.package` record plus blob storage, so packages ride
along in CAR backups and migrate with the account. `npm login`, `npm
publish`, `npm install`, `npm view` and `npm dist-tag` all work against it.
- fe20e9f: An OCI container registry served from the account's own repo, as the new
`@pdsjs/oci` optional peer. `experimental.oci = true` on node, or
`PDS_EXPERIMENTAL_OCI = "true"` on Cloudflare, serves the distribution
API under `/v2/`: stock docker pushes with the account's credentials and
anyone pulls anonymously. Tags, manifests and layers land in a
`dev.pdsjs.oci.repo` record plus blob storage, so images ride along in CAR
backups and migrate with the account. A layer's OCI digest and its blob CID
hold the same SHA-256 bytes, so layers store as single blobs with no mapping
table, streamed through the blob writer without ever being held whole.
- 746c6e9: Registry reads stay out of the site edge cache. /v2/ and /npm/ answer on
site hostnames too, and their responses are mutable: a packument gains
versions and a manifest tag moves. A cached copy served a package after its
record was deleted.
- 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.
- 93816a1: A space governs reads and writes separately, tracking
[atproto PR #5187](https://github.com/bluesky-social/atproto/pull/5187).
`createSpace` and `updateSpace` take a `readPolicy` and a `writePolicy`,
`getSpace` answers with both, and `com.atproto.simplespace.putMember` sets a
member's `read` and `write` rights, which `listMembers` reports. The read
policy decides who may hold a space credential. The write policy decides whose
`notifyWrite` the authority records, so a writer the space does not admit stays
out of the writer set and no syncer pulls their repo. The authority passes
`access` to a managing app's `checkUserAccess`, and omits `clientId` on a write
check: that notice comes from a PDS, not an app.
The earlier spellings still answer. A single `policy` sets both sides,
`addMember` adds a member who reads and writes, and `getSpace` sends the two
unions, the single one, and the `#spaceConfig` object together. A space created
before the split keeps its policy on both sides, and an existing member keeps
both rights.
A delegation token and a client attestation are each spent once. Both must
carry a `jti`, and an authority that has honoured one refuses it again, so a
captured credential exchange cannot be replayed.
A client attestation is verified rather than read. The authority resolves the
`iss` to its `client-metadata.json`, fetches the key set that document
publishes, and checks the signature against the key its `kid` names. An
`appAccess` allow list rests on that check, and until now stated a rule the
authority could not enforce.
`SpaceStoragePort` replaces `addMember` and `isMember` with `putMember` and
`getMember`, `listMembers` answers with `members` rather than `dids`, and
`SpaceRow` carries `readPolicy`, `readManagingApp`, `writePolicy` and
`writeManagingApp`. Both SQLite adapters add the columns to a database they
already hold. `@pdsjs/core/oauth` exports `fetchClientMetadata`,
`resolveClientJwk` and `verifyEs256WithJwk`, which the OAuth token endpoint and
the space authority both use.
- 3d475f8: Permissioned data tracks the October 1 spaces alpha of the reference
implementation (`0.0.0-spaces-alpha-20261001173819`, atproto
`permissioned-data-alpha` at `79d6307e1`).
A space credential is bound to its holder's key with an RFC 9421 HTTP message
signature instead of DPoP. The application signs the `getSpaceCredential`
request's `Authorization` header with a fresh P-256 key, named as a did:key in
`keyid`, and the authority binds the credential to it as `cnf.kid`. A request
presenting the credential uses the `Atproto-Space` scheme and an
`Atproto-Space-Audience` header naming the account it is addressed to, and
signs both. A host refuses a signature made for another account
(`BadSpaceAudience`) or by another key (`BadSpaceSignature`). DPoP credentials,
the `dpopJkt` field and the `DPoP` presentation scheme are no longer accepted.
`@pdsjs/spaces/http-signature` signs and verifies both shapes, and the git
remote helper and the spaces inspector sign with it.
A credential lives ten minutes by default and never more than an hour, and must
carry a finite `iat`, an `exp` after it and a `jti`. A repo host serves
`com.atproto.space.notifyCredentialRevoked`: a space's authority names up to 100
credential ids, and the host refuses those credentials with
`CredentialRevoked` for an hour and ten seconds.
The authority sequences the writer set. Each accepted `notifyWrite` gets a
`spaceRev` that increases across the space, and the notice forwarded to a
syncer carries `repoRev`, `spaceRev` and `prevSpaceRev`. A repeated or older
repo rev is not forwarded again, and a repo rev more than five minutes ahead is
refused with `FutureRev`. `listRepos` entries carry `repoRev` and `spaceRev`,
are ordered by `spaceRev`, and page on a space-revision cursor that every
non-empty page ends on. A writer recorded before this release is sequenced at
its repo rev. The authority accepts a notice addressed to
`<authority>#atproto_space_host` as well as to its bare DID, and a body naming
`rev` instead of `repoRev`.
A repo host stores each write notice it owes an authority before sending it,
and retries a transient failure with backoff for up to a day, keeping only the
newest notice per space. Extensions may declare a `tick`, which core runs
through `runExtensionTicks`: Node and Deno every ten seconds, Cloudflare at its
alarm, which an extension brings forward with the host's new `scheduleWakeup`.
`createSpace` and `listSpaces` take `spaceType`, and still read `type`.
`SpaceStoragePort` changes: `putSpaceWriter` takes a `spaceRev`, writer rows
carry `repoRev` and `spaceRev`, and the port gains `getSpaceWriter`,
`getSpaceRevHead`, the revocation methods and the notice outbox methods. The
schema adds `space_writer.space_rev`, `space_credential_revocation` and
`space_notify_outbox`, migrated in place.
- Updated dependencies [a67a568]
- Updated dependencies [d66fcff]
- Updated dependencies [05a11b4]
- Updated dependencies [f79dd82]
- Updated dependencies [c68858b]
- Updated dependencies [04b15d0]
- Updated dependencies [288063a]
- Updated dependencies [c3c4caa]
- Updated dependencies [bcb5758]
- Updated dependencies [e0b38cd]
- Updated dependencies [4fb8f23]
- Updated dependencies [1cf113d]
- Updated dependencies [04b15d0]
- Updated dependencies [c4af198]
- Updated dependencies [f777a2c]
- Updated dependencies [a981627]
- Updated dependencies [3febe1c]
- Updated dependencies [ac5c8eb]
- Updated dependencies [00bd461]
- Updated dependencies [83083a5]
- Updated dependencies [2c68bdc]
- Updated dependencies [fe7ffe0]
- Updated dependencies [211c7c8]
- Updated dependencies [6cf84d5]
- Updated dependencies [35f794f]
- Updated dependencies [522071f]
- Updated dependencies [32db640]
- Updated dependencies [04b15d0]
- Updated dependencies [cc4577b]
- Updated dependencies [cc4577b]
- Updated dependencies [f5aae7c]
- Updated dependencies [cc4577b]
- Updated dependencies [fe3c9ac]
- Updated dependencies [b99acf8]
- Updated dependencies [c1ec3d6]
- Updated dependencies [04b15d0]
- Updated dependencies [7c06024]
- Updated dependencies [6935a55]
- Updated dependencies [15c69af]
- Updated dependencies [a56be28]
- Updated dependencies [673ec05]
- Updated dependencies [74819af]
- Updated dependencies [4dfdbfa]
- Updated dependencies [04b15d0]
- Updated dependencies [aeb61f9]
- Updated dependencies [04b15d0]
- Updated dependencies [04b15d0]
- Updated dependencies [f27dcf7]
- Updated dependencies [c9e3550]
- Updated dependencies [c01859b]
- Updated dependencies [41df38b]
- Updated dependencies [411b47b]
- Updated dependencies [ca47f96]
- Updated dependencies [861663f]
- Updated dependencies [617df4a]
- Updated dependencies [154e7d7]
- Updated dependencies [bda4794]
- Updated dependencies [10c91f6]
- Updated dependencies [0e1bf66]
- Updated dependencies [77eb9db]
- Updated dependencies [76f5187]
- Updated dependencies [b51250e]
- Updated dependencies [799286b]
- Updated dependencies [4adc49e]
- Updated dependencies [e83838c]
- Updated dependencies [9149663]
- Updated dependencies [16ea1fe]
- Updated dependencies [fd401a4]
- Updated dependencies [abae790]
- Updated dependencies [93816a1]
- Updated dependencies [3d475f8]
- Updated dependencies [04b15d0]
- Updated dependencies [5bb14a2]
- Updated dependencies [078c2df]
- Updated dependencies [ec85961]
- Updated dependencies [d5f111c]
- @pdsjs/sites@2.1.7
- @pdsjs/core@2.5.0
- @pdsjs/git@0.1.3
- @pdsjs/spaces@2.0.8
- @pdsjs/lexicon-resolver@2.5.0
- @pdsjs/npm@0.1.1
- @pdsjs/oci@0.1.1
- @pdsjs/blobs-s3@2.5.0
- @pdsjs/drive@0.1.5
## 2.4.3
### Patch Changes
- Updated dependencies [d307f57]
- Updated dependencies [2d0d653]
- @pdsjs/core@2.4.3
- @pdsjs/drive@0.1.4
- @pdsjs/sites@2.1.6
- @pdsjs/spaces@2.0.7
- @pdsjs/lexicon-resolver@2.4.3
- @pdsjs/blobs-s3@2.4.3
## 2.4.2
### Patch Changes
- Updated dependencies [c235e96]
- @pdsjs/core@2.4.2
- @pdsjs/drive@0.1.3
- @pdsjs/sites@2.1.5
- @pdsjs/spaces@2.0.6
- @pdsjs/lexicon-resolver@2.4.2
- @pdsjs/blobs-s3@2.4.2
## 2.4.1
### Patch Changes
- Updated dependencies [fa1558c]
- @pdsjs/core@2.4.1
- @pdsjs/drive@0.1.2
- @pdsjs/sites@2.1.4
- @pdsjs/spaces@2.0.5
- @pdsjs/lexicon-resolver@2.4.1
- @pdsjs/blobs-s3@2.4.1
## 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
- 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.
- 9a6f2e8: The account rail heading over Sites and Repositories reads "Labs".
- 267d341: The repository page draws skeleton placeholders while it loads. The file
listing, a file's contents, and the commit history each get the shape of the
content they are about to show, instead of a blank panel.
- cd599b2: The `/cf-api/` passthrough allows the zone list and a zone's DNS records,
which the setup wizard's domain picker uses: worker custom domains attach
through the account paths already allowed, and the handle is proven by a
`_atproto` TXT record.
- Updated dependencies [ee6d696]
- Updated dependencies [cb64c59]
- Updated dependencies [54bec84]
- Updated dependencies [922fb50]
- Updated dependencies [68ad18f]
- Updated dependencies [f716d9d]
- Updated dependencies [68ad18f]
- Updated dependencies [68ad18f]
- Updated dependencies [ae02630]
- Updated dependencies [68ad18f]
- Updated dependencies [4c334b3]
- Updated dependencies [86d8eb4]
- Updated dependencies [6b79149]
- Updated dependencies [8616855]
- Updated dependencies [af8b1fd]
- Updated dependencies [715c475]
- @pdsjs/core@2.4.0
- @pdsjs/git@0.1.2
- @pdsjs/blobs-s3@2.4.0
- @pdsjs/drive@0.1.1
- @pdsjs/sites@2.1.3
- @pdsjs/spaces@2.0.4
- @pdsjs/lexicon-resolver@2.4.0
## 2.3.0
### 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.
- de2859f: `PDS_EXPERIMENTAL_CF_API_PROXY` forwards `/cf-api/` paths to
api.cloudflare.com, which answers no CORS preflight of its own. The
passthrough serves the setup wizard when it runs as a static site on the
same worker: it forwards the caller's Authorization header, allows only the
paths the wizard uses, and keeps nothing. The prefix answers on every
hostname the worker serves, so a site page reaches it same-origin.
- 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.
- 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.
- 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.
- Updated dependencies [232a280]
- Updated dependencies [c541c98]
- Updated dependencies [f8e3dff]
- Updated dependencies [9e04a41]
- Updated dependencies [4a19757]
- Updated dependencies [bdad8c4]
- Updated dependencies [51d6402]
- Updated dependencies [d8cae21]
- Updated dependencies [d8cae21]
- Updated dependencies [df17b17]
- Updated dependencies [61f1ce8]
- Updated dependencies [c7f3181]
- Updated dependencies [24c9e1e]
- Updated dependencies [035450d]
- Updated dependencies [3920c52]
- Updated dependencies [585139b]
- Updated dependencies [89b33a7]
- Updated dependencies [5125583]
- @pdsjs/core@2.3.0
- @pdsjs/git@0.1.1
- @pdsjs/spaces@2.0.3
- @pdsjs/lexicon-resolver@2.3.0
- @pdsjs/sites@2.1.2
- @pdsjs/blobs-s3@2.3.0
## 2.2.0
### Minor Changes
- 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.
- 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.
- 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.
- 224cb48: `createS3FetchBackupTarget`: back up to any S3-compatible bucket over plain
fetch and WebCrypto — no AWS SDK, so it runs on Cloudflare Workers, Node, and
Deno alike. Signature Version 4 with path-style addressing and the four basic
operations, which is the subset every compatible provider (Bunny, Backblaze
B2, MinIO, R2, AWS) implements; the signer is verified against AWS's own
documented example vectors.
On Cloudflare this unlocks off-provider backups: set `BACKUP_S3_ENDPOINT`,
`BACKUP_S3_BUCKET`, `BACKUP_S3_REGION` as vars and the two keys as secrets,
and the target wins over the same-account `BACKUPS` R2 binding. The Node
example now uses it for `PDS_BACKUP_S3_*` too, dropping the
`@aws-sdk/client-s3` requirement.
- 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.
- 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
- c9337f8: Store account state in the Durable Object's SQLite, and drop the KV namespace.
Sessions, OAuth tokens/requests, DPoP jtis, app passwords, and passkeys now live
in the same strongly-consistent SQLite as the repo, so a read always sees its
own writes — fixing the lag and dedup misses that KV's eventual consistency
caused (and hardening DPoP replay checks, which eventual consistency could
race). This matches how the Node adapter and Cirrus already work.
The `SHARED_KV` binding is gone. Remove the `[[kv_namespaces]]` block from
`wrangler.toml` before deploying. A spare binding deploys fine, so this is
tidiness. The repo, blobs, DID and handle are unaffected. They were never in KV.
A Worker upgrading from 2.1.0 does start with empty auth state: apps must
authorize again, and app passwords and passkeys must be created again. The KV
keys are left untouched, so that data is still readable with `wrangler kv`.
- 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.
- 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.
- 5a39d42: Let go of firehose subscribers that have gone away.
Subscribers are held as hibernated WebSockets, which outlive a restart of the
Durable Object. Nothing dropped one whose far end had died: the closing
handshake was never completed (required below compatibility date 2026-04-07),
socket errors were unhandled, and a failed send was swallowed. Such a socket
stays in `getWebSockets()` and keeps absorbing broadcasts, so the server holds a
connection it can no longer deliver on while the subscriber, never told to go
away, never reconnects — federation stops with both ends reporting themselves
healthy, and the sockets accumulate.
The handshake is now completed on close, a socket that errors is released, and
one whose send fails is closed rather than swallowed. A subscriber told the
connection ended reconnects on its own.
- Updated dependencies [261d4f4]
- Updated dependencies [dfce920]
- Updated dependencies [a4cae1f]
- Updated dependencies [dfcf1cc]
- Updated dependencies [99c414f]
- Updated dependencies [261d4f4]
- Updated dependencies [218f25c]
- Updated dependencies [1b8cd1e]
- Updated dependencies [f2df586]
- Updated dependencies [e761cdb]
- Updated dependencies [d679bf1]
- Updated dependencies [fd33b13]
- Updated dependencies [d88b21d]
- Updated dependencies [8ce9621]
- Updated dependencies [faceaf7]
- Updated dependencies [bc5314a]
- Updated dependencies [f800d5a]
- Updated dependencies [eeb7976]
- Updated dependencies [b368c19]
- Updated dependencies [44bd2b5]
- Updated dependencies [6e47bc6]
- Updated dependencies [5a39d42]
- Updated dependencies [72965f8]
- Updated dependencies [09e110d]
- Updated dependencies [098af68]
- Updated dependencies [7898894]
- Updated dependencies [378e2bb]
- Updated dependencies [4363389]
- Updated dependencies [011e28d]
- Updated dependencies [4169189]
- Updated dependencies [ee386d7]
- Updated dependencies [8cbae75]
- Updated dependencies [4363389]
- Updated dependencies [4a9943d]
- Updated dependencies [9abf6d6]
- Updated dependencies [9524139]
- Updated dependencies [62dc25a]
- Updated dependencies [8d62485]
- Updated dependencies [f1c6639]
- Updated dependencies [224cb48]
- Updated dependencies [224cb48]
- Updated dependencies [5a39d42]
- Updated dependencies [fd54c7d]
- Updated dependencies [67387fe]
- Updated dependencies [91e52aa]
- Updated dependencies [1852faf]
- Updated dependencies [70925d3]
- Updated dependencies [9ceb6ad]
- Updated dependencies [e628859]
- @pdsjs/core@2.2.0
- @pdsjs/sites@2.1.1
- @pdsjs/lexicon-resolver@2.2.0
- @pdsjs/blobs-s3@2.2.0
- @pdsjs/spaces@2.0.2
## 2.1.0
### Patch Changes
- Updated dependencies [8ac38c1]
- Updated dependencies [f5335a0]
- @pdsjs/core@2.1.0
- @pdsjs/spaces@2.0.1
- @pdsjs/lexicon-resolver@2.1.0
## 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
- Updated dependencies [62c6521]
- Updated dependencies [13e3049]
- @pdsjs/core@2.0.0
- @pdsjs/spaces@2.0.0
- @pdsjs/lexicon-resolver@2.0.0
## 1.0.2
### Patch Changes
- Updated dependencies
- @pdsjs/core@1.0.2
- @pdsjs/spaces@1.0.2
- @pdsjs/lexicon-resolver@1.0.2
## 1.0.1
### Patch Changes
- Updated dependencies [de2ceed]
- @pdsjs/core@1.0.1
- @pdsjs/spaces@1.0.1
- @pdsjs/lexicon-resolver@1.0.1
## 1.0.0
### Patch Changes
- Updated dependencies [3b7895c]
- @pdsjs/core@1.0.0
- @pdsjs/spaces@1.0.0
- @pdsjs/lexicon-resolver@1.0.0