[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
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}