Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/update-handle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@getcirrus/pds": minor
---

Support changing your handle from apps like Bluesky. `com.atproto.identity.updateHandle` was not implemented, so the request was forwarded to the AppView and failed with "Failed to change handle". The PDS now checks that the new handle resolves to your DID, updates your PLC record for did:plc accounts when its signing key is a rotation key, stores the handle, and emits an identity event so relays pick it up. Changing the `HANDLE` variable still works and takes precedence over a handle set this way.
8 changes: 8 additions & 0 deletions packages/pds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,14 @@ _atproto.alice.example.com TXT "did=did:web:pds.example.com"
dig TXT _atproto.alice.example.com
```

### Changing Handle

Change your handle from the handle settings in Bluesky or any other app that uses `com.atproto.identity.updateHandle`. Set up verification for the new handle first (a DNS TXT record, or the PDS hostname itself); the PDS rejects a handle that does not resolve to your DID.

For did:plc accounts the handle is also listed in the PLC directory (`alsoKnownAs`). The PDS updates it for you if its signing key is one of the DID's [rotation keys](#rotation-and-recovery-keys). Accounts migrated with an earlier version of `pds identity` may not have it: run `pds rotation-keys` once to fix that. Alternatively, update `alsoKnownAs` with a rotation key you control (such as a recovery key), then change the handle in the app. The PDS sees the PLC directory already has the new handle and skips that step.

The new handle is stored by the PDS. If you later change the `HANDLE` variable and redeploy, the variable takes precedence again.

## Configuration

The PDS uses environment variables for configuration. Public values go in `wrangler.jsonc`, secrets are stored via Wrangler or in `.dev.vars` for local development.
Expand Down
44 changes: 41 additions & 3 deletions packages/pds/src/account/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ function sqliteDatetimeToIso(value: string): string {

/**
* SQLite-backed storage for person-account data: preferences, email,
* passkeys, and app passwords. Shares the Durable Object's SQLite database
* with SqliteRepoStorage; the email accessors read the email column of the
* repo_state table, whose schema SqliteRepoStorage owns.
* handle, passkeys, and app passwords. Shares the Durable Object's SQLite
* database with SqliteRepoStorage; the email accessors read the email column
* of the repo_state table, whose schema SqliteRepoStorage owns.
*
* Extends RpcTarget so the Durable Object can hand it to the Worker as a
* stub (via account()) — callers invoke store methods directly over RPC.
Expand Down Expand Up @@ -63,9 +63,47 @@ export class AccountStore extends RpcTarget {
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Handle set via com.atproto.identity.updateHandle (single row).
-- env_handle records the HANDLE var it replaced, so a later
-- change to HANDLE takes precedence again.
CREATE TABLE IF NOT EXISTS account_handle (
id INTEGER PRIMARY KEY CHECK (id = 1),
handle TEXT NOT NULL,
env_handle TEXT NOT NULL
);
`);
}

// ============================================
// Handle Methods
// ============================================

/**
* Get the account's current handle. Returns the handle stored by
* updateHandle, unless the HANDLE var has changed since it was stored,
* in which case the var wins.
*/
getHandle(envHandle: string): string {
const rows = this.sql
.exec("SELECT handle, env_handle FROM account_handle WHERE id = 1")
.toArray();
const row = rows[0];
if (!row || row.env_handle !== envHandle) return envHandle;
return row.handle as string;
}

/**
* Store a new handle, overriding the HANDLE var.
*/
setHandle(handle: string, envHandle: string): void {
this.sql.exec(
`INSERT OR REPLACE INTO account_handle (id, handle, env_handle) VALUES (1, ?, ?)`,
handle,
envHandle,
);
}

/**
* Get user preferences.
*/
Expand Down
27 changes: 17 additions & 10 deletions packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ function getAccountDO(env: PDSEnv) {
}

