refactor(federation): 👽 Update all schemas to Working Draft 4

This commit is contained in:
Jesse Wierzbinski 2024-08-25 15:47:03 +02:00
parent b462718dc0
commit 4a470f5f3c
No known key found for this signature in database
13 changed files with 540 additions and 448 deletions

View file

@ -9,40 +9,42 @@ const validUser = {
uri: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", uri: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a",
bio: { bio: {
"text/html": { "text/html": {
content: "<p>Hey</p>\n", content: "<p>Hey</p>",
remote: false,
}, },
}, },
inbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/inbox",
indexable: false,
created_at: "2024-04-07T11:48:29.623Z", created_at: "2024-04-07T11:48:29.623Z",
dislikes: collections: {
"https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/dislikes",
featured: featured:
"https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured", "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured",
likes: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/likes",
followers: followers:
"https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers", "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers",
following: following:
"https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following", "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following",
inbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/inbox",
outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox", outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox",
indexable: false, },
username: "jessew", username: "jessew",
display_name: "Jesse Wierzbinski", display_name: "Jesse Wierzbinski",
fields: [ fields: [
{ {
key: { "text/html": { content: "<p>Identity</p>\n" } }, key: { "text/html": { content: "<p>Identity</p>", remote: false } },
value: { value: {
"text/html": { "text/html": {
content: content:
'<p><a href="https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA">https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA</a></p>\n', '<p><a href="https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA">https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA</a></p>',
remote: false,
}, },
}, },
}, },
], ],
public_key: { public_key: {
actor: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a", actor: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a",
public_key: "XXXXXXXX", key: "XXXXXXXX",
algorithm: "ed25519",
}, },
extensions: { "org.lysand:custom_emojis": { emojis: [] } }, extensions: { "pub.versia:custom_emojis": { emojis: [] } },
}; };
describe("RequestParserHandler", () => { describe("RequestParserHandler", () => {

View file

@ -5,23 +5,17 @@
import type { z } from "zod"; import type { z } from "zod";
import type { import type {
ActionSchema, DeleteSchema,
ActorPublicKeyDataSchema,
DislikeSchema,
EntitySchema, EntitySchema,
ExtensionSchema,
FollowAcceptSchema, FollowAcceptSchema,
FollowRejectSchema, FollowRejectSchema,
FollowSchema, FollowSchema,
LikeSchema, GroupSchema,
InstanceMetadataSchema,
NoteSchema, NoteSchema,
PatchSchema, PublicKeyDataSchema,
PublicationSchema, UnfollowSchema,
ReportSchema,
ServerMetadataSchema,
UndoSchema,
UserSchema, UserSchema,
VisibilitySchema,
} from "./schemas/base"; } from "./schemas/base";
import type { ContentFormatSchema } from "./schemas/content_format"; import type { ContentFormatSchema } from "./schemas/content_format";
import type { ExtensionPropertySchema } from "./schemas/extensions"; import type { ExtensionPropertySchema } from "./schemas/extensions";
@ -34,23 +28,17 @@ type AnyZod = z.ZodType<any, any, any>;
type InferType<T extends AnyZod> = z.infer<T>; type InferType<T extends AnyZod> = z.infer<T>;
export type Note = InferType<typeof NoteSchema>; export type Note = InferType<typeof NoteSchema>;
export type Patch = InferType<typeof PatchSchema>; export type ActorPublicKeyData = InferType<typeof PublicKeyDataSchema>;
export type ActorPublicKeyData = InferType<typeof ActorPublicKeyDataSchema>;
export type ExtensionProperty = InferType<typeof ExtensionPropertySchema>; export type ExtensionProperty = InferType<typeof ExtensionPropertySchema>;
export type VanityExtension = InferType<typeof VanityExtensionSchema>; export type VanityExtension = InferType<typeof VanityExtensionSchema>;
export type User = InferType<typeof UserSchema>; export type User = InferType<typeof UserSchema>;
export type Action = InferType<typeof ActionSchema>;
export type Like = InferType<typeof LikeSchema>;
export type Undo = InferType<typeof UndoSchema>;
export type Dislike = InferType<typeof DislikeSchema>;
export type Follow = InferType<typeof FollowSchema>; export type Follow = InferType<typeof FollowSchema>;
export type FollowAccept = InferType<typeof FollowAcceptSchema>; export type FollowAccept = InferType<typeof FollowAcceptSchema>;
export type FollowReject = InferType<typeof FollowRejectSchema>; export type FollowReject = InferType<typeof FollowRejectSchema>;
export type Extension = InferType<typeof ExtensionSchema>;
export type Report = InferType<typeof ReportSchema>;
export type ServerMetadata = InferType<typeof ServerMetadataSchema>;
export type ContentFormat = InferType<typeof ContentFormatSchema>; export type ContentFormat = InferType<typeof ContentFormatSchema>;
export type CustomEmojiExtension = InferType<typeof CustomEmojiExtensionSchema>; export type CustomEmojiExtension = InferType<typeof CustomEmojiExtensionSchema>;
export type Visibility = InferType<typeof VisibilitySchema>;
export type Publication = InferType<typeof PublicationSchema>;
export type Entity = InferType<typeof EntitySchema>; export type Entity = InferType<typeof EntitySchema>;
export type Delete = InferType<typeof DeleteSchema>;
export type Group = InferType<typeof GroupSchema>;
export type InstanceMetadata = InferType<typeof InstanceMetadataSchema>;
export type Unfollow = InferType<typeof UnfollowSchema>;

View file

@ -1,179 +1,245 @@
import { z } from "zod"; import { z } from "zod";
import { ContentFormatSchema } from "./content_format"; import {
ContentFormatSchema,
ImageOnlyContentFormatSchema,
TextOnlyContentFormatSchema,
} from "./content_format";
import { ExtensionPropertySchema } from "./extensions"; import { ExtensionPropertySchema } from "./extensions";
import { VanityExtensionSchema } from "./extensions/vanity"; import { VanityExtensionSchema } from "./extensions/vanity";
import { extensionTypeRegex } from "./regex"; import { extensionRegex, isISOString, semverRegex } from "./regex";
export const EntitySchema = z.object({ export const EntitySchema = z
id: z.string().uuid(), .object({
created_at: z.string(), id: z.string().max(512),
created_at: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime"),
uri: z.string().url(), uri: z.string().url(),
type: z.string(), type: z.string(),
extensions: ExtensionPropertySchema.optional().nullable().nullable(), extensions: ExtensionPropertySchema.optional().nullable(),
});
export const VisibilitySchema = z.enum([
"public",
"unlisted",
"private",
"direct",
]);
export const PublicationSchema = EntitySchema.extend({
type: z.enum(["Note", "Patch"]),
author: z.string().url(),
content: ContentFormatSchema.optional().nullable(),
attachments: z.array(ContentFormatSchema).optional().nullable(),
replies_to: z.string().url().optional().nullable(),
quotes: z.string().url().optional().nullable(),
mentions: z.array(z.string().url()).optional().nullable(),
subject: z.string().optional().nullable(),
is_sensitive: z.boolean().optional().nullable(),
visibility: VisibilitySchema,
extensions: ExtensionPropertySchema.extend({
"org.lysand:reactions": z
.object({
reactions: z.string(),
}) })
.optional() .strict();
.nullable(),
"org.lysand:polls": z
.object({
poll: z.object({
options: z.array(ContentFormatSchema),
votes: z.array(z.number().int().nonnegative()),
multiple_choice: z.boolean().optional().nullable(),
expires_at: z.string(),
}),
})
.optional()
.nullable(),
})
.optional()
.nullable(),
});
export const NoteSchema = PublicationSchema.extend({ export const NoteSchema = EntitySchema.extend({
type: z.literal("Note"), type: z.literal("Note"),
}); attachments: z.array(ContentFormatSchema).optional().nullable(),
author: z.string().url(),
export const PatchSchema = PublicationSchema.extend({ category: z
type: z.literal("Patch"), .enum([
patched_id: z.string().uuid(), "microblog",
patched_at: z.string(), "forum",
}); "blog",
"image",
export const ActorPublicKeyDataSchema = z.object({ "video",
public_key: z.string(), "audio",
actor: z.string().url(), "messaging",
}); ])
.optional()
export const UserSchema = EntitySchema.extend({ .nullable(),
type: z.literal("User"), content: TextOnlyContentFormatSchema.optional().nullable(),
display_name: z.string().optional().nullable(), device: z
username: z.string(), .object({
avatar: ContentFormatSchema.optional().nullable(), name: z.string(),
header: ContentFormatSchema.optional().nullable(), version: z.string().optional().nullable(),
indexable: z.boolean(), url: z.string().url().optional().nullable(),
public_key: ActorPublicKeyDataSchema, })
bio: ContentFormatSchema.optional().nullable(), .strict()
fields: z .optional()
.nullable(),
group: z
.string()
.url()
.or(z.enum(["public", "followers"]))
.optional()
.nullable(),
is_sensitive: z.boolean().optional().nullable(),
mentions: z.array(z.string().url()).optional().nullable(),
previews: z
.array( .array(
z.object({ z
key: ContentFormatSchema, .object({
value: ContentFormatSchema, link: z.string().url(),
}), title: z.string(),
description: z.string().optional().nullable(),
image: z.string().url().optional().nullable(),
icon: z.string().url().optional().nullable(),
})
.strict(),
) )
.optional() .optional()
.nullable(), .nullable(),
featured: z.string().url(), quotes: z.string().url().optional().nullable(),
followers: z.string().url(), replies_to: z.string().url().optional().nullable(),
following: z.string().url(), subject: z.string().optional().nullable(),
likes: z.string().url(),
dislikes: z.string().url(),
inbox: z.string().url(),
outbox: z.string().url(),
extensions: ExtensionPropertySchema.extend({ extensions: ExtensionPropertySchema.extend({
"org.lysand:vanity": VanityExtensionSchema.optional().nullable(), "pub.versia:reactions": z
.object({
reactions: z.string().url(),
})
.strict()
.optional()
.nullable(),
"pub.versia:polls": z
.object({
options: z.array(TextOnlyContentFormatSchema),
votes: z.array(
z
.number()
.int()
.nonnegative()
.max(2 ** 64 - 1),
),
multiple_choice: z.boolean(),
expires_at: z
.string()
.refine(
(v) => isISOString(v),
"must be a valid ISO8601 datetime",
),
})
.strict()
.optional()
.nullable(),
}) })
.optional() .optional()
.nullable(), .nullable(),
}); });
export const ActionSchema = EntitySchema.extend({ export const PublicKeyDataSchema = z
type: z.union([ .object({
z.literal("Like"), key: z.string().min(1),
z.literal("Dislike"), actor: z.string().url(),
z.literal("Follow"), algorithm: z.literal("ed25519"),
z.literal("FollowAccept"), })
z.literal("FollowReject"), .strict();
z.literal("Announce"),
z.literal("Undo"), export const UserSchema = EntitySchema.extend({
]), type: z.literal("User"),
avatar: ImageOnlyContentFormatSchema.optional().nullable(),
bio: TextOnlyContentFormatSchema.optional().nullable(),
display_name: z.string().optional().nullable(),
fields: z
.array(
z
.object({
key: TextOnlyContentFormatSchema,
value: TextOnlyContentFormatSchema,
})
.strict(),
)
.optional()
.nullable(),
username: z
.string()
.min(1)
.regex(
/^[a-z0-9_-]+$/,
"must be lowercase, alphanumeric, and may contain _ or -",
),
header: ImageOnlyContentFormatSchema.optional().nullable(),
public_key: PublicKeyDataSchema,
manually_approves_followers: z.boolean().optional().nullable(),
indexable: z.boolean().optional().nullable(),
inbox: z.string().url(),
collections: z
.object({
featured: z.string().url(),
followers: z.string().url(),
following: z.string().url(),
outbox: z.string().url(),
"pub.versia:likes/Likes": z.string().url().optional().nullable(),
"pub.versia:likes/Dislikes": z.string().url().optional().nullable(),
})
.catchall(z.string().url()),
extensions: ExtensionPropertySchema.extend({
"pub.versia:vanity": VanityExtensionSchema.optional().nullable(),
})
.optional()
.nullable(),
});
export const DeleteSchema = EntitySchema.extend({
uri: z.null().optional(),
type: z.literal("Delete"),
author: z.string().url(), author: z.string().url(),
deleted_type: z.string(),
target: z.string().url(),
}); });
export const LikeSchema = ActionSchema.extend({ export const FollowSchema = EntitySchema.extend({
type: z.literal("Like"),
object: z.string().url(),
});
export const UndoSchema = ActionSchema.extend({
type: z.literal("Undo"),
object: z.string().url(),
});
export const DislikeSchema = ActionSchema.extend({
type: z.literal("Dislike"),
object: z.string().url(),
});
export const FollowSchema = ActionSchema.extend({
type: z.literal("Follow"), type: z.literal("Follow"),
uri: z.null().optional(),
author: z.string().url(),
followee: z.string().url(), followee: z.string().url(),
}); });
export const FollowAcceptSchema = ActionSchema.extend({ export const FollowAcceptSchema = EntitySchema.extend({
type: z.literal("FollowAccept"), type: z.literal("FollowAccept"),
uri: z.null().optional(),
author: z.string().url(),
follower: z.string().url(), follower: z.string().url(),
}); });
export const FollowRejectSchema = ActionSchema.extend({ export const FollowRejectSchema = EntitySchema.extend({
type: z.literal("FollowReject"), type: z.literal("FollowReject"),
uri: z.null().optional(),
author: z.string().url(),
follower: z.string().url(), follower: z.string().url(),
}); });
export const ExtensionSchema = EntitySchema.extend({ export const UnfollowSchema = EntitySchema.extend({
type: z.literal("Extension"), type: z.literal("Unfollow"),
extension_type: z uri: z.null().optional(),
.string() author: z.string().url(),
.regex( followee: z.string().url(),
extensionTypeRegex, });
"extension_type must be in the format 'namespaced_url:extension_name/ExtensionType', e.g. 'org.lysand:reactions/Reaction'. Notably, only the type can have uppercase letters.",
export const GroupSchema = EntitySchema.extend({
type: z.literal("Group"),
name: TextOnlyContentFormatSchema.optional().nullable(),
description: TextOnlyContentFormatSchema.optional().nullable(),
members: z.string().url(),
notes: z.string().url().optional().nullable(),
});
export const InstanceMetadataSchema = EntitySchema.extend({
type: z.literal("InstanceMetadata"),
id: z.null().optional(),
uri: z.null().optional(),
name: z.string().min(1),
software: z
.object({
name: z.string().min(1),
version: z.string().min(1),
})
.strict(),
compatibility: z
.object({
versions: z.array(
z.string().regex(semverRegex, "must be a valid SemVer version"),
), ),
}); extensions: z.array(
z
export const ReportSchema = ExtensionSchema.extend({ .string()
extension_type: z.literal("org.lysand:reports/Report"), .min(1)
objects: z.array(z.string().url()), .regex(
reason: z.string(), extensionRegex,
comment: z.string().optional().nullable(), "must be in the format 'namespaced_url:extension_name', e.g. 'pub.versia:reactions'",
}); ),
),
export const ServerMetadataSchema = EntitySchema.omit({ })
created_at: true, .strict(),
id: true, description: TextOnlyContentFormatSchema.optional().nullable(),
uri: true, host: z.string(),
}).extend({ shared_inbox: z.string().url().optional().nullable(),
type: z.literal("ServerMetadata"), public_key: z
name: z.string(), .object({
version: z.string(), key: z.string().min(1),
description: z.string().optional().nullable(), algorithm: z.literal("ed25519"),
website: z.string().optional().nullable(), })
moderators: z.array(z.string()).optional().nullable(), .strict(),
admins: z.array(z.string()).optional().nullable(), moderators: z.string().url().optional().nullable(),
logo: ContentFormatSchema.optional().nullable(), admins: z.string().url().optional().nullable(),
banner: ContentFormatSchema.optional().nullable(), logo: ImageOnlyContentFormatSchema.optional().nullable(),
supported_extensions: z.array(z.string()), banner: ImageOnlyContentFormatSchema.optional().nullable(),
extensions: z.record(z.string(), z.any()).optional().nullable(),
}); });

View file

@ -1,17 +1,114 @@
import { types } from "mime-types"; import { types } from "mime-types";
import { z } from "zod"; import { z } from "zod";
export const ContentFormatSchema = z.record( const hashes = {
z.enum(Object.values(types) as [string, ...string[]]), sha256: 64,
z.object({ sha512: 128,
"sha3-256": 64,
"sha3-512": 128,
"blake2b-256": 64,
"blake2b-512": 128,
"blake3-256": 64,
"blake3-512": 128,
md5: 32,
sha1: 40,
sha224: 56,
sha384: 96,
"sha3-224": 56,
"sha3-384": 96,
"blake2s-256": 64,
"blake2s-512": 128,
"blake3-224": 56,
"blake3-384": 96,
};
const contentFormatFromAllowedMimes = (allowedMimes: [string, ...string[]]) =>
z.record(
z.enum(allowedMimes),
z
.object({
content: z.string(), content: z.string(),
remote: z.boolean(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
size: z.number().int().nonnegative().optional().nullable(), size: z
hash: z.record(z.string(), z.string()).optional().nullable(), .number()
blurhash: z.string().optional().nullable(), .int()
fps: z.number().int().nonnegative().optional().nullable(), .nonnegative()
width: z.number().int().nonnegative().optional().nullable(), .max(2 ** 64 - 1)
height: z.number().int().nonnegative().optional().nullable(), .optional()
duration: z.number().nonnegative().optional().nullable(), .nullable(),
}), hash: z
.object(
Object.fromEntries(
Object.entries(hashes).map(([k, v]) => [
k,
z.string().length(v).optional().nullable(),
]),
),
)
.strict()
.optional()
.nullable(),
thumbhash: z.string().optional().nullable(),
fps: z
.number()
.int()
.nonnegative()
.max(2 ** 64 - 1)
.optional()
.nullable(),
width: z
.number()
.int()
.nonnegative()
.max(2 ** 64 - 1)
.optional()
.nullable(),
height: z
.number()
.int()
.nonnegative()
.max(2 ** 64 - 1)
.optional()
.nullable(),
duration: z
.number()
.nonnegative()
.max(2 ** 16 - 1)
.optional()
.nullable(),
})
.strict()
.refine(
(v) =>
v.remote
? z.string().url().safeParse(v.content).success
: true,
"if remote is true, content must be a valid URL",
),
);
export const ContentFormatSchema = contentFormatFromAllowedMimes(
Object.values(types) as [string, ...string[]],
);
export const ImageOnlyContentFormatSchema = contentFormatFromAllowedMimes(
Object.values(types).filter((v) => v.startsWith("image/")) as [
string,
...string[],
],
);
export const TextOnlyContentFormatSchema = contentFormatFromAllowedMimes(
Object.values(types).filter((v) => v.startsWith("text/")) as [
string,
...string[],
],
);
export const AudioOnlyContentFormatSchema = contentFormatFromAllowedMimes(
Object.values(types).filter((v) => v.startsWith("audio/")) as [
string,
...string[],
],
); );

View file

@ -1,7 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { CustomEmojiExtensionSchema } from "./extensions/custom_emojis"; import { CustomEmojiExtensionSchema } from "./extensions/custom_emojis";
export const ExtensionPropertySchema = z.object({ export const ExtensionPropertySchema = z
"org.lysand:custom_emojis": .object({
"pub.versia:custom_emojis":
CustomEmojiExtensionSchema.optional().nullable(), CustomEmojiExtensionSchema.optional().nullable(),
}); })
.catchall(z.any());

View file

@ -5,7 +5,7 @@
* @see https://versia.pub/extensions/custom-emojis * @see https://versia.pub/extensions/custom-emojis
*/ */
import { z } from "zod"; import { z } from "zod";
import { ContentFormatSchema } from "../content_format"; import { ImageOnlyContentFormatSchema } from "../content_format";
import { emojiRegex } from "../regex"; import { emojiRegex } from "../regex";
/** /**
@ -15,14 +15,14 @@ import { emojiRegex } from "../regex";
* { * {
* // ... * // ...
* "extensions": { * "extensions": {
* "org.lysand:custom_emojis": { * "pub.versia:custom_emojis": {
* "emojis": [ * "emojis": [
* { * {
* "name": "happy_face", * "name": ":happy_face:",
* "url": { * "url": {
* "image/png": { * "image/png": {
* "content": "https://cdn.example.com/emojis/happy_face.png", * "content": "https://cdn.example.com/emojis/happy_face.png",
* "content_type": "image/png" * "remote": true
* } * }
* } * }
* }, * },
@ -35,16 +35,18 @@ import { emojiRegex } from "../regex";
*/ */
export const CustomEmojiExtensionSchema = z.object({ export const CustomEmojiExtensionSchema = z.object({
emojis: z.array( emojis: z.array(
z.object({ z
.object({
name: z name: z
.string() .string()
.min(1) .min(1)
.max(256) .max(256)
.regex( .regex(
emojiRegex, emojiRegex,
"Emoji name must be alphanumeric, underscores, or dashes.", "Emoji name must be alphanumeric, underscores, or dashes, and surrounded by identifiers",
), ),
url: ContentFormatSchema, url: ImageOnlyContentFormatSchema,
}), })
.strict(),
), ),
}); });

View file

@ -0,0 +1,40 @@
import { z } from "zod";
import { EntitySchema } from "../base";
/**
* @description Like entity
* @see https://versia.pub/extensions/likes
* @example
* {
* "id": "3e7e4750-afd4-4d99-a256-02f0710a0520",
* "type": "pub.versia:likes/Like",
* "created_at": "2021-01-01T00:00:00.000Z",
* "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
* "uri": "https://example.com/likes/3e7e4750-afd4-4d99-a256-02f0710a0520",
* "liked": "https://otherexample.org/notes/fmKZ763jzIU8"
* }
*/
export const LikeSchema = EntitySchema.extend({
type: z.literal("pub.versia:likes/Like"),
author: z.string().url(),
liked: z.string().url(),
});
/**
* @description Dislike entity
* @see https://versia.pub/extensions/likes
* @example
* {
* "id": "3e7e4750-afd4-4d99-a256-02f0710a0520",
* "type": "pub.versia:likes/Dislike",
* "created_at": "2021-01-01T00:00:00.000Z",
* "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
* "uri": "https://example.com/dislikes/3e7e4750-afd4-4d99-a256-02f0710a0520",
* "disliked": "https://otherexample.org/notes/fmKZ763jzIU8"
* }
*/
export const DislikeSchema = EntitySchema.extend({
type: z.literal("pub.versia:likes/Dislike"),
author: z.string().url(),
disliked: z.string().url(),
});

View file

@ -5,88 +5,29 @@
* @see https://versia.pub/extensions/polls * @see https://versia.pub/extensions/polls
*/ */
import { z } from "zod"; import { z } from "zod";
import { ExtensionSchema } from "../base"; import { EntitySchema } from "../base";
import { ContentFormatSchema } from "../content_format";
/**
* @description Poll extension entity
* @see https://versia.pub/extensions/polls
* @example
* {
* "type": "Extension",
* "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
* "extension_type": "org.lysand:polls/Poll",
* "uri": "https://example.com/polls/d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
* "options": [
* {
* "text/plain": {
* "content": "Red"
* }
* },
* {
* "text/plain": {
* "content": "Blue"
* }
* },
* {
* "text/plain": {
* "content": "Green"
* }
* }
* ],
* "votes": [
* 9,
* 5,
* 0
* ],
* "multiple_choice": false,
* "expires_at": "2021-01-04T00:00:00.000Z"
* }
*/
export const PollSchema = ExtensionSchema.extend({
extension_type: z.literal("org.lysand:polls/Poll"),
options: z.array(ContentFormatSchema),
votes: z.array(z.number().int().nonnegative()),
multiple_choice: z.boolean().optional().nullable(),
expires_at: z.string(),
});
/** /**
* @description Vote extension entity * @description Vote extension entity
* @see https://versia.pub/extensions/polls * @see https://versia.pub/extensions/polls
* @example * @example
* { * {
* "type": "Extension", * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "id": "31c4de70-e266-4f61-b0f7-3767d3ccf565", * "type": "pub.versia:polls/Vote",
* "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "created_at": "2021-01-01T00:00:00.000Z", * "created_at": "2021-01-01T00:00:00.000Z",
* "uri": "https://example.com/votes/31c4de70-e266-4f61-b0f7-3767d3ccf565", * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
* "extension_type": "org.lysand:polls/Vote", * "poll": "https://example.com/notes/f08a124e-fe90-439e-8be4-15a428a72a19",
* "poll": "https://example.com/polls/31c4de70-e266-4f61-b0f7-3767d3ccf565",
* "option": 1 * "option": 1
* } * }
*/ */
export const VoteSchema = ExtensionSchema.extend({ export const VoteSchema = EntitySchema.extend({
extension_type: z.literal("org.lysand:polls/Vote"), type: z.literal("pub.versia:polls/Vote"),
author: z.string().url(),
poll: z.string().url(), poll: z.string().url(),
option: z.number(), option: z
}); .number()
.int()
/** .nonnegative()
* @description Vote result extension entity .max(2 ** 64 - 1),
* @see https://versia.pub/extensions/polls
* @example
* {
* "type": "Extension",
* "id": "c6d5755b-f42c-418f-ab53-2ee3705d6628",
* "created_at": "2021-01-01T00:00:00.000Z",
* "uri": "https://example.com/polls/c6d5755b-f42c-418f-ab53-2ee3705d6628/result",
* "extension_type": "org.lysand:polls/VoteResult",
* "poll": "https://example.com/polls/c6d5755b-f42c-418f-ab53-2ee3705d6628",
* "votes": [9, 5, 0]
* }
*/
export const VoteResultSchema = ExtensionSchema.extend({
extension_type: z.literal("org.lysand:polls/VoteResult"),
poll: z.string().url(),
votes: z.array(z.number().int().nonnegative()),
}); });

View file

@ -5,24 +5,25 @@
* @see https://versia.pub/extensions/reactions * @see https://versia.pub/extensions/reactions
*/ */
import { z } from "zod"; import { z } from "zod";
import { ExtensionSchema } from "../base"; import { EntitySchema } from "../base";
/** /**
* @description Reaction extension entity * @description Reaction extension entity
* @see https://versia.pub/extensions/reactions * @see https://versia.pub/extensions/reactions
* @example * @example
* { * {
* "type": "Extension", * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3", * "type": "pub.versia:reactions/Reaction",
* "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "created_at": "2021-01-01T00:00:00.000Z", * "created_at": "2021-01-01T00:00:00.000Z",
* "uri": "https://example.com/reactions/d6eb84ea-cd13-43f9-9c54-01244da8e5e3", * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
* "extension_type": "org.lysand:reactions/Reaction", * "object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
* "object": "https://example.com/posts/d6eb84ea-cd13-43f9-9c54-01244da8e5e3", * "content": "😀",
* "content": "👍"
* } * }
*/ */
export const ReactionSchema = ExtensionSchema.extend({ export const ReactionSchema = EntitySchema.extend({
extension_type: z.literal("org.lysand:reactions/Reaction"), type: z.literal("pub.versia:reactions/Reaction"),
author: z.string().url(),
object: z.string().url(), object: z.string().url(),
content: z.string(), content: z.string().min(1).max(256),
}); });

View file

@ -0,0 +1,21 @@
import { z } from "zod";
import { EntitySchema } from "../base";
/**
* @description Share entity
* @see https://versia.pub/extensions/share
* @example
* {
* "id": "3e7e4750-afd4-4d99-a256-02f0710a0520",
* "type": "pub.versia:share/Share",
* "created_at": "2021-01-01T00:00:00.000Z",
* "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
* "uri": "https://example.com/shares/3e7e4750-afd4-4d99-a256-02f0710a0520",
* "shared": "https://otherexample.org/notes/fmKZ763jzIU8"
* }
*/
export const ShareSchema = EntitySchema.extend({
type: z.literal("pub.versia:share/Share"),
author: z.string().url(),
shared: z.string().url(),
});

View file

@ -6,7 +6,11 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { ContentFormatSchema } from "../content_format"; import {
AudioOnlyContentFormatSchema,
ImageOnlyContentFormatSchema,
} from "../content_format";
import { isISOString } from "../regex";
/** /**
* @description Vanity extension entity * @description Vanity extension entity
@ -17,29 +21,31 @@ import { ContentFormatSchema } from "../content_format";
* "type": "User", * "type": "User",
* // ... * // ...
* "extensions": { * "extensions": {
* "org.lysand:vanity": { * "pub.versia:vanity": {
* "avatar_overlay": { * "avatar_overlays": [
* {
* "image/png": { * "image/png": {
* "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png", * "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png",
* "content_type": "image/png" * "remote": true,
* } * }
* }, * }
* ],
* "avatar_mask": { * "avatar_mask": {
* "image/png": { * "image/png": {
* "content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg", * "content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg",
* "content_type": "image/jpeg" * "remote": true,
* } * }
* }, * },
* "background": { * "background": {
* "image/png": { * "image/png": {
* "content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png", * "content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png",
* "content_type": "image/png" * "remote": true,
* } * }
* }, * },
* "audio": { * "audio": {
* "audio/mpeg": { * "audio/mpeg": {
* "content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3", * "content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3",
* "content_type": "audio/mpeg" * "remote": true,
* } * }
* }, * },
* "pronouns": { * "pronouns": {
@ -56,38 +62,46 @@ import { ContentFormatSchema } from "../content_format";
* }, * },
* "birthday": "1998-04-12", * "birthday": "1998-04-12",
* "location": "+40.6894-074.0447/", * "location": "+40.6894-074.0447/",
* "activitypub": [
* "@erikuden@mastodon.de"
* ],
* "aliases": [ * "aliases": [
* "https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a", * "https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a",
* "https://social.lysand.org/users/f565ef02-035d-4974-ba5e-f62a8558331d" * "https://versia.social/users/f565ef02-035d-4974-ba5e-f62a8558331d"
* ] * ]
* } * }
* } * }
* } * }
*/ */
export const VanityExtensionSchema = z.object({ export const VanityExtensionSchema = z
avatar_overlay: ContentFormatSchema.optional().nullable(), .object({
avatar_mask: ContentFormatSchema.optional().nullable(), avatar_overlays: z
background: ContentFormatSchema.optional().nullable(), .array(ImageOnlyContentFormatSchema)
audio: ContentFormatSchema.optional().nullable(), .optional()
.nullable(),
avatar_mask: ImageOnlyContentFormatSchema.optional().nullable(),
background: ImageOnlyContentFormatSchema.optional().nullable(),
audio: AudioOnlyContentFormatSchema.optional().nullable(),
pronouns: z.record( pronouns: z.record(
z.string(), z.string(),
z.array( z.array(
z.union([ z.union([
z.object({ z
.object({
subject: z.string(), subject: z.string(),
object: z.string(), object: z.string(),
dependent_possessive: z.string(), dependent_possessive: z.string(),
independent_possessive: z.string(), independent_possessive: z.string(),
reflexive: z.string(), reflexive: z.string(),
}), })
.strict(),
z.string(), z.string(),
]), ]),
), ),
), ),
birthday: z.string().optional().nullable(), birthday: z
.string()
.refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
.optional()
.nullable(),
location: z.string().optional().nullable(), location: z.string().optional().nullable(),
activitypub: z.string().optional().nullable(), aliases: z.array(z.string().url()).optional().nullable(),
}); })
.strict();

View file

@ -5,13 +5,14 @@
*/ */
import { import {
caseInsensitive,
charIn, charIn,
charNotIn,
createRegExp, createRegExp,
digit, digit,
exactly, exactly,
global, global,
letter, letter,
not,
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
@ -19,14 +20,21 @@ import {
* Regular expression for matching emojis. * Regular expression for matching emojis.
*/ */
export const emojiRegex: RegExp = createRegExp( export const emojiRegex: RegExp = createRegExp(
// A-Z a-z 0-9 _ - exactly(
oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
[caseInsensitive, global], oneOrMore(letter.or(digit).or(charIn("_-"))),
exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
),
[global],
);
export const semverRegex: RegExp = new RegExp(
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm,
); );
/** /**
* Regular expression for matching an extension_type * Regular expression for matching an extension_type
* @example org.lysand:custom_emojis/Emoji * @example pub.versia:custom_emojis/Emoji
*/ */
export const extensionTypeRegex: RegExp = createRegExp( export const extensionTypeRegex: RegExp = createRegExp(
// org namespace, then colon, then alphanumeric/_/-, then extension name // org namespace, then colon, then alphanumeric/_/-, then extension name
@ -38,3 +46,21 @@ export const extensionTypeRegex: RegExp = createRegExp(
oneOrMore(exactly(letter.or(digit).or(charIn("_-")))), oneOrMore(exactly(letter.or(digit).or(charIn("_-")))),
), ),
); );
/**
* Regular expression for matching an extension
* @example pub.versia:custom_emojis
*/
export const extensionRegex: RegExp = createRegExp(
// org namespace, then colon, then alphanumeric/_/-, then extension name
exactly(
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))),
exactly(":"),
oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))),
),
);
export const isISOString = (val: string | Date) => {
const d = new Date(val);
return !Number.isNaN(d.valueOf()) && d.toISOString() === val;
};

