atproto user agency toolkit for individuals and groups
1import { parse, ValidationError, type BaseSchema } from "@atcute/lexicons/validations";
2import { validateP2pdsRecord, loadP2pdsLexicons, getLexicon } from "./lexicons.js";
3
4import {
5 AppBskyActorProfile,
6 AppBskyFeedGenerator,
7 AppBskyFeedLike,
8 AppBskyFeedPost,
9 AppBskyFeedPostgate,
10 AppBskyFeedRepost,
11 AppBskyFeedThreadgate,
12 AppBskyGraphBlock,
13 AppBskyGraphFollow,
14 AppBskyGraphList,
15 AppBskyGraphListblock,
16 AppBskyGraphListitem,
17 AppBskyGraphStarterpack,
18 AppBskyGraphVerification,
19 AppBskyLabelerService,
20} from "@atcute/bluesky";
21
22/**
23 * Map of collection NSID to validation schema.
24 */
25const recordSchemas: Record<string, BaseSchema> = {
26 "app.bsky.actor.profile": AppBskyActorProfile.mainSchema,
27 "app.bsky.feed.generator": AppBskyFeedGenerator.mainSchema,
28 "app.bsky.feed.like": AppBskyFeedLike.mainSchema,
29 "app.bsky.feed.post": AppBskyFeedPost.mainSchema,
30 "app.bsky.feed.postgate": AppBskyFeedPostgate.mainSchema,
31 "app.bsky.feed.repost": AppBskyFeedRepost.mainSchema,
32 "app.bsky.feed.threadgate": AppBskyFeedThreadgate.mainSchema,
33 "app.bsky.graph.block": AppBskyGraphBlock.mainSchema,
34 "app.bsky.graph.follow": AppBskyGraphFollow.mainSchema,
35 "app.bsky.graph.list": AppBskyGraphList.mainSchema,
36 "app.bsky.graph.listblock": AppBskyGraphListblock.mainSchema,
37 "app.bsky.graph.listitem": AppBskyGraphListitem.mainSchema,
38 "app.bsky.graph.starterpack": AppBskyGraphStarterpack.mainSchema,
39 "app.bsky.graph.verification": AppBskyGraphVerification.mainSchema,
40 "app.bsky.labeler.service": AppBskyLabelerService.mainSchema,
41};
42
43// Load p2pds lexicons at module init so they're available for validation.
44loadP2pdsLexicons();
45
46/**
47 * Record validator for AT Protocol records.
48 * Uses optimistic validation: known schemas are validated, unknown are allowed.
49 * Delegates org.p2pds.* collections to the custom lexicon validator.
50 */
51export class RecordValidator {
52 private strictMode: boolean;
53
54 constructor(options: { strict?: boolean } = {}) {
55 this.strictMode = options.strict ?? false;
56 }
57
58 validateRecord(collection: string, record: unknown): void {
59 // Delegate p2pds collections to the custom lexicon validator
60 if (collection.startsWith("org.p2pds.")) {
61 validateP2pdsRecord(collection, record);
62 return;
63 }
64
65 const schema = recordSchemas[collection];
66
67 if (!schema) {
68 if (this.strictMode) {
69 throw new Error(
70 `No lexicon schema loaded for collection: ${collection}`,
71 );
72 }
73 return;
74 }
75
76 try {
77 parse(schema, record);
78 } catch (error) {
79 if (error instanceof ValidationError) {
80 throw new Error(
81 `Lexicon validation failed for ${collection}: ${error.message}`,
82 );
83 }
84 throw error;
85 }
86 }
87
88 hasSchema(collection: string): boolean {
89 return collection in recordSchemas || getLexicon(collection) !== undefined;
90 }
91
92 getLoadedSchemas(): string[] {
93 const p2pdsSchemas = Array.from(loadP2pdsLexicons().keys());
94 return [...Object.keys(recordSchemas), ...p2pdsSchemas];
95 }
96}
97
98export const validator = new RecordValidator({ strict: false });