Skip to content

feat(pds): implement com.atproto.identity.updateHandle - #250

Open
decoded-cipher wants to merge 3 commits into
ascorbic:feat/plc-rotation-keysfrom
decoded-cipher:fix/207-update-handle
Open

decoded-cipher wants to merge 3 commits into
ascorbic:feat/plc-rotation-keysfrom
decoded-cipher:fix/207-update-handle

Conversation

@decoded-cipher

@decoded-cipher decoded-cipher commented Sep 29, 2026 •

Copy link
Copy Markdown

Summary

Changing your handle from Bluesky (or any app) fails with "Failed to change handle". com.atproto.identity.updateHandle had no route, so it fell through to the AppView proxy, which answers 501 MethodNotImplemented.

This adds the endpoint, following the reference PDS:

  • Lowercases and validates the handle, and rejects disallowed TLDs.
  • Checks that the handle resolves to the account's DID: DNS TXT first, then /.well-known/atproto-did. The PDS hostname needs no lookup, since this worker always serves our DID there.
  • For did:plc accounts, writes the handle to alsoKnownAs in the PLC directory, signed with the signing key using the helpers in src/plc.ts. Skips this if the directory already has it.
  • Stores the handle and emits an identity event so relays and AppViews re-verify it.
  • OAuth tokens need identity:handle. Session tokens, app passwords and AUTH_TOKEN pass, as in the reference PDS. Service JWTs are refused.

Since the handle used to come only from HANDLE, the stored handle is kept in a new single-row account_handle table, and everything that served env.HANDLE now reads it through a getHandle helper: the did:web DID document, resolveHandle, describeRepo, sessions, OAuth, identity events, and the homepage, status and passkey pages. The row remembers which HANDLE it replaced, so if HANDLE is changed and redeployed later, the var wins again.

This is now based on #253 and targets feat/plc-rotation-keys, so both can reach main together through #253.

Closes #207

Commits

  1. feat(pds): store a handle that overrides the HANDLE var (storage only).
  2. refactor(pds): read the account handle through the account store (no behaviour change).
  3. feat(pds): implement com.atproto.identity.updateHandle, with tests, README section, changeset and plan doc row.

Notes for review

  • Based on feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253. updatePlcHandle uses getLatestPlcOperation and signOperation from src/plc.ts. When the signing key is not a rotation key, the error points to pds rotation-keys, or to setting alsoKnownAs with a rotation key the user holds; retrying then succeeds because the PLC step is skipped. The README section says the same.
  • Service JWTs are refused. getServiceAuth mints them for any caller, and a self-addressed one bound to updateHandle passed requireAuth with full trust, skipping the identity:handle check. updateHandle now returns 403 for them, using AuthInfo.method from feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253. The reference PDS doesn't accept service auth for this method either. The wider getServiceAuth issue noted in feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253 is unchanged.
  • App passwords can change the handle, matching the reference PDS, where the identity:handle check applies only to OAuth tokens.
  • /.well-known/did.json, the homepage and resolveHandle now read the handle from the Durable Object instead of env.
  • The CLI (pds status, activate, deactivate, dashboard) still reads HANDLE from config, so after a handle change those checks look at the old handle. Left for a follow-up to keep this focused.

Test plan

  • PDS unit suite passes on top of feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253 (369, 20 new): validation, DNS and well-known verification, did:web end to end (DID doc, describeRepo, resolveHandle, createSession, identity event), did:plc (signed op verified, alsoKnownAs update, skip when current, not a rotation key, PLC rejection or unreachable, no duplicate entries), HANDLE precedence, OAuth identity:handle scope, service JWTs refused, app passwords accepted.
  • PDS CLI suite passes (124).
  • Each commit builds and passes tests on its own; no new type errors.
  • Change handle from bsky.app on a deployed did:web instance (details below).
  • Same on a did:plc instance whose signing key is a rotation key: the PDS appended a PLC operation setting alsoKnownAs to the new handle, signed with the Cirrus key, prev chained to the previous op, other fields unchanged; plc.directory and Bluesky's AppView then reported the new handle.
  • Rebased onto feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253 and redeployed both instances: on the did:plc instance the handle was changed to the PDS hostname and back, giving two more PLC operations signed through src/plc.ts; a self-addressed service JWT bound to updateHandle returned 403 on both instances.

Before / after (bsky.app, did:web test instance)

Same Worker and handle, deployed from main, then from this branch:

Before (main) After (this PR)
image image
  • Before: updateHandle was proxied to the AppView and returned 501 MethodNotImplemented.
  • After: the DID document, describeRepo and createSession use the new handle (the old one is rejected), the PDS firehose carries the #identity event, and Bluesky's AppView reports the new handle for the DID.
  • A handle with no DNS record returns 400 with the TXT record to add, and the handle is left unchanged.

@decoded-cipher
decoded-cipher marked this pull request as draft September 29, 2026 18:01
@decoded-cipher

decoded-cipher commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

How to verify

Read-only commands. The test instances stay up until this is merged.

