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.

feat: enable nested object in placeholders

Hugo b50af995 6bf7aaaf

+170 -25
+1 -1
lib/lexicons/cache.ts
··· 13 13 if (Date.now() - row.fetchedAt.getTime() > TTL_MS) return null; 14 14 15 15 try { 16 - return parseLexicon(nsid, JSON.parse(row.schema)); 16 + return await parseLexicon(nsid, JSON.parse(row.schema)); 17 17 } catch { 18 18 return null; 19 19 }
+98 -9
lib/lexicons/resolver.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 1 + import { describe, it, expect, vi } from "vitest"; 2 + 3 + vi.mock("./cache.js", () => ({ 4 + getCachedRaw: vi.fn().mockResolvedValue(null), 5 + setCacheRaw: vi.fn().mockResolvedValue(undefined), 6 + })); 7 + 2 8 import { 3 9 isValidNsid, 4 10 nsidToAuthority, ··· 98 104 }); 99 105 100 106 describe("parseLexicon", () => { 101 - it("extracts primitive fields from record properties", () => { 107 + it("extracts primitive fields from record properties", async () => { 102 108 const json = { 103 109 lexicon: 1, 104 110 id: "app.test.record", ··· 115 121 }, 116 122 }, 117 123 }; 118 - const result = parseLexicon("app.test.record", json); 124 + const result = await parseLexicon("app.test.record", json); 119 125 expect(result.nsid).toBe("app.test.record"); 120 126 expect(result.fields).toEqual([ 121 127 { path: "name", type: "string", description: "The name" }, ··· 124 130 ]); 125 131 }); 126 132 127 - it("extracts nested object fields with dotted paths", () => { 133 + it("extracts nested object fields with dotted paths", async () => { 128 134 const json = { 129 135 lexicon: 1, 130 136 id: "app.test.nested", ··· 145 151 }, 146 152 }, 147 153 }; 148 - const result = parseLexicon("app.test.nested", json); 154 + const result = await parseLexicon("app.test.nested", json); 149 155 expect(result.fields).toEqual([ 150 156 { path: "subject.uri", type: "string", description: undefined }, 151 157 { path: "subject.cid", type: "string", description: undefined }, 152 158 ]); 153 159 }); 154 160 155 - it("resolves local #ref properties", () => { 161 + it("resolves local #ref properties", async () => { 156 162 const json = { 157 163 lexicon: 1, 158 164 id: "app.test.ref", ··· 174 180 }, 175 181 }, 176 182 }; 177 - const result = parseLexicon("app.test.ref", json); 183 + const result = await parseLexicon("app.test.ref", json); 178 184 expect(result.fields).toEqual([ 179 185 { path: "condition.field", type: "string", description: undefined }, 180 186 { path: "condition.value", type: "string", description: undefined }, 181 187 ]); 182 188 }); 183 189 184 - it("throws for lexicon without record definition", () => { 190 + it("resolves external ref properties", async () => { 191 + const { getCachedRaw } = await import("./cache.js"); 192 + vi.mocked(getCachedRaw).mockResolvedValueOnce({ 193 + lexicon: 1, 194 + id: "com.atproto.repo.strongRef", 195 + defs: { 196 + main: { 197 + type: "object", 198 + properties: { 199 + uri: { type: "string", description: "AT URI" }, 200 + cid: { type: "string", description: "CID" }, 201 + }, 202 + }, 203 + }, 204 + }); 205 + 206 + const json = { 207 + lexicon: 1, 208 + id: "app.test.externalRef", 209 + defs: { 210 + main: { 211 + type: "record", 212 + record: { 213 + properties: { 214 + subject: { type: "ref", ref: "com.atproto.repo.strongRef" }, 215 + createdAt: { type: "string" }, 216 + }, 217 + }, 218 + }, 219 + }, 220 + }; 221 + const result = await parseLexicon("app.test.externalRef", json); 222 + expect(result.fields).toEqual([ 223 + { path: "subject.uri", type: "string", description: "AT URI" }, 224 + { path: "subject.cid", type: "string", description: "CID" }, 225 + { path: "createdAt", type: "string", description: undefined }, 226 + ]); 227 + }); 228 + 229 + it("resolves sibling refs to the same external lexicon (e.g. like.subject + like.via)", async () => { 230 + const { getCachedRaw } = await import("./cache.js"); 231 + const strongRef = { 232 + lexicon: 1, 233 + id: "com.atproto.repo.strongRef", 234 + defs: { 235 + main: { 236 + type: "object", 237 + properties: { 238 + uri: { type: "string" }, 239 + cid: { type: "string" }, 240 + }, 241 + }, 242 + }, 243 + }; 244 + // Both siblings will trigger a lookup; mock returns the same lexicon for each. 245 + vi.mocked(getCachedRaw).mockResolvedValueOnce(strongRef).mockResolvedValueOnce(strongRef); 246 + 247 + const json = { 248 + lexicon: 1, 249 + id: "app.bsky.feed.like", 250 + defs: { 251 + main: { 252 + type: "record", 253 + record: { 254 + properties: { 255 + via: { type: "ref", ref: "com.atproto.repo.strongRef" }, 256 + subject: { type: "ref", ref: "com.atproto.repo.strongRef" }, 257 + createdAt: { type: "string" }, 258 + }, 259 + }, 260 + }, 261 + }, 262 + }; 263 + const result = await parseLexicon("app.bsky.feed.like", json); 264 + expect(result.fields).toEqual([ 265 + { path: "via.uri", type: "string", description: undefined }, 266 + { path: "via.cid", type: "string", description: undefined }, 267 + { path: "subject.uri", type: "string", description: undefined }, 268 + { path: "subject.cid", type: "string", description: undefined }, 269 + { path: "createdAt", type: "string", description: undefined }, 270 + ]); 271 + }); 272 + 273 + it("throws for lexicon without record definition", async () => { 185 274 const json = { 186 275 lexicon: 1, 187 276 id: "app.test.token", ··· 189 278 main: { type: "token" }, 190 279 }, 191 280 }; 192 - expect(() => parseLexicon("app.test.token", json)).toThrow("no record definition"); 281 + await expect(parseLexicon("app.test.token", json)).rejects.toThrow("no record definition"); 193 282 }); 194 283 });
+67 -14
lib/lexicons/resolver.ts
··· 2 2 import { resolve as resolvePath } from "node:path"; 3 3 import { resolveTxt } from "node:dns/promises"; 4 4 import { resolveDid } from "../pds/resolver.js"; 5 + import { getCachedRaw, setCacheRaw } from "./cache.js"; 5 6 6 7 export type LexiconField = { 7 8 path: string; ··· 44 45 45 46 export { isNsidAllowed, nsidRequiresWantedDids } from "./match.js"; 46 47 48 + const MAX_DEPTH = 8; 49 + 50 + function parseRef(ref: string): { nsid: string; defName: string } { 51 + const hashIndex = ref.indexOf("#"); 52 + if (hashIndex === -1) return { nsid: ref, defName: "main" }; 53 + return { nsid: ref.slice(0, hashIndex), defName: ref.slice(hashIndex + 1) }; 54 + } 55 + 56 + async function fetchExternalDefs(nsid: string): Promise<Record<string, any> | null> { 57 + const cached = await getCachedRaw(nsid); 58 + if (cached) return (cached.defs as Record<string, any>) ?? null; 59 + 60 + const raw = await resolveRaw(nsid); 61 + if (!raw) return null; 62 + await setCacheRaw(nsid, raw).catch(() => {}); 63 + return (raw.defs as Record<string, any>) ?? null; 64 + } 65 + 47 66 /** 48 67 * Extract filterable fields from a lexicon record's properties. 49 68 * Only includes primitive types suitable for equality conditions. 50 69 */ 51 - function extractFields( 70 + async function extractFields( 52 71 properties: Record<string, any>, 53 72 defs: Record<string, any>, 54 73 prefix: string, 55 - ): LexiconField[] { 74 + visited: Set<string>, 75 + depth: number, 76 + ): Promise<LexiconField[]> { 56 77 const fields: LexiconField[] = []; 57 78 58 79 for (const [name, prop] of Object.entries(properties)) { ··· 67 88 68 89 case "object": 69 90 if (prop.properties) { 70 - fields.push(...extractFields(prop.properties, defs, path)); 91 + fields.push(...(await extractFields(prop.properties, defs, path, visited, depth + 1))); 71 92 } 72 93 break; 73 94 74 95 case "ref": { 75 - // Resolve local refs (e.g. "#condition") within the same lexicon 76 - if (typeof prop.ref === "string" && prop.ref.startsWith("#")) { 77 - const def = defs[prop.ref.slice(1)]; 96 + if (depth >= MAX_DEPTH) break; 97 + const ref = prop.ref as string | undefined; 98 + if (!ref || visited.has(ref)) break; 99 + // Per-branch copy so sibling properties referencing the same lexicon 100 + // (e.g. both `subject` and `via` pointing to com.atproto.repo.strongRef) 101 + // each get a clean view. Cycles within a single path are still caught. 102 + const nextVisited = new Set(visited); 103 + nextVisited.add(ref); 104 + 105 + if (ref.startsWith("#")) { 106 + const def = defs[ref.slice(1)]; 78 107 if (def?.type === "object" && def.properties) { 79 - fields.push(...extractFields(def.properties, defs, path)); 108 + fields.push( 109 + ...(await extractFields(def.properties, defs, path, nextVisited, depth + 1)), 110 + ); 111 + } 112 + } else { 113 + const { nsid, defName } = parseRef(ref); 114 + const externalDefs = await fetchExternalDefs(nsid); 115 + if (externalDefs) { 116 + const def = externalDefs[defName]; 117 + if (def?.type === "object" && def.properties) { 118 + fields.push( 119 + ...(await extractFields( 120 + def.properties, 121 + externalDefs, 122 + path, 123 + nextVisited, 124 + depth + 1, 125 + )), 126 + ); 127 + } 80 128 } 81 129 } 82 130 break; ··· 90 138 /** 91 139 * Parse a lexicon JSON and extract record fields for the condition builder. 92 140 */ 93 - export function parseLexicon(nsid: string, json: Record<string, unknown>): LexiconSchema { 141 + export async function parseLexicon( 142 + nsid: string, 143 + json: Record<string, unknown>, 144 + ): Promise<LexiconSchema> { 94 145 const defs = json.defs as Record<string, any> | undefined; 95 146 if (!defs?.main || defs.main.type !== "record") { 96 147 throw new Error(`Lexicon ${nsid} has no record definition`); 97 148 } 98 149 99 150 const record = defs.main.record; 100 - const fields = record?.properties ? extractFields(record.properties, defs, "") : []; 151 + const fields = record?.properties 152 + ? await extractFields(record.properties, defs, "", new Set<string>(), 0) 153 + : []; 101 154 102 155 return { 103 156 nsid, ··· 215 268 // --------------------------------------------------------------------------- 216 269 217 270 /** Try to resolve a lexicon from the local lexicons/ directory. */ 218 - export function resolveLocal(nsid: string): LexiconSchema | null { 271 + export async function resolveLocal(nsid: string): Promise<LexiconSchema | null> { 219 272 const raw = fetchLocalRaw(nsid); 220 273 if (!raw) return null; 221 274 try { 222 - return parseLexicon(nsid, raw); 275 + return await parseLexicon(nsid, raw); 223 276 } catch { 224 277 return null; 225 278 } ··· 230 283 const raw = await fetchRemoteRaw(nsid); 231 284 if (!raw) return null; 232 285 try { 233 - return parseLexicon(nsid, raw); 286 + return await parseLexicon(nsid, raw); 234 287 } catch { 235 288 return null; 236 289 } ··· 248 301 const raw = await fetchViaAtprotoRaw(nsid); 249 302 if (!raw) return null; 250 303 try { 251 - return parseLexicon(nsid, raw); 304 + return await parseLexicon(nsid, raw); 252 305 } catch { 253 306 return null; 254 307 } ··· 259 312 * Tries: local files → AT Protocol DNS resolution → HTTP authority domain. 260 313 */ 261 314 export async function resolve(nsid: string): Promise<LexiconSchema | null> { 262 - const local = resolveLocal(nsid); 315 + const local = await resolveLocal(nsid); 263 316 if (local) return local; 264 317 265 318 const atproto = await resolveViaAtproto(nsid);
+4 -1
lib/lexicons/schema-tree.ts
··· 39 39 // --------------------------------------------------------------------------- 40 40 41 41 async function fetchExternalDefs(nsid: string): Promise<Record<string, any> | null> { 42 - const raw = (await getCachedRaw(nsid)) ?? (await resolveRaw(nsid)); 42 + const cached = await getCachedRaw(nsid); 43 + if (cached) return (cached.defs as Record<string, any>) ?? null; 44 + 45 + const raw = await resolveRaw(nsid); 43 46 if (!raw) return null; 44 47 45 48 // Cache on successful fetch