// Shamelessly lifted from: https://gitlab.com/soapbox-pub/ditto/-/blob/main/src/schemas/activitypub.ts import { z } from "zod"; const apId = z.string().url(); const recipients = z.array(z.string()).catch([]); const published = () => z.string().datetime().catch(new Date().toISOString()); /** Validates individual items in an array, dropping any that aren"t valid. */ function filteredArray(schema: T) { return z.any().array() .transform((arr) => ( arr.map((item) => { const parsed = schema.safeParse(item); return parsed.success ? parsed.data : undefined; }).filter((item): item is z.infer => Boolean(item)) )); } const imageSchema = z.object({ type: z.literal("Image").catch("Image"), url: z.string().url(), }); const attachmentSchema = z.object({ type: z.literal("Document").catch("Document"), mediaType: z.string().optional().catch(undefined), url: z.string().url(), }); const mentionSchema = z.object({ type: z.literal("Mention"), href: z.string().url(), name: z.string().optional().catch(undefined), }); const hashtagSchema = z.object({ type: z.literal("Hashtag"), href: z.string().url(), name: z.string(), }); const emojiSchema = z.object({ type: z.literal("Emoji"), icon: imageSchema, name: z.string(), }); const tagSchema = z.discriminatedUnion("type", [ mentionSchema, hashtagSchema, emojiSchema, ]); const propertyValueSchema = z.object({ type: z.literal("PropertyValue"), name: z.string(), value: z.string(), verified_at: z.string().nullish(), }); /** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */ const proxySchema = z.object({ protocol: z.string().url(), proxied: z.string(), authoritative: z.boolean().optional().catch(undefined), }); const personSchema = z.object({ type: z.literal("Person"), id: apId, icon: imageSchema.optional().catch(undefined), image: imageSchema.optional().catch(undefined), name: z.string().catch(""), preferredUsername: z.string(), inbox: apId, followers: apId.optional().catch(undefined), following: apId.optional().catch(undefined), outbox: apId.optional().catch(undefined), summary: z.string().catch(""), attachment: filteredArray(propertyValueSchema).catch([]), tag: filteredArray(emojiSchema).catch([]), endpoints: z.object({ sharedInbox: apId.optional(), }).optional().catch({}), publicKey: z.object({ id: apId, owner: apId, publicKeyPem: z.string(), }).optional().catch(undefined), proxyOf: z.array(proxySchema).optional().catch(undefined), }); const applicationSchema = personSchema.merge(z.object({ type: z.literal("Application") })); const groupSchema = personSchema.merge(z.object({ type: z.literal("Group") })); const organizationSchema = personSchema.merge(z.object({ type: z.literal("Organization") })); const serviceSchema = personSchema.merge(z.object({ type: z.literal("Service") })); const actorSchema = z.discriminatedUnion("type", [ personSchema, applicationSchema, groupSchema, organizationSchema, serviceSchema, ]); const noteSchema = z.object({ type: z.literal("Note"), id: apId, to: recipients, cc: recipients, content: z.string(), attachment: z.array(attachmentSchema).optional().catch(undefined), tag: filteredArray(tagSchema).catch([]), inReplyTo: apId.optional().catch(undefined), attributedTo: apId, published: published(), sensitive: z.boolean().optional().catch(undefined), summary: z.string().nullish().catch(undefined), quoteUrl: apId.optional().catch(undefined), source: z.object({ content: z.string(), mediaType: z.literal("text/markdown"), }).optional().catch(undefined), proxyOf: z.array(proxySchema).optional().catch(undefined), }); const flexibleNoteSchema = noteSchema.extend({ quoteURL: apId.optional().catch(undefined), quoteUri: apId.optional().catch(undefined), _misskey_quote: apId.optional().catch(undefined), }).transform((note) => { const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note; return { quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote, ...rest, }; }); // https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781 const objectSchema = z.union([ flexibleNoteSchema, personSchema, applicationSchema, groupSchema, organizationSchema, serviceSchema, ]).pipe( z.discriminatedUnion("type", [ noteSchema, personSchema, applicationSchema, groupSchema, organizationSchema, serviceSchema, ]), ); const createNoteSchema = z.object({ type: z.literal("Create"), id: apId, to: recipients, cc: recipients, actor: apId, object: noteSchema, published: published(), proxyOf: z.array(proxySchema).optional().catch(undefined), }); const announceNoteSchema = z.object({ type: z.literal("Announce"), id: apId, to: recipients, cc: recipients, actor: apId, object: apId.or(noteSchema), published: published(), proxyOf: z.array(proxySchema).optional().catch(undefined), }); const followSchema = z.object({ type: z.literal("Follow"), id: apId, to: recipients, cc: recipients, actor: apId, object: apId, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const acceptSchema = z.object({ type: z.literal("Accept"), id: apId, actor: apId, to: recipients, cc: recipients, object: apId.or(followSchema), }); const likeSchema = z.object({ type: z.literal("Like"), id: apId, actor: apId, object: apId, to: recipients, cc: recipients, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const emojiReactSchema = z.object({ type: z.literal("EmojiReact"), id: apId, actor: apId, object: apId, content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)), to: recipients, cc: recipients, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const deleteSchema = z.object({ type: z.literal("Delete"), id: apId, actor: apId, object: apId, to: recipients, cc: recipients, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const updateActorSchema = z.object({ type: z.literal("Update"), id: apId, actor: apId, to: recipients, cc: recipients, object: actorSchema, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const activitySchema = z.discriminatedUnion("type", [ followSchema, acceptSchema, createNoteSchema, announceNoteSchema, updateActorSchema, likeSchema, emojiReactSchema, deleteSchema, ]).refine((activity) => { const ids: string[] = [activity.id]; if (activity.type === "Create") { ids.push( activity.object.id, activity.object.attributedTo, ); } if (activity.type === "Update") { ids.push(activity.object.id); } const { origin: actorOrigin } = new URL(activity.actor); // Object containment return ids.every((id) => { const { origin: idOrigin } = new URL(id); return idOrigin === actorOrigin; }); }); type Activity = z.infer; type CreateNote = z.infer; type Announce = z.infer; type Update = z.infer; type Object = z.infer; type Follow = z.infer; type Accept = z.infer; type Actor = z.infer; type Note = z.infer; type Mention = z.infer; type Hashtag = z.infer; type Emoji = z.infer; type Like = z.infer; type EmojiReact = z.infer; type Delete = z.infer; type Proxy = z.infer; export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema }; export type { Accept, Activity, Actor, Announce, CreateNote, Delete, Emoji, EmojiReact, Follow, Hashtag, Like, Mention, Note, Object, Proxy, Update, };