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), }); /** * A custom Zap activity type we made up, based on: * https://github.com/nostr-protocol/nips/blob/master/57.md */ const zapSchema = z.object({ type: z.literal('Zap'), id: apId, actor: apId, object: apId, to: recipients, cc: recipients, proxyOf: z.array(proxySchema).optional().catch(undefined), }); const activitySchema = z.discriminatedUnion('type', [ followSchema, acceptSchema, createNoteSchema, announceNoteSchema, updateActorSchema, likeSchema, emojiReactSchema, deleteSchema, zapSchema, ]).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 Zap = 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, Zap, };