diff --git a/federation/http.test.ts b/federation/http.test.ts
index eedca9e..1e7a9c7 100644
--- a/federation/http.test.ts
+++ b/federation/http.test.ts
@@ -9,40 +9,42 @@ const validUser = {
uri: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a",
bio: {
"text/html": {
- content: "
Hey
\n",
+ content: "Hey
",
+ remote: false,
},
},
- created_at: "2024-04-07T11:48:29.623Z",
- dislikes:
- "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/dislikes",
- 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:
- "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers",
- 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",
indexable: false,
+ created_at: "2024-04-07T11:48:29.623Z",
+ collections: {
+ featured:
+ "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/featured",
+ followers:
+ "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/followers",
+ following:
+ "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/following",
+ outbox: "https://social.lysand.org/users/018eb863-753f-76ff-83d6-fd590de7740a/outbox",
+ },
username: "jessew",
display_name: "Jesse Wierzbinski",
fields: [
{
- key: { "text/html": { content: "Identity
\n" } },
+ key: { "text/html": { content: "Identity
", remote: false } },
value: {
"text/html": {
content:
- 'https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA
\n',
+ 'https://keyoxide.org/aspe:keyoxide.org:NKLLPWPV7P35NEU7JP4K4ID4CA
',
+ remote: false,
},
},
},
],
public_key: {
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", () => {
diff --git a/federation/schemas.ts b/federation/schemas.ts
index fca54dd..2159ac0 100644
--- a/federation/schemas.ts
+++ b/federation/schemas.ts
@@ -5,23 +5,17 @@
import type { z } from "zod";
import type {
- ActionSchema,
- ActorPublicKeyDataSchema,
- DislikeSchema,
+ DeleteSchema,
EntitySchema,
- ExtensionSchema,
FollowAcceptSchema,
FollowRejectSchema,
FollowSchema,
- LikeSchema,
+ GroupSchema,
+ InstanceMetadataSchema,
NoteSchema,
- PatchSchema,
- PublicationSchema,
- ReportSchema,
- ServerMetadataSchema,
- UndoSchema,
+ PublicKeyDataSchema,
+ UnfollowSchema,
UserSchema,
- VisibilitySchema,
} from "./schemas/base";
import type { ContentFormatSchema } from "./schemas/content_format";
import type { ExtensionPropertySchema } from "./schemas/extensions";
@@ -34,23 +28,17 @@ type AnyZod = z.ZodType;
type InferType = z.infer;
export type Note = InferType;
-export type Patch = InferType;
-export type ActorPublicKeyData = InferType;
+export type ActorPublicKeyData = InferType;
export type ExtensionProperty = InferType;
export type VanityExtension = InferType;
export type User = InferType;
-export type Action = InferType;
-export type Like = InferType;
-export type Undo = InferType;
-export type Dislike = InferType;
export type Follow = InferType;
export type FollowAccept = InferType;
export type FollowReject = InferType;
-export type Extension = InferType;
-export type Report = InferType;
-export type ServerMetadata = InferType;
export type ContentFormat = InferType;
export type CustomEmojiExtension = InferType;
-export type Visibility = InferType;
-export type Publication = InferType;
export type Entity = InferType;
+export type Delete = InferType;
+export type Group = InferType;
+export type InstanceMetadata = InferType;
+export type Unfollow = InferType;
diff --git a/federation/schemas/base.ts b/federation/schemas/base.ts
index 262698e..445788c 100644
--- a/federation/schemas/base.ts
+++ b/federation/schemas/base.ts
@@ -1,51 +1,103 @@
import { z } from "zod";
-import { ContentFormatSchema } from "./content_format";
+import {
+ ContentFormatSchema,
+ ImageOnlyContentFormatSchema,
+ TextOnlyContentFormatSchema,
+} from "./content_format";
import { ExtensionPropertySchema } from "./extensions";
import { VanityExtensionSchema } from "./extensions/vanity";
-import { extensionTypeRegex } from "./regex";
-
-export const EntitySchema = z.object({
- id: z.string().uuid(),
- created_at: z.string(),
- uri: z.string().url(),
- type: z.string(),
- extensions: ExtensionPropertySchema.optional().nullable().nullable(),
-});
-
-export const VisibilitySchema = z.enum([
- "public",
- "unlisted",
- "private",
- "direct",
-]);
+import { extensionRegex, isISOString, semverRegex } from "./regex";
+
+export const EntitySchema = z
+ .object({
+ id: z.string().max(512),
+ created_at: z
+ .string()
+ .refine((v) => isISOString(v), "must be a valid ISO8601 datetime"),
+ uri: z.string().url(),
+ type: z.string(),
+ extensions: ExtensionPropertySchema.optional().nullable(),
+ })
+ .strict();
-export const PublicationSchema = EntitySchema.extend({
- type: z.enum(["Note", "Patch"]),
- author: z.string().url(),
- content: ContentFormatSchema.optional().nullable(),
+export const NoteSchema = EntitySchema.extend({
+ type: z.literal("Note"),
attachments: z.array(ContentFormatSchema).optional().nullable(),
- replies_to: z.string().url().optional().nullable(),
- quotes: z.string().url().optional().nullable(),
+ author: z.string().url(),
+ category: z
+ .enum([
+ "microblog",
+ "forum",
+ "blog",
+ "image",
+ "video",
+ "audio",
+ "messaging",
+ ])
+ .optional()
+ .nullable(),
+ content: TextOnlyContentFormatSchema.optional().nullable(),
+ device: z
+ .object({
+ name: z.string(),
+ version: z.string().optional().nullable(),
+ url: z.string().url().optional().nullable(),
+ })
+ .strict()
+ .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(
+ z
+ .object({
+ 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()
+ .nullable(),
+ quotes: z.string().url().optional().nullable(),
+ replies_to: 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
+ "pub.versia:reactions": z
.object({
- reactions: z.string(),
+ reactions: z.string().url(),
})
+ .strict()
.optional()
.nullable(),
- "org.lysand:polls": z
+ "pub.versia: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(),
- }),
+ 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(),
})
@@ -53,127 +105,141 @@ export const PublicationSchema = EntitySchema.extend({
.nullable(),
});
-export const NoteSchema = PublicationSchema.extend({
- type: z.literal("Note"),
-});
-
-export const PatchSchema = PublicationSchema.extend({
- type: z.literal("Patch"),
- patched_id: z.string().uuid(),
- patched_at: z.string(),
-});
-
-export const ActorPublicKeyDataSchema = z.object({
- public_key: z.string(),
- actor: z.string().url(),
-});
+export const PublicKeyDataSchema = z
+ .object({
+ key: z.string().min(1),
+ actor: z.string().url(),
+ algorithm: z.literal("ed25519"),
+ })
+ .strict();
export const UserSchema = EntitySchema.extend({
type: z.literal("User"),
+ avatar: ImageOnlyContentFormatSchema.optional().nullable(),
+ bio: TextOnlyContentFormatSchema.optional().nullable(),
display_name: z.string().optional().nullable(),
- username: z.string(),
- avatar: ContentFormatSchema.optional().nullable(),
- header: ContentFormatSchema.optional().nullable(),
- indexable: z.boolean(),
- public_key: ActorPublicKeyDataSchema,
- bio: ContentFormatSchema.optional().nullable(),
fields: z
.array(
- z.object({
- key: ContentFormatSchema,
- value: ContentFormatSchema,
- }),
+ z
+ .object({
+ key: TextOnlyContentFormatSchema,
+ value: TextOnlyContentFormatSchema,
+ })
+ .strict(),
)
.optional()
.nullable(),
- featured: z.string().url(),
- followers: z.string().url(),
- following: z.string().url(),
- likes: z.string().url(),
- dislikes: z.string().url(),
+ 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(),
- outbox: 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({
- "org.lysand:vanity": VanityExtensionSchema.optional().nullable(),
+ "pub.versia:vanity": VanityExtensionSchema.optional().nullable(),
})
.optional()
.nullable(),
});
-export const ActionSchema = EntitySchema.extend({
- type: z.union([
- z.literal("Like"),
- z.literal("Dislike"),
- z.literal("Follow"),
- z.literal("FollowAccept"),
- z.literal("FollowReject"),
- z.literal("Announce"),
- z.literal("Undo"),
- ]),
+export const DeleteSchema = EntitySchema.extend({
+ uri: z.null().optional(),
+ type: z.literal("Delete"),
author: z.string().url(),
+ deleted_type: z.string(),
+ target: z.string().url(),
});
-export const LikeSchema = ActionSchema.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({
+export const FollowSchema = EntitySchema.extend({
type: z.literal("Follow"),
+ uri: z.null().optional(),
+ author: z.string().url(),
followee: z.string().url(),
});
-export const FollowAcceptSchema = ActionSchema.extend({
+export const FollowAcceptSchema = EntitySchema.extend({
type: z.literal("FollowAccept"),
+ uri: z.null().optional(),
+ author: z.string().url(),
follower: z.string().url(),
});
-export const FollowRejectSchema = ActionSchema.extend({
+export const FollowRejectSchema = EntitySchema.extend({
type: z.literal("FollowReject"),
+ uri: z.null().optional(),
+ author: z.string().url(),
follower: z.string().url(),
});
-export const ExtensionSchema = EntitySchema.extend({
- type: z.literal("Extension"),
- extension_type: z
- .string()
- .regex(
- 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 ReportSchema = ExtensionSchema.extend({
- extension_type: z.literal("org.lysand:reports/Report"),
- objects: z.array(z.string().url()),
- reason: z.string(),
- comment: z.string().optional().nullable(),
+export const UnfollowSchema = EntitySchema.extend({
+ type: z.literal("Unfollow"),
+ uri: z.null().optional(),
+ author: z.string().url(),
+ followee: z.string().url(),
});
-export const ServerMetadataSchema = EntitySchema.omit({
- created_at: true,
- id: true,
- uri: true,
-}).extend({
- type: z.literal("ServerMetadata"),
- name: z.string(),
- version: z.string(),
- description: z.string().optional().nullable(),
- website: z.string().optional().nullable(),
- moderators: z.array(z.string()).optional().nullable(),
- admins: z.array(z.string()).optional().nullable(),
- logo: ContentFormatSchema.optional().nullable(),
- banner: ContentFormatSchema.optional().nullable(),
- supported_extensions: z.array(z.string()),
- extensions: z.record(z.string(), z.any()).optional().nullable(),
+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
+ .string()
+ .min(1)
+ .regex(
+ extensionRegex,
+ "must be in the format 'namespaced_url:extension_name', e.g. 'pub.versia:reactions'",
+ ),
+ ),
+ })
+ .strict(),
+ description: TextOnlyContentFormatSchema.optional().nullable(),
+ host: z.string(),
+ shared_inbox: z.string().url().optional().nullable(),
+ public_key: z
+ .object({
+ key: z.string().min(1),
+ algorithm: z.literal("ed25519"),
+ })
+ .strict(),
+ moderators: z.string().url().optional().nullable(),
+ admins: z.string().url().optional().nullable(),
+ logo: ImageOnlyContentFormatSchema.optional().nullable(),
+ banner: ImageOnlyContentFormatSchema.optional().nullable(),
});
diff --git a/federation/schemas/content_format.ts b/federation/schemas/content_format.ts
index 3d2a5c6..9d9508d 100644
--- a/federation/schemas/content_format.ts
+++ b/federation/schemas/content_format.ts
@@ -1,17 +1,114 @@
import { types } from "mime-types";
import { z } from "zod";
-export const ContentFormatSchema = z.record(
- z.enum(Object.values(types) as [string, ...string[]]),
- z.object({
- content: z.string(),
- description: z.string().optional().nullable(),
- size: z.number().int().nonnegative().optional().nullable(),
- hash: z.record(z.string(), z.string()).optional().nullable(),
- blurhash: z.string().optional().nullable(),
- fps: z.number().int().nonnegative().optional().nullable(),
- width: z.number().int().nonnegative().optional().nullable(),
- height: z.number().int().nonnegative().optional().nullable(),
- duration: z.number().nonnegative().optional().nullable(),
- }),
+const hashes = {
+ sha256: 64,
+ 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(),
+ remote: z.boolean(),
+ description: z.string().optional().nullable(),
+ size: z
+ .number()
+ .int()
+ .nonnegative()
+ .max(2 ** 64 - 1)
+ .optional()
+ .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[],
+ ],
);
diff --git a/federation/schemas/extensions.ts b/federation/schemas/extensions.ts
index 7078bde..2077fed 100644
--- a/federation/schemas/extensions.ts
+++ b/federation/schemas/extensions.ts
@@ -1,7 +1,9 @@
import { z } from "zod";
import { CustomEmojiExtensionSchema } from "./extensions/custom_emojis";
-export const ExtensionPropertySchema = z.object({
- "org.lysand:custom_emojis":
- CustomEmojiExtensionSchema.optional().nullable(),
-});
+export const ExtensionPropertySchema = z
+ .object({
+ "pub.versia:custom_emojis":
+ CustomEmojiExtensionSchema.optional().nullable(),
+ })
+ .catchall(z.any());
diff --git a/federation/schemas/extensions/custom_emojis.ts b/federation/schemas/extensions/custom_emojis.ts
index 8573f54..573c44b 100644
--- a/federation/schemas/extensions/custom_emojis.ts
+++ b/federation/schemas/extensions/custom_emojis.ts
@@ -5,7 +5,7 @@
* @see https://versia.pub/extensions/custom-emojis
*/
import { z } from "zod";
-import { ContentFormatSchema } from "../content_format";
+import { ImageOnlyContentFormatSchema } from "../content_format";
import { emojiRegex } from "../regex";
/**
@@ -15,14 +15,14 @@ import { emojiRegex } from "../regex";
* {
* // ...
* "extensions": {
- * "org.lysand:custom_emojis": {
+ * "pub.versia:custom_emojis": {
* "emojis": [
* {
- * "name": "happy_face",
+ * "name": ":happy_face:",
* "url": {
* "image/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({
emojis: z.array(
- z.object({
- name: z
- .string()
- .min(1)
- .max(256)
- .regex(
- emojiRegex,
- "Emoji name must be alphanumeric, underscores, or dashes.",
- ),
- url: ContentFormatSchema,
- }),
+ z
+ .object({
+ name: z
+ .string()
+ .min(1)
+ .max(256)
+ .regex(
+ emojiRegex,
+ "Emoji name must be alphanumeric, underscores, or dashes, and surrounded by identifiers",
+ ),
+ url: ImageOnlyContentFormatSchema,
+ })
+ .strict(),
),
});
diff --git a/federation/schemas/extensions/likes.ts b/federation/schemas/extensions/likes.ts
new file mode 100644
index 0000000..53ee827
--- /dev/null
+++ b/federation/schemas/extensions/likes.ts
@@ -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(),
+});
diff --git a/federation/schemas/extensions/polls.ts b/federation/schemas/extensions/polls.ts
index fb16238..db4accf 100644
--- a/federation/schemas/extensions/polls.ts
+++ b/federation/schemas/extensions/polls.ts
@@ -5,88 +5,29 @@
* @see https://versia.pub/extensions/polls
*/
import { z } from "zod";
-import { ExtensionSchema } 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(),
-});
+import { EntitySchema } from "../base";
/**
* @description Vote extension entity
* @see https://versia.pub/extensions/polls
* @example
* {
- * "type": "Extension",
- * "id": "31c4de70-e266-4f61-b0f7-3767d3ccf565",
+ * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
+ * "type": "pub.versia:polls/Vote",
+ * "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "created_at": "2021-01-01T00:00:00.000Z",
- * "uri": "https://example.com/votes/31c4de70-e266-4f61-b0f7-3767d3ccf565",
- * "extension_type": "org.lysand:polls/Vote",
- * "poll": "https://example.com/polls/31c4de70-e266-4f61-b0f7-3767d3ccf565",
+ * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
+ * "poll": "https://example.com/notes/f08a124e-fe90-439e-8be4-15a428a72a19",
* "option": 1
* }
*/
-export const VoteSchema = ExtensionSchema.extend({
- extension_type: z.literal("org.lysand:polls/Vote"),
- poll: z.string().url(),
- option: z.number(),
-});
-
-/**
- * @description Vote result extension entity
- * @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"),
+export const VoteSchema = EntitySchema.extend({
+ type: z.literal("pub.versia:polls/Vote"),
+ author: z.string().url(),
poll: z.string().url(),
- votes: z.array(z.number().int().nonnegative()),
+ option: z
+ .number()
+ .int()
+ .nonnegative()
+ .max(2 ** 64 - 1),
});
diff --git a/federation/schemas/extensions/reactions.ts b/federation/schemas/extensions/reactions.ts
index 6291d39..fb15e80 100644
--- a/federation/schemas/extensions/reactions.ts
+++ b/federation/schemas/extensions/reactions.ts
@@ -5,24 +5,25 @@
* @see https://versia.pub/extensions/reactions
*/
import { z } from "zod";
-import { ExtensionSchema } from "../base";
+import { EntitySchema } from "../base";
/**
* @description Reaction extension entity
* @see https://versia.pub/extensions/reactions
* @example
* {
- * "type": "Extension",
- * "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
+ * "id": "6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
+ * "type": "pub.versia:reactions/Reaction",
+ * "uri": "https://example.com/actions/6f27bc77-58ee-4c9b-b804-8cc1c1182fa9",
* "created_at": "2021-01-01T00:00:00.000Z",
- * "uri": "https://example.com/reactions/d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
- * "extension_type": "org.lysand:reactions/Reaction",
- * "object": "https://example.com/posts/d6eb84ea-cd13-43f9-9c54-01244da8e5e3",
- * "content": "👍"
+ * "author": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe",
+ * "object": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19",
+ * "content": "😀",
* }
*/
-export const ReactionSchema = ExtensionSchema.extend({
- extension_type: z.literal("org.lysand:reactions/Reaction"),
+export const ReactionSchema = EntitySchema.extend({
+ type: z.literal("pub.versia:reactions/Reaction"),
+ author: z.string().url(),
object: z.string().url(),
- content: z.string(),
+ content: z.string().min(1).max(256),
});
diff --git a/federation/schemas/extensions/share.ts b/federation/schemas/extensions/share.ts
new file mode 100644
index 0000000..3209b38
--- /dev/null
+++ b/federation/schemas/extensions/share.ts
@@ -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(),
+});
diff --git a/federation/schemas/extensions/vanity.ts b/federation/schemas/extensions/vanity.ts
index a1810d7..f6ba3dd 100644
--- a/federation/schemas/extensions/vanity.ts
+++ b/federation/schemas/extensions/vanity.ts
@@ -6,7 +6,11 @@
*/
import { z } from "zod";
-import { ContentFormatSchema } from "../content_format";
+import {
+ AudioOnlyContentFormatSchema,
+ ImageOnlyContentFormatSchema,
+} from "../content_format";
+import { isISOString } from "../regex";
/**
* @description Vanity extension entity
@@ -17,29 +21,31 @@ import { ContentFormatSchema } from "../content_format";
* "type": "User",
* // ...
* "extensions": {
- * "org.lysand:vanity": {
- * "avatar_overlay": {
- * "image/png": {
- * "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png",
- * "content_type": "image/png"
+ * "pub.versia:vanity": {
+ * "avatar_overlays": [
+ * {
+ * "image/png": {
+ * "content": "https://cdn.example.com/ab5081cf-b11f-408f-92c2-7c246f290593/cat_ears.png",
+ * "remote": true,
+ * }
* }
- * },
+ * ],
* "avatar_mask": {
* "image/png": {
* "content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg",
- * "content_type": "image/jpeg"
+ * "remote": true,
* }
* },
* "background": {
* "image/png": {
* "content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png",
- * "content_type": "image/png"
+ * "remote": true,
* }
* },
* "audio": {
* "audio/mpeg": {
* "content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3",
- * "content_type": "audio/mpeg"
+ * "remote": true,
* }
* },
* "pronouns": {
@@ -56,38 +62,46 @@ import { ContentFormatSchema } from "../content_format";
* },
* "birthday": "1998-04-12",
* "location": "+40.6894-074.0447/",
- * "activitypub": [
- * "@erikuden@mastodon.de"
- * ],
* "aliases": [
* "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({
- avatar_overlay: ContentFormatSchema.optional().nullable(),
- avatar_mask: ContentFormatSchema.optional().nullable(),
- background: ContentFormatSchema.optional().nullable(),
- audio: ContentFormatSchema.optional().nullable(),
- pronouns: z.record(
- z.string(),
- z.array(
- z.union([
- z.object({
- subject: z.string(),
- object: z.string(),
- dependent_possessive: z.string(),
- independent_possessive: z.string(),
- reflexive: z.string(),
- }),
- z.string(),
- ]),
+export const VanityExtensionSchema = z
+ .object({
+ avatar_overlays: z
+ .array(ImageOnlyContentFormatSchema)
+ .optional()
+ .nullable(),
+ avatar_mask: ImageOnlyContentFormatSchema.optional().nullable(),
+ background: ImageOnlyContentFormatSchema.optional().nullable(),
+ audio: AudioOnlyContentFormatSchema.optional().nullable(),
+ pronouns: z.record(
+ z.string(),
+ z.array(
+ z.union([
+ z
+ .object({
+ subject: z.string(),
+ object: z.string(),
+ dependent_possessive: z.string(),
+ independent_possessive: z.string(),
+ reflexive: z.string(),
+ })
+ .strict(),
+ z.string(),
+ ]),
+ ),
),
- ),
- birthday: z.string().optional().nullable(),
- location: z.string().optional().nullable(),
- activitypub: z.string().optional().nullable(),
-});
+ birthday: z
+ .string()
+ .refine((v) => isISOString(v), "must be a valid ISO8601 datetime")
+ .optional()
+ .nullable(),
+ location: z.string().optional().nullable(),
+ aliases: z.array(z.string().url()).optional().nullable(),
+ })
+ .strict();
diff --git a/federation/schemas/regex.ts b/federation/schemas/regex.ts
index 5622d38..dc9ac88 100644
--- a/federation/schemas/regex.ts
+++ b/federation/schemas/regex.ts
@@ -5,13 +5,14 @@
*/
import {
- caseInsensitive,
charIn,
+ charNotIn,
createRegExp,
digit,
exactly,
global,
letter,
+ not,
oneOrMore,
} from "magic-regexp";
@@ -19,14 +20,21 @@ import {
* Regular expression for matching emojis.
*/
export const emojiRegex: RegExp = createRegExp(
- // A-Z a-z 0-9 _ -
- oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))),
- [caseInsensitive, global],
+ exactly(
+ exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
+ 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
- * @example org.lysand:custom_emojis/Emoji
+ * @example pub.versia:custom_emojis/Emoji
*/
export const extensionTypeRegex: RegExp = createRegExp(
// 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("_-")))),
),
);
+
+/**
+ * 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;
+};
diff --git a/federation/validator.ts b/federation/validator.ts
index bec3345..f93e26d 100644
--- a/federation/validator.ts
+++ b/federation/validator.ts
@@ -1,23 +1,13 @@
import type { z } from "zod";
import { fromError } from "zod-validation-error";
import {
- ActionSchema,
- ActorPublicKeyDataSchema,
- DislikeSchema,
EntitySchema,
- ExtensionSchema,
FollowAcceptSchema,
FollowRejectSchema,
FollowSchema,
- LikeSchema,
NoteSchema,
- PatchSchema,
- PublicationSchema,
- ReportSchema,
- ServerMetadataSchema,
- UndoSchema,
+ PublicKeyDataSchema,
UserSchema,
- VisibilitySchema,
} from "./schemas/base";
import { ContentFormatSchema } from "./schemas/content_format";
import { ExtensionPropertySchema } from "./schemas/extensions";
@@ -81,15 +71,6 @@ export class EntityValidator {
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> {
- return this.validate(PatchSchema, data);
- }
-
/**
* Validates an ActorPublicKeyData entity.
* @param data - The data to validate
@@ -97,8 +78,8 @@ export class EntityValidator {
*/
public ActorPublicKeyData(
data: unknown,
- ): Promise> {
- return this.validate(ActorPublicKeyDataSchema, data);
+ ): Promise> {
+ return this.validate(PublicKeyDataSchema, data);
}
/**
@@ -121,42 +102,6 @@ export class EntityValidator {
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> {
- 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> {
- 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> {
- 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> {
- return this.validate(DislikeSchema, data);
- }
-
/**
* Validates a Follow entity.
* @param data - The data to validate
@@ -188,37 +133,6 @@ export class EntityValidator {
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> {
- 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> {
- 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> {
- return this.validate(ServerMetadataSchema, data);
- }
-
/**
* Validates a ContentFormat entity.
* @param data - The data to validate
@@ -241,28 +155,6 @@ export class EntityValidator {
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> {
- 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> {
- return this.validate(PublicationSchema, data);
- }
-
/**
* Validates an Entity.
* @param data - The data to validate