Repository navigation
feat(pds): implement com.atproto.identity.updateHandle - #250
decoded-cipher wants to merge 3 commits into
Conversation
How to verifyRead-only commands. The test instances stay up until this is merged. did:web ( # 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.devdid:plc ( # 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.devRoute exists (on 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"} |
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
a469d15 to
7bd07d5
Compare
|
I've rebased this onto #253 so it uses Also added: |
Summary
Changing your handle from Bluesky (or any app) fails with "Failed to change handle".
com.atproto.identity.updateHandlehad no route, so it fell through to the AppView proxy, which answers501 MethodNotImplemented.This adds the endpoint, following the reference PDS:
/.well-known/atproto-did. The PDS hostname needs no lookup, since this worker always serves our DID there.alsoKnownAsin the PLC directory, signed with the signing key using the helpers insrc/plc.ts. Skips this if the directory already has it.identity:handle. Session tokens, app passwords andAUTH_TOKENpass, 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-rowaccount_handletable, and everything that servedenv.HANDLEnow reads it through agetHandlehelper: the did:web DID document,resolveHandle,describeRepo, sessions, OAuth, identity events, and the homepage, status and passkey pages. The row remembers whichHANDLEit replaced, so ifHANDLEis changed and redeployed later, the var wins again.This is now based on #253 and targets
feat/plc-rotation-keys, so both can reachmaintogether through #253.Closes #207
Commits
feat(pds): store a handle that overrides the HANDLE var (storage only).refactor(pds): read the account handle through the account store (no behaviour change).feat(pds): implementcom.atproto.identity.updateHandle, with tests, README section, changeset and plan doc row.Notes for review
updatePlcHandleusesgetLatestPlcOperationandsignOperationfromsrc/plc.ts. When the signing key is not a rotation key, the error points topds rotation-keys, or to settingalsoKnownAswith a rotation key the user holds; retrying then succeeds because the PLC step is skipped. The README section says the same.getServiceAuthmints them for any caller, and a self-addressed one bound toupdateHandlepassedrequireAuthwith full trust, skipping theidentity:handlecheck.updateHandlenow returns 403 for them, usingAuthInfo.methodfrom 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 widergetServiceAuthissue noted in feat(pds): give Cirrus a PLC rotation key and offer recovery keys #253 is unchanged.identity:handlecheck applies only to OAuth tokens./.well-known/did.json, the homepage andresolveHandlenow read the handle from the Durable Object instead of env.pds status,activate,deactivate,dashboard) still readsHANDLEfrom config, so after a handle change those checks look at the old handle. Left for a follow-up to keep this focused.Test plan
describeRepo,resolveHandle,createSession, identity event), did:plc (signed op verified,alsoKnownAsupdate, skip when current, not a rotation key, PLC rejection or unreachable, no duplicate entries),HANDLEprecedence, OAuthidentity:handlescope, service JWTs refused, app passwords accepted.alsoKnownAsto the new handle, signed with the Cirrus key,prevchained to the previous op, other fields unchanged; plc.directory and Bluesky's AppView then reported the new handle.src/plc.ts; a self-addressed service JWT bound toupdateHandlereturned 403 on both instances.Before / after (bsky.app, did:web test instance)
Same Worker and handle, deployed from
main, then from this branch:main)updateHandlewas proxied to the AppView and returned501 MethodNotImplemented.describeRepoandcreateSessionuse the new handle (the old one is rejected), the PDS firehose carries the#identityevent, and Bluesky's AppView reports the new handle for the DID.