an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
1import { ValidationResult } from "npm:@atproto/lexicon";
2import { parseAtUri } from "./aturi.ts";
3import { FINEPDSAndHandleFromDid } from "./identity.ts";
4import * as ATPAPI from "npm:@atproto/api"
5
6export function validate<R extends { $type?: string }>(
7 record: unknown,
8 validator: (v: unknown) => ValidationResult<R>
9): record is R {
10 return validator(record).success;
11}
12
13type NotificationDeclarationRecord = {
14 $type: "app.bsky.notification.declaration";
15 // its not a string its a enum but we just dont have the lexicons for it
16 allowSubscriptions: string;
17 [k: string]: unknown;
18};
19// declare function validateNotificationDeclarationRecord<V>(v: V): ValidationResult<NotificationDeclarationRecord & V>;
20function validateNotificationDeclaration(
21 obj: unknown
22): obj is NotificationDeclarationRecord {
23 return (
24 typeof obj === "object" &&
25 obj !== null &&
26 typeof (obj as any).allowSubscriptions === "string"
27 );
28}
29
30const recordValidators = {
31 "app.bsky.actor.profile": ATPAPI.AppBskyActorProfile.validateRecord,
32 "app.bsky.feed.generator": ATPAPI.AppBskyFeedGenerator.validateRecord,
33 "app.bsky.feed.like": ATPAPI.AppBskyFeedLike.validateRecord,
34 "app.bsky.feed.post": ATPAPI.AppBskyFeedPost.validateRecord,
35 "app.bsky.feed.repost": ATPAPI.AppBskyFeedRepost.validateRecord,
36 "app.bsky.feed.threadgate": ATPAPI.AppBskyFeedThreadgate.validateRecord,
37 "app.bsky.graph.block": ATPAPI.AppBskyGraphBlock.validateRecord,
38 "app.bsky.graph.follow": ATPAPI.AppBskyGraphFollow.validateRecord,
39 "app.bsky.graph.list": ATPAPI.AppBskyGraphList.validateRecord,
40 "app.bsky.graph.listblock": ATPAPI.AppBskyGraphListblock.validateRecord,
41 "app.bsky.graph.listitem": ATPAPI.AppBskyGraphListitem.validateRecord,
42 "app.bsky.notification.declaration": validateNotificationDeclaration, // smh my head
43} as const;
44
45type RecordTypeMap = {
46 "app.bsky.actor.profile": ATPAPI.AppBskyActorProfile.Record;
47 "app.bsky.feed.generator": ATPAPI.AppBskyFeedGenerator.Record;
48 "app.bsky.feed.like": ATPAPI.AppBskyFeedLike.Record;
49 "app.bsky.feed.post": ATPAPI.AppBskyFeedPost.Record;
50 "app.bsky.feed.repost": ATPAPI.AppBskyFeedRepost.Record;
51 "app.bsky.feed.threadgate": ATPAPI.AppBskyFeedThreadgate.Record;
52 "app.bsky.graph.block": ATPAPI.AppBskyGraphBlock.Record;
53 "app.bsky.graph.follow": ATPAPI.AppBskyGraphFollow.Record;
54 "app.bsky.graph.list": ATPAPI.AppBskyGraphList.Record;
55 "app.bsky.graph.listblock": ATPAPI.AppBskyGraphListblock.Record;
56 "app.bsky.graph.listitem": ATPAPI.AppBskyGraphListitem.Record;
57 "app.bsky.notification.declaration": NotificationDeclarationRecord; // smh my smh head smh my head
58};
59
60// type RecordTypeMap = {
61// [K in KnownRecordType]: ReturnType<typeof recordValidators[K]> extends ValidationResult<infer T> ? T : never
62// }
63type KnownRecordType = keyof typeof recordValidators;
64
65export function validateRecord<T extends KnownRecordType>(
66 record: unknown
67): RecordTypeMap[T] | undefined {
68 const type = (record as { $type?: string })?.$type;
69 if (!type || !(type in recordValidators)) return undefined;
70
71 const validator = recordValidators[type as T] as (
72 v: unknown
73 ) => ValidationResult<RecordTypeMap[T]>;
74 const result = validator(record);
75 if (result.success) return result.value;
76 return undefined;
77}
78export function assertRecord<T extends KnownRecordType>(
79 record: unknown
80): RecordTypeMap[T] | undefined {
81 if (typeof record !== 'object' || record === null) {
82 return undefined;
83 }
84 const type = (record as { $type?: string })?.$type;
85 if (typeof type !== 'string' || !(type in recordValidators)) {
86 return undefined;
87 }
88 return record as RecordTypeMap[T];
89}
90export async function resolveRecordFromURI({
91 did,
92 uri,
93}: {
94 did?: string;
95 uri: string;
96}): Promise<RecordTypeMap[KnownRecordType] | undefined> {
97 const parsed = parseAtUri(uri);
98 const safeDid = did ?? parsed?.did!;
99 const pdsurl = await FINEPDSAndHandleFromDid(safeDid);
100
101 const res = await fetch(
102 `${pdsurl}/xrpc/com.atproto.repo.getRecord?repo=${safeDid}&collection=${parsed?.collection}&rkey=${parsed?.rkey}`
103 );
104 const json = await res.json();
105 const record = json?.value;
106
107 return validateRecord(record);
108}
109
110async function _test(uri: string) {
111 const record = await resolveRecordFromURI({ uri });
112
113 if (record?.$type === "app.bsky.feed.post") {
114 record.text; // Type-safe access to `AppBskyFeedPost.Record`
115 }
116}