View file

@ -1,23 +1,13 @@
import type { z } from "zod"; import type { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import {
ActionSchema,
ActorPublicKeyDataSchema,
DislikeSchema,
EntitySchema, EntitySchema,
ExtensionSchema,
FollowAcceptSchema, FollowAcceptSchema,
FollowRejectSchema, FollowRejectSchema,
FollowSchema, FollowSchema,
LikeSchema,
NoteSchema, NoteSchema,
PatchSchema, PublicKeyDataSchema,
PublicationSchema,
ReportSchema,
ServerMetadataSchema,
UndoSchema,
UserSchema, UserSchema,
VisibilitySchema,
} from "./schemas/base"; } from "./schemas/base";
import { ContentFormatSchema } from "./schemas/content_format"; import { ContentFormatSchema } from "./schemas/content_format";
import { ExtensionPropertySchema } from "./schemas/extensions"; import { ExtensionPropertySchema } from "./schemas/extensions";
@ -81,15 +71,6 @@ export class EntityValidator {
return this.validate(NoteSchema, data); return this.validate(NoteSchema, data);
} }
/**
* Validates a Patch entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Patch(data: unknown): Promise<InferType<typeof PatchSchema>> {
return this.validate(PatchSchema, data);
}
/** /**
* Validates an ActorPublicKeyData entity. * Validates an ActorPublicKeyData entity.
* @param data - The data to validate * @param data - The data to validate
@ -97,8 +78,8 @@ export class EntityValidator {
*/ */
public ActorPublicKeyData( public ActorPublicKeyData(
data: unknown, data: unknown,
): Promise<InferType<typeof ActorPublicKeyDataSchema>> { ): Promise<InferType<typeof PublicKeyDataSchema>> {
return this.validate(ActorPublicKeyDataSchema, data); return this.validate(PublicKeyDataSchema, data);
} }
/** /**
@ -121,42 +102,6 @@ export class EntityValidator {
return this.validate(UserSchema, data); return this.validate(UserSchema, data);
} }
/**
* Validates an Action entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Action(data: unknown): Promise<InferType<typeof ActionSchema>> {
return this.validate(ActionSchema, data);
}
/**
* Validates a Like entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Like(data: unknown): Promise<InferType<typeof LikeSchema>> {
return this.validate(LikeSchema, data);
}
/**
* Validates an Undo entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Undo(data: unknown): Promise<InferType<typeof UndoSchema>> {
return this.validate(UndoSchema, data);
}
/**
* Validates a Dislike entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Dislike(data: unknown): Promise<InferType<typeof DislikeSchema>> {
return this.validate(DislikeSchema, data);
}
/** /**
* Validates a Follow entity. * Validates a Follow entity.
* @param data - The data to validate * @param data - The data to validate
@ -188,37 +133,6 @@ export class EntityValidator {
return this.validate(FollowRejectSchema, data); return this.validate(FollowRejectSchema, data);
} }
/**
* Validates an Extension entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Extension(
data: unknown,
): Promise<InferType<typeof ExtensionSchema>> {
return this.validate(ExtensionSchema, data);
}
/**
* Validates a Report entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Report(data: unknown): Promise<InferType<typeof ReportSchema>> {
return this.validate(ReportSchema, data);
}
/**
* Validates a ServerMetadata entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public ServerMetadata(
data: unknown,
): Promise<InferType<typeof ServerMetadataSchema>> {
return this.validate(ServerMetadataSchema, data);
}
/** /**
* Validates a ContentFormat entity. * Validates a ContentFormat entity.
* @param data - The data to validate * @param data - The data to validate
@ -241,28 +155,6 @@ export class EntityValidator {
return this.validate(CustomEmojiExtensionSchema, data); return this.validate(CustomEmojiExtensionSchema, data);
} }
/**
* Validates a Visibility entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Visibility(
data: unknown,
): Promise<InferType<typeof VisibilitySchema>> {
return this.validate(VisibilitySchema, data);
}
/**
* Validates a Publication entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public Publication(
data: unknown,
): Promise<InferType<typeof PublicationSchema>> {
return this.validate(PublicationSchema, data);
}
/** /**
* Validates an Entity. * Validates an Entity.
* @param data - The data to validate * @param data - The data to validate