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: lexicon resolver

Hugo cc13b499 b999c498

+232
+33
app/routes/api/lexicons/[nsid].ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { getCached, setCache } from "@/lexicons/cache.js"; 3 + import { isValidNsid, isNsidAllowed, resolve } from "@/lexicons/resolver.js"; 4 + import { config } from "@/config.js"; 5 + 6 + export const GET = createRoute(async (c) => { 7 + const nsid = c.req.param("nsid")!; 8 + 9 + if (!isValidNsid(nsid)) { 10 + return c.json({ error: "Invalid NSID format" }, 400); 11 + } 12 + if (!isNsidAllowed(nsid, config.nsidAllowlist, config.nsidBlocklist)) { 13 + return c.json({ error: "This NSID is not allowed on this instance" }, 403); 14 + } 15 + 16 + let schema = await getCached(nsid); 17 + if (!schema) { 18 + schema = await resolve(nsid); 19 + if (!schema) { 20 + return c.json({ error: `Could not resolve lexicon: ${nsid}` }, 404); 21 + } 22 + await setCache(schema); 23 + } 24 + 25 + return c.json({ 26 + nsid: schema.nsid, 27 + description: schema.description, 28 + fields: [ 29 + { path: "repo", type: "string", description: "DID of the repo that emitted the event" }, 30 + ...schema.fields, 31 + ], 32 + }); 33 + });
+32
lib/lexicons/cache.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { db } from "../db/index.js"; 3 + import { lexiconCache } from "../db/schema.js"; 4 + import { parseLexicon, type LexiconSchema } from "./resolver.js"; 5 + 6 + const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 7 + 8 + export async function getCached(nsid: string): Promise<LexiconSchema | null> { 9 + const row = await db.query.lexiconCache.findFirst({ 10 + where: eq(lexiconCache.nsid, nsid), 11 + }); 12 + if (!row) return null; 13 + if (Date.now() - row.fetchedAt.getTime() > TTL_MS) return null; 14 + 15 + try { 16 + return parseLexicon(nsid, JSON.parse(row.schema)); 17 + } catch { 18 + return null; 19 + } 20 + } 21 + 22 + export async function setCache(schema: LexiconSchema): Promise<void> { 23 + const json = JSON.stringify(schema.raw); 24 + const now = new Date(); 25 + await db 26 + .insert(lexiconCache) 27 + .values({ nsid: schema.nsid, schema: json, fetchedAt: now }) 28 + .onConflictDoUpdate({ 29 + target: lexiconCache.nsid, 30 + set: { schema: json, fetchedAt: now }, 31 + }); 32 + }
+167
lib/lexicons/resolver.ts
··· 1 + import { readFileSync, existsSync } from "node:fs"; 2 + import { resolve as resolvePath } from "node:path"; 3 + 4 + export type LexiconField = { 5 + path: string; 6 + type: string; 7 + description?: string; 8 + }; 9 + 10 + export type LexiconSchema = { 11 + nsid: string; 12 + description?: string; 13 + fields: LexiconField[]; 14 + raw: Record<string, unknown>; 15 + }; 16 + 17 + const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,}$/; 18 + 19 + export function isValidNsid(nsid: string): boolean { 20 + return NSID_RE.test(nsid); 21 + } 22 + 23 + /** 24 + * Derive the authority domain from an NSID. 25 + * e.g. "sh.tangled.feed.star" -> "tangled.sh" 26 + */ 27 + export function nsidToAuthority(nsid: string): string { 28 + const parts = nsid.split("."); 29 + return `${parts[1]}.${parts[0]}`; 30 + } 31 + 32 + /** 33 + * Check whether an NSID is allowed by the instance's allowlist/blocklist. 34 + * Blocklist takes precedence. Supports glob patterns like "app.bsky.*". 35 + */ 36 + export function isNsidAllowed( 37 + nsid: string, 38 + allowlist: string[], 39 + blocklist: string[], 40 + ): boolean { 41 + const matches = (pattern: string) => 42 + pattern.endsWith(".*") 43 + ? nsid.startsWith(pattern.slice(0, -1)) 44 + : nsid === pattern; 45 + 46 + if (blocklist.some(matches)) return false; 47 + if (allowlist.length > 0) return allowlist.some(matches); 48 + return true; 49 + } 50 + 51 + /** 52 + * Extract filterable fields from a lexicon record's properties. 53 + * Only includes primitive types suitable for equality conditions. 54 + */ 55 + function extractFields( 56 + properties: Record<string, any>, 57 + defs: Record<string, any>, 58 + prefix: string, 59 + ): LexiconField[] { 60 + const fields: LexiconField[] = []; 61 + 62 + for (const [name, prop] of Object.entries(properties)) { 63 + const path = `${prefix}.${name}`; 64 + 65 + switch (prop.type) { 66 + case "string": 67 + case "integer": 68 + case "boolean": 69 + fields.push({ path, type: prop.type, description: prop.description }); 70 + break; 71 + 72 + case "object": 73 + if (prop.properties) { 74 + fields.push(...extractFields(prop.properties, defs, path)); 75 + } 76 + break; 77 + 78 + case "ref": { 79 + // Resolve local refs (e.g. "#condition") within the same lexicon 80 + if (typeof prop.ref === "string" && prop.ref.startsWith("#")) { 81 + const def = defs[prop.ref.slice(1)]; 82 + if (def?.type === "object" && def.properties) { 83 + fields.push(...extractFields(def.properties, defs, path)); 84 + } 85 + } 86 + break; 87 + } 88 + } 89 + } 90 + 91 + return fields; 92 + } 93 + 94 + /** 95 + * Parse a lexicon JSON and extract record fields for the condition builder. 96 + */ 97 + export function parseLexicon( 98 + nsid: string, 99 + json: Record<string, unknown>, 100 + ): LexiconSchema { 101 + const defs = json.defs as Record<string, any> | undefined; 102 + if (!defs?.main || defs.main.type !== "record") { 103 + throw new Error(`Lexicon ${nsid} has no record definition`); 104 + } 105 + 106 + const record = defs.main.record; 107 + const fields = record?.properties 108 + ? extractFields(record.properties, defs, "record") 109 + : []; 110 + 111 + return { 112 + nsid, 113 + description: defs.main.description, 114 + fields, 115 + raw: json, 116 + }; 117 + } 118 + 119 + /** Try to resolve a lexicon from the local lexicons/ directory. */ 120 + export function resolveLocal(nsid: string): LexiconSchema | null { 121 + const path = resolvePath("lexicons", ...nsid.split(".")) + ".json"; 122 + if (!existsSync(path)) return null; 123 + try { 124 + return parseLexicon(nsid, JSON.parse(readFileSync(path, "utf-8"))); 125 + } catch { 126 + return null; 127 + } 128 + } 129 + 130 + /** Try to fetch a lexicon from the authority domain. */ 131 + export async function resolveRemote( 132 + nsid: string, 133 + ): Promise<LexiconSchema | null> { 134 + const authority = nsidToAuthority(nsid); 135 + const segments = nsid.split("."); 136 + 137 + const urls = [ 138 + `https://${authority}/lexicons/${segments.join("/")}.json`, 139 + `https://${authority}/.well-known/lexicons/${nsid}.json`, 140 + ]; 141 + 142 + for (const url of urls) { 143 + try { 144 + const res = await fetch(url, { 145 + headers: { Accept: "application/json" }, 146 + signal: AbortSignal.timeout(10_000), 147 + }); 148 + if (!res.ok) continue; 149 + 150 + const json = (await res.json()) as Record<string, unknown>; 151 + if (json.lexicon !== 1 || json.id !== nsid) continue; 152 + 153 + return parseLexicon(nsid, json); 154 + } catch { 155 + continue; 156 + } 157 + } 158 + 159 + return null; 160 + } 161 + 162 + /** Resolve a lexicon by NSID. Tries local first, then remote. */ 163 + export async function resolve( 164 + nsid: string, 165 + ): Promise<LexiconSchema | null> { 166 + return resolveLocal(nsid) ?? resolveRemote(nsid); 167 + }