// DID document for did:web resolution
app.get("/.well-known/did.json", (c) => {
return c.json(identity.buildDidDocument(c.env));
app.get("/.well-known/did.json", async (c) => {
const handle = await identity.getHandle(c.env, getAccountDO(c.env));
return c.json(identity.buildDidDocument(c.env, handle));
});

// Handle verification for AT Protocol
Expand All @@ -139,7 +140,8 @@ app.get("/xrpc/_health", async (c) => {
});

// Homepage
app.get("/", (c) => {
app.get("/", async (c) => {
const handle = await identity.getHandle(c.env, getAccountDO(c.env));
const html = `<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -172,19 +174,19 @@ body {
<div class="cloud">☁️</div>
<div class="name"><a href="https://github.com/ascorbic/cirrus">CIRRUS</a></div>
<div class="what">a personal data server for the atmosphere</div>
<div class="handle"><a href="https://bsky.app/profile/${c.env.HANDLE}" target="_blank">@${c.env.HANDLE}</a></div>
<div class="handle"><a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a></div>
<div class="version">v${version}</div>
</body>
</html>`;
return c.html(html);
});

// Status dashboard
app.get("/status", (c) => {
app.get("/status", async (c) => {
return c.html(
renderDashboard({
hostname: c.env.PDS_HOSTNAME,
handle: c.env.HANDLE,
handle: await identity.getHandle(c.env, getAccountDO(c.env)),
did: c.env.DID,
version,
spacesEnabled: c.env.SPACES_ENABLED === "true",
Expand Down Expand Up @@ -292,7 +294,7 @@ app.get(
// Handle resolution - return our DID for our handle, let others fall through to proxy
app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => {
const handle = c.req.query("handle");
if (handle === c.env.HANDLE) {
if (handle === (await identity.getHandle(c.env, getAccountDO(c.env)))) {
return c.json({ did: c.env.DID });
}
await next();
Expand All @@ -302,7 +304,11 @@ app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => {
app.get(
"/xrpc/com.atproto.identity.getRecommendedDidCredentials",
requireAuth,
identity.getRecommendedDidCredentials,
(c) => identity.getRecommendedDidCredentials(c, getAccountDO(c.env)),
);

app.post("/xrpc/com.atproto.identity.updateHandle", requireAuth, (c) =>
identity.updateHandle(c, getAccountDO(c.env)),
);

// Identity management for outbound migration
Expand Down Expand Up @@ -418,7 +424,8 @@ app.post(
requireAuth,
async (c) => {
const accountDO = getAccountDO(c.env);
const result = await accountDO.repo().emitIdentityEvent(c.env.HANDLE);
const handle = await identity.getHandle(c.env, accountDO);
const result = await accountDO.repo().emitIdentityEvent(handle);
return c.json(result);
},
);
Expand Down Expand Up @@ -496,7 +503,7 @@ app.get("/passkey/register", async (c) => {
renderPasskeyRegistrationPage({
options,
token,
handle: c.env.HANDLE,
handle: await identity.getHandle(c.env, accountDO),
}),
200,
{ "Content-Security-Policy": csp },
Expand Down
7 changes: 4 additions & 3 deletions packages/pds/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { compare } from "bcryptjs";
import type { PDSEnv } from "./types";
import type { AccountDurableObject } from "./account-do";
import { getHandle } from "./xrpc/identity";
import {
getAuthenticationOptions,
verifyPasskeyAuthentication,
Expand Down Expand Up @@ -141,7 +142,7 @@ export function getProvider(env: PDSEnv): ATProtoOAuthProvider {
if (!valid) return null;
return {
sub: env.DID,
handle: env.HANDLE,
handle: await getHandle(env, accountDO),
};
},
// Passkey authentication options
Expand All @@ -163,7 +164,7 @@ export function getProvider(env: PDSEnv): ATProtoOAuthProvider {
if (!result.success) return null;
return {
sub: env.DID,
handle: env.HANDLE,
handle: await getHandle(env, accountDO),
};
},
// DO-SQLite-cached permission-set resolver for `include:` scopes.
Expand Down Expand Up @@ -298,7 +299,7 @@ export function createOAuthApp(
// sub is required, we also include preferred_username (handle)
return c.json({
sub: tokenData.sub,
preferred_username: c.env.HANDLE,
preferred_username: await getHandle(c.env, getAccountDO(c.env)),
});
});

Expand Down
Loading