···11import { sql } from 'kysely'
22import { db } from '@api/db/db'
33-import { keyserverClient } from './keyserverClient'
33+import { createKeyserverClient } from './keyserverClient'
44+import { getKeyserverForUser } from './idResolver'
55+import type { Did } from '@atcute/lexicons'
4657/**
68 * Create a follow relationship between two accounts.
79 * Updates follow_counts for both accounts in a transaction.
810 */
911export async function createFollow(
1010- followerDid: string,
1111- followeeDid: string,
1212+ followerDid: Did,
1313+ followeeDid: Did,
1214 uri: string,
1315 createdAt: number
1416): Promise<void> {
···5557 // After successful database transaction, add follower to keyserver group
5658 // This allows the follower to decrypt the followee's private posts
5759 try {
6060+ // Resolve the followee's keyserver (reuses DID doc already cached for handle)
6161+ const { keyserverDid, keyserverUrl } =
6262+ await getKeyserverForUser(followeeDid)
6363+6464+ // Create client for the followee's keyserver
6565+ const keyserverClient = createKeyserverClient(keyserverDid, keyserverUrl)
6666+5867 await keyserverClient.addMember({
5968 group_id: `${followeeDid}#followers`,
6069 member_did: followerDid
···7281 * Returns the URI of the deleted follow record (for PDS deletion), or null if not found.
7382 */
7483export async function deleteFollow(
7575- followerDid: string,
7676- followeeDid: string
8484+ followerDid: Did,
8585+ followeeDid: Did
7786): Promise<string | null> {
7887 const uri = await db.transaction().execute(async (trx) => {
7988 // Step 1: Query the follow record to get the URI before deleting
···122131 // This revokes the follower's ability to decrypt the followee's new private posts
123132 if (uri) {
124133 try {
134134+ // Resolve the followee's keyserver (reuses DID doc already cached for handle)
135135+ const { keyserverDid, keyserverUrl } =
136136+ await getKeyserverForUser(followeeDid)
137137+138138+ // Create client for the followee's keyserver
139139+ const keyserverClient = createKeyserverClient(keyserverDid, keyserverUrl)
140140+125141 await keyserverClient.removeMember({
126142 group_id: `${followeeDid}#followers`,
127143 member_did: followerDid
+89
packages/server/src/lib/idResolver.ts
···66 WebDidDocumentResolver,
77 WellKnownHandleResolver
88} from '@atcute/identity-resolver'
99+import type { DidDocument } from '@atcute/identity'
1010+import type { Did } from '@atcute/lexicons'
1111+import env from './env'
9121013export const handleResolver = new CompositeHandleResolver({
1114 strategy: 'race',
···2326 web: new WebDidDocumentResolver()
2427 }
2528})
2929+3030+// Types for keyserver resolution
3131+export interface KeyserverInfo {
3232+ keyserverDid: Did
3333+ keyserverUrl: string
3434+}
3535+3636+/**
3737+ * Extract keyserver service from DID document
3838+ * Looks for service with type "AtprotoKeyserver"
3939+ */
4040+function extractKeyserverFromDidDoc(didDoc: DidDocument, userDid: Did) {
4141+ if (!didDoc.service) {
4242+ return null
4343+ }
4444+4545+ const keyserverService = didDoc.service.find(
4646+ (s) => s.type === 'AtprotoKeyserver'
4747+ )
4848+4949+ if (!keyserverService) {
5050+ return null
5151+ }
5252+5353+ const keyserverUrl = keyserverService.serviceEndpoint
5454+5555+ if (typeof keyserverUrl !== 'string') {
5656+ console.warn(`Invalid keyserver URL in DID document for ${userDid}`)
5757+ return null
5858+ }
5959+6060+ // Extract keyserver DID from service ID
6161+ // Service ID format: "did:web:keyserver.example.com#atproto_keyserver"
6262+ const keyserverDid = keyserverService.id.split('#')[0] as Did
6363+6464+ return {
6565+ keyserverDid,
6666+ keyserverUrl
6767+ }
6868+}
6969+7070+/**
7171+ * Get keyserver info for a user with fallback
7272+ * Reuses DID document resolution that's already cached by didDocResolver
7373+ */
7474+export async function getKeyserverForUser(userDid: Did) {
7575+ // Resolve DID document (this is already cached by didDocResolver)
7676+ try {
7777+ const didDoc = await didDocResolver.resolve(
7878+ userDid as Did<'web'> | Did<'plc'>
7979+ )
8080+ const info = extractKeyserverFromDidDoc(didDoc, userDid)
8181+8282+ if (info) {
8383+ return info
8484+ }
8585+ } catch (error) {
8686+ console.warn(
8787+ `Failed to resolve DID for keyserver lookup ${userDid}: ${error instanceof Error ? error.message : 'Unknown error'}`
8888+ )
8989+ }
9090+9191+ // Fallback to default keyserver from environment
9292+ if (env.KEYSERVER_DID && env.KEYSERVER_URL) {
9393+ return {
9494+ keyserverDid: env.KEYSERVER_DID,
9595+ keyserverUrl: env.KEYSERVER_URL
9696+ }
9797+ }
9898+9999+ throw new Error(
100100+ `No keyserver found in DID document for ${userDid} and no fallback configured`
101101+ )
102102+}
103103+104104+/**
105105+ * Get keyserver for a group (group owner's keyserver)
106106+ * Group ID format: "did:plc:abc123#followers"
107107+ */
108108+export async function getKeyserverForGroup(groupId: string) {
109109+ const [ownerDid] = groupId.split('#')
110110+ if (!ownerDid) {
111111+ throw new Error(`Invalid group ID format: ${groupId}`)
112112+ }
113113+ return getKeyserverForUser(ownerDid as Did)
114114+}
+36-9
packages/server/src/lib/keyserverClient.ts
···44import type { Did, Nsid } from '@atcute/lexicons'
5566/**
77- * Keyserver client configured to authenticate as the XRPC server itself.
88- * This allows the server to manage group membership on behalf of users who
99- * have authorized delegation.
77+ * Create a keyserver client for a specific keyserver
88+ * Authenticates as the XRPC server to manage group membership on behalf of users
99+ * who have authorized delegation.
1010+ *
1111+ * @param keyserverDid - DID of the keyserver (optional, uses env default if not provided)
1212+ * @param keyserverUrl - URL of the keyserver (optional, derived from DID if not provided)
1013 */
1111-export const keyserverClient = new KeyserverClient({
1212- keyserverDid: env.KEYSERVER_DID,
1313- keyserverUrl: env.KEYSERVER_URL,
1414- getServiceAuthToken: async (aud, lxm) => {
1515- return await generateServiceAuthToken(aud as Did, lxm as Nsid)
1414+export function createKeyserverClient(
1515+ keyserverDid?: Did,
1616+ keyserverUrl?: string
1717+): KeyserverClient {
1818+ // Use provided keyserver or fall back to env default
1919+ const finalKeyserverDid = keyserverDid || env.KEYSERVER_DID
2020+ let finalKeyserverUrl = keyserverUrl || env.KEYSERVER_URL
2121+2222+ // Derive URL from DID if not provided
2323+ if (!finalKeyserverUrl && finalKeyserverDid) {
2424+ if (finalKeyserverDid.startsWith('did:web:')) {
2525+ const keyserverHost = decodeURIComponent(
2626+ finalKeyserverDid.replace('did:web:', '')
2727+ )
2828+ const prefix = keyserverHost.startsWith('localhost') ? 'http://' : 'https://'
2929+ finalKeyserverUrl = `${prefix}${keyserverHost}`
3030+ }
1631 }
1717-})
3232+3333+ if (!finalKeyserverDid || !finalKeyserverUrl) {
3434+ throw new Error('Keyserver DID and URL must be provided or configured in environment')
3535+ }
3636+3737+ return new KeyserverClient({
3838+ keyserverDid: finalKeyserverDid,
3939+ keyserverUrl: finalKeyserverUrl,
4040+ getServiceAuthToken: async (aud, lxm) => {
4141+ return await generateServiceAuthToken(aud as Did, lxm as Nsid)
4242+ }
4343+ })
4444+}