Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: did resolution

Hugo 6c05edb4 035f09ea

+127 -70
+9
bun.lock
··· 5 5 "": { 6 6 "name": "squall", 7 7 "dependencies": { 8 + "@atproto/identity": "^0.4.12", 8 9 "@atproto/oauth-client-node": "^0.3.17", 9 10 "@vanilla-extract/css": "^1.20.1", 10 11 "@vanilla-extract/sprinkles": "^1.6.5", ··· 51 52 52 53 "@atproto/common-web": ["@atproto/common-web@0.4.19", "", { "dependencies": { "@atproto/lex-data": "^0.0.14", "@atproto/lex-json": "^0.0.14", "@atproto/syntax": "^0.5.1", "zod": "^3.23.8" } }, "sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw=="], 53 54 55 + "@atproto/crypto": ["@atproto/crypto@0.4.5", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw=="], 56 + 54 57 "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 58 + 59 + "@atproto/identity": ["@atproto/identity@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.17", "@atproto/crypto": "^0.4.5" } }, "sha512-P+Jn0HvKhIh1tps5n3xGrCxt+XiFWzp4kdgloyFhFmVLwjDU547DQkWx4r5Vhuiah7fRZGVSlk39R4U6SPrACg=="], 55 60 56 61 "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 57 62 ··· 188 193 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 189 194 190 195 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 196 + 197 + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], 198 + 199 + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 191 200 192 201 "@oxc-project/runtime": ["@oxc-project/runtime@0.123.0", "", {}, "sha512-wRf0z8saz9tHLcK3YeTeBmwISrpy4bBimvKxUmryiIhbt+ZJb0nwwJNL3D8xpeWbNfZlGSlzRBZbfcbApIGZJw=="], 193 202
+3 -14
lib/auth/client.ts
··· 10 10 const { JoseKey, NodeOAuthClient, requestLocalLock } = 11 11 require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node"); 12 12 import { config } from "../config.js"; 13 + import { resolveDidToHandle as pdsResolveDidToHandle } from "../pds/resolver.js"; 13 14 import { sessionStore, stateStore } from "./storage.js"; 14 15 15 16 const KEY_PATH = resolve("./data/oauth-key.json"); ··· 86 87 87 88 /** 88 89 * Resolve a DID to a handle. In local dev, queries the local PDS. 89 - * In production, queries the PLC directory. 90 + * In production, uses @atproto/identity (handles did:plc + did:web). 90 91 */ 91 92 export async function resolveDidToHandle(did: string): Promise<string> { 92 93 if (!did.startsWith("did:")) return did; ··· 105 106 } 106 107 } 107 108 108 - // Production: resolve via PLC directory 109 - try { 110 - const res = await fetch(`https://plc.directory/${did}`); 111 - if (res.ok) { 112 - const data = (await res.json()) as { alsoKnownAs?: string[] }; 113 - const atUri = data.alsoKnownAs?.find((u) => u.startsWith("at://")); 114 - if (atUri) return atUri.replace("at://", ""); 115 - } 116 - } catch { 117 - // fall through 118 - } 119 - 120 - return did; 109 + return pdsResolveDidToHandle(did); 121 110 } 122 111 123 112 /**
+90 -47
lib/pds/resolver.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 - import { fetchRecord, resolveDid } from "./resolver.js"; 2 + import { fetchRecord, resolveDid, resolveDidToHandle, didResolver } from "./resolver.js"; 3 3 4 4 describe("resolveDid", () => { 5 - const mockFetch = vi.fn(); 6 - 7 5 beforeEach(() => { 8 - vi.stubGlobal("fetch", mockFetch); 6 + vi.spyOn(didResolver, "resolve"); 9 7 }); 10 8 11 9 afterEach(() => { 12 - vi.unstubAllGlobals(); 10 + vi.restoreAllMocks(); 13 11 }); 14 12 15 13 it("returns PDS endpoint from DID document", async () => { 16 - mockFetch.mockResolvedValueOnce({ 17 - ok: true, 18 - json: async () => ({ 19 - service: [{ id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }], 20 - }), 14 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 15 + id: "did:plc:abc", 16 + service: [ 17 + { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }, 18 + ], 21 19 }); 22 20 23 21 const result = await resolveDid("did:plc:abc"); 24 22 expect(result).toBe("https://pds.example.com"); 25 23 }); 26 24 27 - it("returns null when plc.directory is unreachable", async () => { 28 - mockFetch.mockRejectedValueOnce(new Error("network error")); 25 + it("resolves did:web DIDs", async () => { 26 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 27 + id: "did:web:example.com", 28 + service: [ 29 + { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }, 30 + ], 31 + }); 32 + 33 + const result = await resolveDid("did:web:example.com"); 34 + expect(result).toBe("https://pds.example.com"); 35 + expect(didResolver.resolve).toHaveBeenCalledWith("did:web:example.com"); 36 + }); 37 + 38 + it("returns null when resolution fails", async () => { 39 + vi.mocked(didResolver.resolve).mockRejectedValueOnce(new Error("network error")); 29 40 30 41 const result = await resolveDid("did:plc:abc"); 31 42 expect(result).toBeNull(); 32 43 }); 33 44 34 - it("returns null when plc.directory returns non-200", async () => { 35 - mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 45 + it("returns null when DID not found", async () => { 46 + vi.mocked(didResolver.resolve).mockResolvedValueOnce(null); 36 47 37 48 const result = await resolveDid("did:plc:abc"); 38 49 expect(result).toBeNull(); 39 50 }); 40 51 41 52 it("returns null when no #atproto_pds service in document", async () => { 42 - mockFetch.mockResolvedValueOnce({ 43 - ok: true, 44 - json: async () => ({ 45 - service: [{ id: "#other", serviceEndpoint: "https://other.com" }], 46 - }), 53 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 54 + id: "did:plc:abc", 55 + service: [{ id: "#other", type: "Other", serviceEndpoint: "https://other.com" }], 47 56 }); 48 57 49 58 const result = await resolveDid("did:plc:abc"); ··· 51 60 }); 52 61 }); 53 62 63 + describe("resolveDidToHandle", () => { 64 + beforeEach(() => { 65 + vi.spyOn(didResolver, "resolve"); 66 + }); 67 + 68 + afterEach(() => { 69 + vi.restoreAllMocks(); 70 + }); 71 + 72 + it("returns handle from DID document", async () => { 73 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 74 + id: "did:plc:abc", 75 + alsoKnownAs: ["at://alice.bsky.social"], 76 + }); 77 + 78 + const result = await resolveDidToHandle("did:plc:abc"); 79 + expect(result).toBe("alice.bsky.social"); 80 + }); 81 + 82 + it("returns DID when resolution fails", async () => { 83 + vi.mocked(didResolver.resolve).mockRejectedValueOnce(new Error("network error")); 84 + 85 + const result = await resolveDidToHandle("did:plc:abc"); 86 + expect(result).toBe("did:plc:abc"); 87 + }); 88 + 89 + it("returns DID when no handle found", async () => { 90 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 91 + id: "did:plc:abc", 92 + }); 93 + 94 + const result = await resolveDidToHandle("did:plc:abc"); 95 + expect(result).toBe("did:plc:abc"); 96 + }); 97 + }); 98 + 54 99 describe("fetchRecord", () => { 55 100 const mockFetch = vi.fn(); 56 101 57 102 beforeEach(() => { 103 + vi.spyOn(didResolver, "resolve"); 58 104 vi.stubGlobal("fetch", mockFetch); 59 105 }); 60 106 61 107 afterEach(() => { 108 + vi.restoreAllMocks(); 62 109 vi.unstubAllGlobals(); 63 110 }); 64 111 65 112 it("fetches record via DID resolution + PDS getRecord", async () => { 66 - // First call: DID resolution 67 - mockFetch.mockResolvedValueOnce({ 68 - ok: true, 69 - json: async () => ({ 70 - service: [{ id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }], 71 - }), 113 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 114 + id: "did:plc:abc", 115 + service: [ 116 + { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }, 117 + ], 72 118 }); 73 - // Second call: getRecord 74 119 mockFetch.mockResolvedValueOnce({ 75 120 ok: true, 76 121 json: async () => ({ ··· 88 133 }); 89 134 90 135 // Verify getRecord URL 91 - const getRecordCall = mockFetch.mock.calls[1]!; 136 + const getRecordCall = mockFetch.mock.calls[0]!; 92 137 const url = new URL(getRecordCall[0] as string); 93 138 expect(url.origin).toBe("https://pds.example.com"); 94 139 expect(url.pathname).toBe("/xrpc/com.atproto.repo.getRecord"); ··· 102 147 }); 103 148 104 149 it("throws when DID cannot be resolved", async () => { 105 - mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 150 + vi.mocked(didResolver.resolve).mockResolvedValueOnce(null); 106 151 107 152 await expect(fetchRecord("at://did:plc:unknown/col/rk")).rejects.toThrow( 108 153 "Could not resolve PDS", ··· 110 155 }); 111 156 112 157 it("throws when PDS getRecord fails", async () => { 113 - mockFetch 114 - .mockResolvedValueOnce({ 115 - ok: true, 116 - json: async () => ({ 117 - service: [{ id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }], 118 - }), 119 - }) 120 - .mockResolvedValueOnce({ ok: false, status: 404 }); 158 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 159 + id: "did:plc:abc", 160 + service: [ 161 + { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }, 162 + ], 163 + }); 164 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 121 165 122 166 await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (404)"); 123 167 }); 124 168 125 169 it("returns defaults for missing cid/value in response", async () => { 126 - mockFetch 127 - .mockResolvedValueOnce({ 128 - ok: true, 129 - json: async () => ({ 130 - service: [{ id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }], 131 - }), 132 - }) 133 - .mockResolvedValueOnce({ 134 - ok: true, 135 - json: async () => ({}), 136 - }); 170 + vi.mocked(didResolver.resolve).mockResolvedValueOnce({ 171 + id: "did:plc:abc", 172 + service: [ 173 + { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }, 174 + ], 175 + }); 176 + mockFetch.mockResolvedValueOnce({ 177 + ok: true, 178 + json: async () => ({}), 179 + }); 137 180 138 181 const result = await fetchRecord("at://did:plc:abc/col/rk"); 139 182 expect(result.uri).toBe("at://did:plc:abc/col/rk");
+24 -9
lib/pds/resolver.ts
··· 1 + import { DidResolver, MemoryCache, getPds, getHandle } from "@atproto/identity"; 2 + 3 + const STALE_TTL = 60_000 * 60; // 1 hour 4 + const MAX_TTL = 60_000 * 60 * 24; // 24 hours 5 + 6 + export const didResolver = new DidResolver({ 7 + timeout: 10_000, 8 + didCache: new MemoryCache(STALE_TTL, MAX_TTL), 9 + }); 10 + 1 11 export type FetchedRecord = { 2 12 uri: string; 3 13 cid: string; ··· 14 24 return { did: match[1]!, collection: match[2]!, rkey: match[3]! }; 15 25 } 16 26 17 - /** Resolve a DID to its PDS endpoint URL via plc.directory. */ 27 + /** Resolve a DID to its PDS endpoint URL. Handles did:plc and did:web. */ 18 28 export async function resolveDid(did: string): Promise<string | null> { 19 29 try { 20 - const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { 21 - signal: AbortSignal.timeout(10_000), 22 - }); 23 - if (!res.ok) return null; 24 - const doc = (await res.json()) as { 25 - service?: Array<{ id: string; serviceEndpoint: string }>; 26 - }; 27 - return doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 30 + const doc = await didResolver.resolve(did); 31 + return doc ? (getPds(doc) ?? null) : null; 28 32 } catch { 29 33 return null; 34 + } 35 + } 36 + 37 + /** Resolve a DID to its handle, or return the DID if no handle is found. */ 38 + export async function resolveDidToHandle(did: string): Promise<string> { 39 + if (!did.startsWith("did:")) return did; 40 + try { 41 + const doc = await didResolver.resolve(did); 42 + return (doc && getHandle(doc)) || did; 43 + } catch { 44 + return did; 30 45 } 31 46 } 32 47
+1
package.json
··· 10 10 "db:migrate": "bun run lib/db/migrate.ts" 11 11 }, 12 12 "dependencies": { 13 + "@atproto/identity": "^0.4.12", 13 14 "@atproto/oauth-client-node": "^0.3.17", 14 15 "@vanilla-extract/css": "^1.20.1", 15 16 "@vanilla-extract/sprinkles": "^1.6.5",