Skip to content

Publishing to standard.site

With a pds section in the config, blogwright mirrors the site to your AT Protocol PDS as standard.site records: one site.standard.publication record for the site and one site.standard.document record per post. After a one-time setup, publishing is hands-off — every successful production deploy re-reconciles the records against your local content.

The sync is a reconcile, not an event stream:

  • One publication record (site.standard.publication) describes the site: its URL (https://<domain>), the display name from pds.name, and the optional pds.description. It is created once by pds init and updated whenever the config drifts from the live record. Preferences you set out-of-band in a standard.site client (such as turning showInDiscover off) are preserved — a config-driven update never clobbers them.
  • One document record per post (site.standard.document) carries the post’s title, description, publication date, and URL path. Posts are enumerated from the content collection (src/content/blog by default, overridable via paths.content): every .md/.mdx file, excluding drafts (draft: true); each post must carry title, description, and pubDate frontmatter — a file missing any of them fails the sync rather than being skipped. A frontmatter slug wins outright; otherwise the slug is the file path minus its extension and any trailing /index segment — the same id Astro’s glob loader produces.

Each document’s record key (rkey) is derived deterministically from the post’s URL path/posts/<slug>/ — using a vendored TID scheme from mastrojs/atproto. No lookup table, no PDS round-trip: the CLI and your site can both compute the rkey from the path alone, which is how the site builds its <link rel="site.standard.document"> tags via the blogwright/rkey subpath export. See the rkey reference for the derivation.

A name is all that is required:

config/production.jsonc
{
"region": "us-east-1",
"siteName": "example",
"domain": "example.com",
"pds": {
"name": "My Blog",
},
}
Key Default Purpose
pds.name (required) Publication display name, and the OAuth client name shown on the consent screen.
pds.description none Optional publication description.
pds.secretName <siteName>/atproto Secrets Manager secret holding the OAuth client key and session.
pds.handleResolver https://public.api.bsky.app Resolver pds login uses to turn a handle into a DID. Must be an https URL. Logging in with a bare DID skips handle resolution entirely — the PDS endpoint itself is always discovered from the DID document during OAuth.

Publishing also requires a configured domain — the OAuth client identity and every published URL are built from it. See Custom domains and the configuration reference. Without a pds section the feature is entirely inert.

Auth is atproto OAuth, and the site itself is a confidential OAuth client. It serves its own client documents from protocol-fixed paths:

  • /oauth/client-metadata.json — the client metadata; per the atproto OAuth spec its URL is the client id.
  • /oauth/jwks.json — the public half of the client’s ES256 key.
  • /oauth/callback — the redirect target. A static page that displays the redirect parameters is enough, because the login flow only asks you to paste the URL you land on back into the terminal.

Both JSON documents are generated by pds keygen and committed to the repo (under paths.publicDir, public/ by default). The private key and the OAuth session live in a Secrets Manager secret (<siteName>/atproto by default), read only at keygen, login, and sync time — they never enter the builder MicroVM.

Refresh tokens are single-use: every sync transparently refreshes the session and writes the rotated token back to the secret. Confidential-client sessions live indefinitely as long as they refresh within 180 days, so a site that never goes that long between production deploys never needs to log in again. Concurrent syncs (overlapping CI deploys) are safe — when one sync rotates the session under another, the client re-reads the secret and adopts the rotated session instead of destroying it.

Order matters: the OAuth documents must be live on the site before login can run, because both pds login and pds init first fetch the deployed /oauth/ documents and verify they match the local key and config — a stale or missing deployment would otherwise fail deep inside the OAuth flow.

Terminal window
blogwright pds keygen

This generates an ES256 key, stores the private JWK in the Secrets Manager secret, and writes the two public client documents:

  • public/oauth/client-metadata.json
  • public/oauth/jwks.json

Keygen clears any stored session — client auth is bound to the key, so re-running it means logging in again. (It is also the migration path from the pre-OAuth app-password secret format, which is no longer supported.)

Terminal window
git add public/oauth && git commit -m "Add OAuth client documents"
blogwright deploy

The documents must be reachable at https://<domain>/oauth/… before the next step.

Terminal window
blogwright pds login --identifier <handle-or-did>

The CLI prints an authorization URL. Open it in a browser, approve access on your PDS, land on the site’s /oauth/callback page, and paste the full callback URL — query string and all — back into the terminal. The session and your DID are persisted into the secret.

Terminal window
blogwright pds init
git add public/.well-known src/data/atproto.json && git commit -m "Add standard.site publication"

pds init creates the site.standard.publication record (or updates it, idempotently, when one already exists) and writes two files for you to commit:

  • public/.well-known/site.standard.publication — the publication’s AT-URI; standard.site clients use it to verify the site.
  • src/data/atproto.json{ did, publicationUri }, which the site imports to render its document <link> tags.

If the committed well-known file points at a publication owned by a different account (a fork, or an account migration), pds init refuses rather than silently publishing documents against a publication you do not own — delete the file to start fresh, or log in with the owning account.

Terminal window
blogwright pds sync

This reconciles all document records: creates missing ones, updates drifted ones, and reports how many were unchanged. pds sync publishes canonical production URLs and refuses to run for any other environment.

From here on, publishing is automatic. Every successful production deploy re-runs the reconcile as a post-deploy step. It is deliberately non-fatal: a PDS outage must not fail a good site deploy, so a failed sync logs a warning and the next deploy heals. Non-production environments and uninitialised sites skip the step entirely.

A few behaviors worth knowing:

  • Deleted posts are never deleted from the PDS. Records with no matching local post are reported as orphans with a warning listing their rkeys — remove them manually if the deletion was intentional.
  • A lapsed session warns and skips. If the session is no longer valid (no refresh for 180 days, or a rotation raced), the post-deploy sync warns and the deploy succeeds; a direct pds sync fails with the same explanation. Either way, re-run blogwright pds login — the client key survives, so no keygen or redeploy is needed.
  • Sanity checks before anything is written. The sync verifies the committed well-known file matches src/data/atproto.json, and that the logged-in DID matches the one recorded at init — mismatches fail with a pointer to pds init or the offending file. Two slugs that derive the same rkey fail the sync as a collision.
Terminal window
blogwright pds secret status # metadata + which parts exist — never values
blogwright pds secret delete --yes # log out and discard the client key

pds secret status shows the secret’s name, ARN, and last-changed time, plus whether it holds a client key (and its key id), a DID, and a session — it never prints secret values. pds secret delete requires --yes and deletes the secret immediately, with no recovery window; getting back to a working state afterwards means starting over from pds keygen.