Enable LLMs to handle webhooks with plaintext files
1import * as nodePath from "node:path";
2import * as fs from "node:fs/promises";
3import matter from "gray-matter";
4import { validateFrontmatter, LureParseError } from "./schema.js";
5import type { ParsedLure } from "./types.js";
6import type { StandardSchemaV1 } from "@standard-schema/spec";
7
8export { LureParseError };
9
10export class LureCache {
11 private readonly byLurePath = new Map<string, ParsedLure>();
12 private readonly byFilePath = new Map<string, string>();
13
14 constructor(
15 private readonly luresDir: string,
16 private readonly allowUnverified: boolean,
17 private readonly configSchema: StandardSchemaV1 | undefined,
18 ) {}
19
20 async load(): Promise<void> {
21 const entries = await fs.readdir(this.luresDir, { recursive: true });
22 for (const entry of entries) {
23 if (typeof entry === "string" && entry.endsWith(".lure")) {
24 const filePath = nodePath.join(this.luresDir, entry);
25 try {
26 await this.set(filePath);
27 } catch (error) {
28 console.error(`Failed to load lure ${filePath}:`, error);
29 }
30 }
31 }
32 }
33
34 async set(filePath: string): Promise<void> {
35 const resolved = nodePath.resolve(filePath);
36 const resolvedDir = nodePath.resolve(this.luresDir);
37
38 if (!resolved.startsWith(resolvedDir + nodePath.sep) && resolved !== resolvedDir) {
39 throw new LureParseError(`Path traversal detected: ${filePath}`, filePath);
40 }
41
42 const content = await fs.readFile(filePath, "utf-8");
43 const { data, content: template } = matter(content);
44
45 const frontmatter = await validateFrontmatter(data, filePath, this.configSchema);
46
47 if (!this.allowUnverified && frontmatter.verify === undefined) {
48 throw new LureParseError(
49 `Lure has no verify block but allowUnverified is false: ${filePath}`,
50 filePath,
51 );
52 }
53
54 const relative = nodePath.relative(resolvedDir, nodePath.resolve(filePath));
55 const lurePath = "/" + relative.replace(/\.lure$/, "").replace(/\\/g, "/");
56
57 const parsed: ParsedLure = {
58 filePath,
59 lurePath,
60 frontmatter,
61 template: template.trim(),
62 };
63
64 const oldLurePath = this.byFilePath.get(filePath);
65 if (oldLurePath !== undefined) {
66 this.byLurePath.delete(oldLurePath);
67 }
68
69 this.byLurePath.set(lurePath, parsed);
70 this.byFilePath.set(filePath, lurePath);
71 }
72
73 delete(filePath: string): void {
74 const lurePath = this.byFilePath.get(filePath);
75 if (lurePath !== undefined) {
76 this.byLurePath.delete(lurePath);
77 this.byFilePath.delete(filePath);
78 }
79 }
80
81 get(lurePath: string): ParsedLure | undefined {
82 return this.byLurePath.get(lurePath);
83 }
84}