[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
0

Configure Feed

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

n8n-nodes-atproto / src / nodes / Atproto / lexicon.ts
14 kB 418 lines
1/** 2 * Lexicon resolution for AT Protocol record schemas. 3 * 4 * Resolves a lexicon schema document from an NSID via two paths: 5 * 1. PDS endpoint: `com.atproto.lexicon.resolveLexicon` (requires auth) 6 * 2. Fallback: `@atproto/lexicon-resolver` DNS-based resolution (no auth) 7 * 8 * Results are cached in-memory (module-level Map) since calls originate 9 * from the n8n editor (resourceMapperMethod), not during execution. 10 */ 11 12import type { Agent } from '@atproto/api'; 13 14// --------------------------------------------------------------------------- 15// Types — simplified view of what we extract from lexicon documents 16// --------------------------------------------------------------------------- 17 18/** A single property/field definition in a lexicon record. */ 19export interface LexiconProperty { 20 type: string; 21 format?: string; 22 /** For `ref` types: the target NSID or #local-name. */ 23 ref?: string; 24 /** For `union` types: the list of possible ref targets. */ 25 refs?: string[]; 26 /** For `array` types: the schema of array items. */ 27 items?: LexiconProperty; 28 /** For `object` types: nested properties. */ 29 properties?: Record<string, LexiconProperty>; 30 /** For `object` types: required sub-field names. */ 31 required?: string[]; 32 description?: string; 33 /** Whether the field is nullable (from the `nullable` array). */ 34 nullable?: boolean; 35} 36 37/** Parsed record schema extracted from a lexicon document. */ 38export interface LexiconSchema { 39 /** The record's property definitions (from `defs.main.record.properties`). */ 40 properties: Record<string, LexiconProperty>; 41 /** Names of required fields (from `defs.main.record.required`). */ 42 required: string[]; 43 /** The record key strategy declared by the lexicon. */ 44 key?: 'tid' | 'any' | 'literal'; 45 /** When `key` is `literal`, the literal value (e.g. `self`). */ 46 literalKey?: string; 47 /** The raw lexicon document defs (for local ref resolution like `#someName`). */ 48 rawDefs?: Record<string, unknown>; 49 /** NSID of this lexicon schema. */ 50 nsid: string; 51} 52 53// --------------------------------------------------------------------------- 54// Cache 55// --------------------------------------------------------------------------- 56 57const schemaCache = new Map<string, LexiconSchema>(); 58 59/** Clear the in-memory cache (useful in tests). */ 60export function clearLexiconCache(): void { 61 schemaCache.clear(); 62} 63 64// --------------------------------------------------------------------------- 65// Resolution 66// --------------------------------------------------------------------------- 67 68/** 69 * Resolve a lexicon schema for the given collection NSID. 70 * 71 * Resolution chain: 72 * 1. Check module-level cache. 73 * 2. Try PDS `com.atproto.lexicon.resolveLexicon` endpoint. 74 * 3. If that fails, try `@atproto/lexicon-resolver` (DNS-based, dynamic import). 75 * 4. If both fail, return `null` (triggers JSON fallback in n8n). 76 * 77 * @param agent - Authenticated AT Protocol agent (may be null if unavailable). 78 * @param nsid - The collection NSID to resolve (e.g. `app.bsky.feed.post`). 79 */ 80export async function resolveLexiconSchema( 81 agent: Agent | null, 82 nsid: string, 83): Promise<LexiconSchema | null> { 84 const cached = schemaCache.get(nsid); 85 if (cached) return cached; 86 87 // Try path A: PDS endpoint. 88 // The @atproto/api client's strict XRPC validation rejects `record` types 89 // in the resolveLexicon response schema. We call the endpoint via the 90 // typed client anyway and extract the response body from the error, which 91 // is still populated even when validation fails. 92 if (agent) { 93 try { 94 const response = 95 await agent.com.atproto.lexicon.resolveLexicon({ nsid }); 96 const schema = parseRawLexiconDocument( 97 response.data.schema as Record<string, unknown>, 98 nsid, 99 ); 100 if (schema) { 101 schemaCache.set(nsid, schema); 102 return schema; 103 } 104 } catch (err) { 105 // The client throws XRPCInvalidResponseError when the response 106 // doesn't match the schema. The response body is still available 107 // on the error object. 108 const errObj = err as Record<string, unknown>; 109 const responseBody = errObj.responseBody as 110 | Record<string, unknown> 111 | undefined; 112 if (responseBody?.schema) { 113 const schema = parseRawLexiconDocument( 114 responseBody.schema as Record<string, unknown>, 115 nsid, 116 ); 117 if (schema) { 118 schemaCache.set(nsid, schema); 119 return schema; 120 } 121 } 122 // Fall through to path B 123 } 124 } 125 126 // Try path B: @atproto/lexicon-resolver (DNS-based, no auth required). 127 // Dynamic import to avoid CJS/ESM interop issues at module load time. 128 // eslint-disable-next-line @typescript-eslint/consistent-type-imports 129 try { 130 const mod: { resolveLexicon: (nsid: string) => Promise<unknown> } = 131 // @ts-expect-error Can't resolve package under current moduleResolution 132 await import('@atproto/lexicon-resolver'); 133 const resolution = await mod.resolveLexicon(nsid); 134 const lexicon = (resolution as Record<string, unknown>) 135 .lexicon as Record<string, unknown>; 136 const schema = parseLexiconDocumentRecord(lexicon, nsid); 137 if (schema) { 138 schemaCache.set(nsid, schema); 139 return schema; 140 } 141 } catch { 142 // Both paths failed — return null for JSON fallback 143 } 144 145 return null; 146} 147 148// --------------------------------------------------------------------------- 149// Parsing helpers — exported for direct use in tests 150// --------------------------------------------------------------------------- 151 152/** 153 * Parse a raw lexicon document (without $type or CID validation) into our 154 * internal schema. Useful for tests that construct mock lexicons directly. 155 */ 156export function parseLexiconDoc( 157 doc: Record<string, unknown>, 158 nsid: string, 159): LexiconSchema | null { 160 return parseRawLexiconDocument(doc, nsid); 161} 162 163/** 164 * Parse a raw lexicon document (from PDS endpoint) into our internal schema. 165 */ 166function parseRawLexiconDocument( 167 doc: Record<string, unknown>, 168 nsid: string, 169): LexiconSchema | null { 170 const defs = doc?.defs as Record<string, unknown> | undefined; 171 if (!defs) return null; 172 173 const mainDef = defs?.main as Record<string, unknown> | undefined; 174 if (!mainDef) return null; 175 176 return extractRecordSchema(mainDef, defs, nsid); 177} 178 179/** 180 * Parse a LexiconDocumentRecord (from @atproto/lexicon-resolver) into our 181 * internal schema. The shape is the same as the raw document, just 182 * wrapped in a typed container. 183 */ 184function parseLexiconDocumentRecord( 185 lexicon: Record<string, unknown>, 186 nsid: string, 187): LexiconSchema | null { 188 const defs = lexicon?.defs as Record<string, unknown> | undefined; 189 if (!defs) return null; 190 191 const mainDef = defs?.main as Record<string, unknown> | undefined; 192 if (!mainDef) return null; 193 194 return extractRecordSchema(mainDef, defs, nsid); 195} 196 197/** 198 * Extract the record schema from a `defs.main` definition that has 199 * `type: "record"`. 200 */ 201function extractRecordSchema( 202 mainDef: Record<string, unknown>, 203 defs: Record<string, unknown>, 204 nsid: string, 205): LexiconSchema | null { 206 if (mainDef.type !== 'record') { 207 // Some lexicons have query/procedure as main — not a record schema 208 return null; 209 } 210 211 const record = mainDef.record as Record<string, unknown> | undefined; 212 if (!record || record.type !== 'object') return null; 213 214 const properties = (record.properties as Record<string, unknown>) ?? {}; 215 const required = (record.required as string[]) ?? []; 216 const nullable = (record.nullable as string[]) ?? []; 217 218 // Parse record key strategy 219 const key = mainDef.key as string | undefined; 220 let keyType: 'tid' | 'any' | 'literal' | undefined; 221 let literalKey: string | undefined; 222 223 if (key === 'tid') { 224 keyType = 'tid'; 225 } else if (key === 'any') { 226 keyType = 'any'; 227 } else if (key && key.startsWith('literal:')) { 228 keyType = 'literal'; 229 literalKey = key.slice('literal:'.length); 230 } 231 232 // Convert raw properties to our internal representation 233 const parsedProperties: Record<string, LexiconProperty> = {}; 234 for (const [name, raw] of Object.entries(properties)) { 235 const prop = raw as Record<string, unknown>; 236 parsedProperties[name] = { 237 type: (prop.type as string) ?? 'unknown', 238 format: prop.format as string | undefined, 239 ref: prop.ref as string | undefined, 240 refs: prop.refs as string[] | undefined, 241 items: prop.items 242 ? parseInlineProperty(prop.items as Record<string, unknown>) 243 : undefined, 244 properties: prop.properties 245 ? parseInlineObjectProperties( 246 prop.properties as Record<string, unknown>, 247 ) 248 : undefined, 249 required: prop.required as string[] | undefined, 250 description: prop.description as string | undefined, 251 nullable: nullable.includes(name), 252 }; 253 } 254 255 return { 256 properties: parsedProperties, 257 required, 258 key: keyType, 259 literalKey, 260 rawDefs: defs, 261 nsid, 262 }; 263} 264 265/** 266 * Recursively parse a nested property definition (e.g. for `array` items 267 * or inline `object` fields). 268 * 269 * Handles the AT Protocol shorthand where `type` is inferred from other 270 * fields (e.g. `{ "ref": "..." }` implies `type: "ref"`). 271 */ 272function parseInlineProperty( 273 raw: Record<string, unknown>, 274): LexiconProperty { 275 // Infer type from other fields if not explicitly set 276 let type = (raw.type as string) ?? 'unknown'; 277 if (!raw.type) { 278 if (raw.ref) type = 'ref'; 279 else if (raw.refs) type = 'union'; 280 else if (raw.properties) type = 'object'; 281 else if (raw.items) type = 'array'; 282 } 283 284 return { 285 type, 286 format: raw.format as string | undefined, 287 ref: raw.ref as string | undefined, 288 refs: raw.refs as string[] | undefined, 289 items: raw.items 290 ? parseInlineProperty(raw.items as Record<string, unknown>) 291 : undefined, 292 properties: raw.properties 293 ? parseInlineObjectProperties( 294 raw.properties as Record<string, unknown>, 295 ) 296 : undefined, 297 required: raw.required as string[] | undefined, 298 description: raw.description as string | undefined, 299 }; 300} 301 302/** 303 * Parse the `properties` map of an inline object definition. 304 */ 305function parseInlineObjectProperties( 306 raw: Record<string, unknown>, 307): Record<string, LexiconProperty> { 308 const result: Record<string, LexiconProperty> = {}; 309 for (const [name, value] of Object.entries(raw)) { 310 result[name] = parseInlineProperty(value as Record<string, unknown>); 311 } 312 return result; 313} 314 315// --------------------------------------------------------------------------- 316// Ref resolution 317// --------------------------------------------------------------------------- 318 319/** Resolved ref: properties plus the names of fields required by the 320 * referenced definition. */ 321export interface ResolvedRef { 322 properties: Record<string, LexiconProperty>; 323 required: string[]; 324} 325 326/** 327 * Resolve a local ref (`#someName`) or cross-document ref 328 * (`app.bsky.richtext.facet` or `app.bsky.feed.post#replyRef`) to 329 * its property definitions. 330 * 331 * For local refs, looks in the `rawDefs` of the given schema. 332 * For cross-document refs, attempts to resolve the target NSID. 333 * 334 * @returns The resolved properties + required-field names, or `null` if 335 * resolution fails. 336 */ 337export async function resolveRefProperties( 338 ref: string, 339 currentSchema: LexiconSchema, 340 resolveExternal: (nsid: string) => Promise<LexiconSchema | null>, 341): Promise<ResolvedRef | null> { 342 if (ref.startsWith('#')) { 343 // Local ref — look up in defs 344 const localName = ref.slice(1); 345 return resolveLocalDef(localName, currentSchema.rawDefs); 346 } 347 348 // Cross-document ref — may include a fragment like 349 // `app.bsky.feed.post#replyRef` 350 const hashIdx = ref.indexOf('#'); 351 const targetNsid = hashIdx >= 0 ? ref.slice(0, hashIdx) : ref; 352 const fragment = hashIdx >= 0 ? ref.slice(hashIdx + 1) : undefined; 353 354 const resolved = await resolveExternal(targetNsid); 355 if (!resolved) return null; 356 357 if (fragment) { 358 return resolveLocalDef(fragment, resolved.rawDefs); 359 } 360 361 // No fragment — return the record's top-level properties + required 362 return { 363 properties: resolved.properties, 364 required: resolved.required, 365 }; 366} 367 368/** 369 * Look up a def by name in the raw defs map and extract its properties 370 * along with the names of required fields. 371 */ 372function resolveLocalDef( 373 name: string, 374 rawDefs?: Record<string, unknown>, 375): ResolvedRef | null { 376 if (!rawDefs) return null; 377 378 const def = rawDefs[name] as Record<string, unknown> | undefined; 379 if (!def) return null; 380 381 // Direct object definition 382 if (def.type === 'object') { 383 return extractObjectDef(def); 384 } 385 386 // Token — no properties 387 if (def.type === 'token') { 388 return { properties: {}, required: [] }; 389 } 390 391 // Record — unwrap to get the inner object 392 if (def.type === 'record') { 393 const record = def.record as Record<string, unknown> | undefined; 394 if (!record || record.type !== 'object') return null; 395 return extractObjectDef(record); 396 } 397 398 return null; 399} 400 401/** Extract a `{ properties, required }` pair from an `object`-type def. */ 402function extractObjectDef( 403 objDef: Record<string, unknown>, 404): ResolvedRef | null { 405 const properties = objDef.properties as 406 | Record<string, unknown> 407 | undefined; 408 if (!properties) return null; 409 const required = (objDef.required as string[]) ?? []; 410 const nullable = (objDef.nullable as string[]) ?? []; 411 const result: Record<string, LexiconProperty> = {}; 412 for (const [k, v] of Object.entries(properties)) { 413 const p = parseInlineProperty(v as Record<string, unknown>); 414 p.nullable = nullable.includes(k); 415 result[k] = p; 416 } 417 return { properties: result, required }; 418}