did:web (did:web:pds-test.arjunkrishna.dev, handle changed to test2.arjunkrishna.dev from bsky.app):

# DID document served by the PDS
curl -s https://pds-test.arjunkrishna.dev/.well-known/did.json | jq -c .alsoKnownAs
# ["at://test2.arjunkrishna.dev"]

# Bluesky AppView's view of the account
curl -s 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=did:web:pds-test.arjunkrishna.dev' | jq -r .handle
# test2.arjunkrishna.dev

did:plc (did:plc:h4h4mit4eqaxnc3lcjycbp5c, Cirrus signing key is the rotation key, handle changed to plc-test2.arjunkrishna.dev from bsky.app):

# PLC audit log: genesis, the op written by updateHandle, then a round trip
# (to the PDS hostname and back) after rebasing onto #253
curl -s https://plc.directory/did:plc:h4h4mit4eqaxnc3lcjycbp5c/log/audit \
  | jq -c '.[] | {createdAt, prev: .operation.prev, alsoKnownAs: .operation.alsoKnownAs}'
# {"createdAt":"2026-09-29T17:48:18.700Z","prev":null,"alsoKnownAs":["at://pds-test-plc.arjunkrishna.dev"]}
# {"createdAt":"2026-09-29T17:58:49.585Z","prev":"bafyreib7b7dce7beaf3iw2ysoaql7iuj7rsudqff7aa3cfp2acn6gpnb3u","alsoKnownAs":["at://plc-test2.arjunkrishna.dev"]}
# {"createdAt":"2026-10-05T08:02:34.062Z","prev":"bafyreieihzubhtqawfzqzqwme3yjgeevxj73tirru6m5docbn2b5jn6vdy","alsoKnownAs":["at://pds-test-plc.arjunkrishna.dev"]}
# {"createdAt":"2026-10-05T08:02:38.882Z","prev":"bafyreidujlnfyoi6h5n22c4g6zoxgxsppdhazgaiqnzzwlobrtmi2cxbwy","alsoKnownAs":["at://plc-test2.arjunkrishna.dev"]}

curl -s 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=did:plc:h4h4mit4eqaxnc3lcjycbp5c' | jq -r .handle
# plc-test2.arjunkrishna.dev

Route exists (on main this is proxied to the AppView and returns 501):

curl -s -X POST https://pds-test.arjunkrishna.dev/xrpc/com.atproto.identity.updateHandle \
  -H 'Content-Type: application/json' -d '{"handle":"example.com"}' | jq -c .
# {"error":"AuthMissing","message":"Authorization header required"}

@decoded-cipher
decoded-cipher marked this pull request as ready for review September 29, 2026 18:09
The handle has only ever come from the HANDLE var, so there is nowhere
to keep a handle changed at runtime. Add a single-row account_handle
table to AccountStore with getHandle/setHandle.

The row records the HANDLE value it replaced. If HANDLE is later
changed and redeployed, getHandle returns the var again, so editing
the config keeps working as before.
Every place that served the handle read c.env.HANDLE directly: the
did:web DID document, resolveHandle, describeRepo, session responses,
createSession's identifier check, OAuth login and userinfo, identity
events, getRecommendedDidCredentials, and the homepage, status and
passkey pages.

Route them through a getHandle helper that reads the stored handle and
falls back to HANDLE. Nothing sets a stored handle yet, so behaviour is
unchanged.
updateHandle had no route, so it fell through to the AppView proxy,
which answers 501 MethodNotImplemented. Apps showed "Failed to change
handle" with no way forward.

The new endpoint lowercases and validates the handle, rejects
disallowed TLDs, and checks that it resolves to the account's DID via
DNS TXT, then /.well-known/atproto-did (the PDS hostname needs no
lookup). For did:plc accounts it writes the handle to alsoKnownAs in
the PLC directory, signed with the signing key using the helpers in
src/plc.ts, and skips that step when the directory already has it. It
then stores the handle and emits an identity event so relays and
AppViews re-verify it.

The PLC update needs the signing key to be a rotation key. If it is
not, the endpoint points to `pds rotation-keys`, or to updating
alsoKnownAs with a rotation key the user holds; retrying then succeeds
because the PLC step is skipped.

OAuth tokens need identity:handle, matching the reference PDS. Service
JWTs are refused: getServiceAuth mints them for any caller, so a
self-addressed one would skip that check. App passwords are allowed,
as in the reference PDS.

Closes ascorbic#207
@decoded-cipher
decoded-cipher changed the base branch from main to feat/plc-rotation-keys October 5, 2026 08:25
@decoded-cipher

Copy link
Copy Markdown
Author

I've rebased this onto #253 so it uses src/plc.ts, and pointed it at feat/plc-rotation-keys so both can reach main together through #253. If you'd rather keep them separate, I'll point it back at main.

Also added: updateHandle now refuses service JWTs (a self-addressed one from getServiceAuth skipped the identity:handle check), and the not-a-rotation-key error points to pds rotation-keys. Both test instances are running the combined code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trying to change handle/username fails

1 participant