feat(federation): Port to Versia 0.6

This commit is contained in:
Jesse Wierzbinski 2026-02-25 02:34:27 +01:00
parent de69f27877
commit fca30b4dad
No known key found for this signature in database
62 changed files with 1614 additions and 2008 deletions

View file

@ -0,0 +1,324 @@
import * as VersiaEntities from "@versia/sdk/entities";
import {
CollectionSchema,
EntitySchema,
URICollectionSchema,
} from "@versia/sdk/schemas";
import { ApiError } from "@versia-server/kit";
import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { db, Instance, Note, User } from "@versia-server/kit/db";
import { Notes, Users } from "@versia-server/kit/tables";
import { and, eq, inArray, sql } from "drizzle-orm";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod";
export default apiRoute((app) =>
app.get(
"/.versia/v0.6/entities/:entity_type/:id/collections/:collection_type",
describeRoute({
summary:
"Retrieve the Versia representation of a collection attached to an entity.",
tags: ["Federation"],
responses: {
200: {
description: "Collection",
content: {
"application/json": {
schema: resolver(
z.union([
CollectionSchema,
URICollectionSchema,
]),
),
},
},
},
404: {
description: "Collection not found.",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
validator(
"param",
z.object({
entity_type: EntitySchema.shape.type,
id: EntitySchema.shape.id,
collection_type: z.string(),
}),
handleZodError,
),
validator(
"query",
z.object({
limit: z.coerce.number().int().min(1).max(40).default(40),
offset: z.coerce.number().int().nonnegative().default(0),
}),
handleZodError,
),
async (context) => {
const { entity_type, id, collection_type } =
context.req.valid("param");
const { limit, offset } = context.req.valid("query");
let entity:
| VersiaEntities.Collection
| VersiaEntities.URICollection
| null = null;
switch (entity_type) {
case "Note": {
const note = await Note.fromSql(
and(
eq(Notes.id, id),
inArray(Notes.visibility, ["public", "unlisted"]),
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.remote
) {
throw ApiError.noteNotFound();
}
switch (collection_type) {
case "replies": {
const replies = await Note.manyFromSql(
and(
eq(Notes.replyId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
undefined,
limit,
offset,
);
const replyCount = await db.$count(
Notes,
and(
eq(Notes.replyId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
);
entity = new VersiaEntities.URICollection({
author: note.author.id,
total: replyCount,
items: replies.map((reply) =>
reply.reference.toString(),
),
});
break;
}
case "quotes": {
const quotes = await Note.manyFromSql(
and(
eq(Notes.quotingId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
undefined,
limit,
offset,
);
const quoteCount = await db.$count(
Notes,
and(
eq(Notes.quotingId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
);
entity = new VersiaEntities.URICollection({
author: note.author.id,
total: quoteCount,
items: quotes.map((quote) =>
quote.reference.toString(),
),
});
break;
}
case "pub.versia:share/Shares": {
const shares = await Note.manyFromSql(
and(
eq(Notes.reblogId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
undefined,
limit,
offset,
);
const shareCount = await db.$count(
Notes,
and(
eq(Notes.reblogId, note.id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
);
entity = new VersiaEntities.URICollection({
author: note.author.id,
total: shareCount,
items: shares.map((share) =>
share.reference.toString(),
),
});
break;
}
}
break;
}
case "User": {
const user = await User.fromId(id);
if (!user || user.remote) {
throw ApiError.notFound();
}
switch (collection_type) {
case "outbox": {
const total = await db.$count(
Notes,
and(
eq(Notes.authorId, id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
);
const outboxItems = await Note.manyFromSql(
and(
eq(Notes.authorId, id),
inArray(Notes.visibility, [
"public",
"unlisted",
]),
),
undefined,
limit,
offset,
);
entity = new VersiaEntities.Collection({
author: user.id,
total,
items: outboxItems.map((note) =>
note.toVersia(),
),
});
break;
}
case "followers": {
if (user.data.isHidingCollections) {
entity = new VersiaEntities.URICollection({
author: user.id,
items: [],
total: 0,
});
break;
}
const total = await db.$count(
Users,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
);
const followers = await User.manyFromSql(
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
undefined,
limit,
offset,
);
entity = new VersiaEntities.URICollection({
author: user.id,
items: followers.map((follower) =>
follower.reference.toString(),
),
total,
});
break;
}
case "following": {
if (user.data.isHidingCollections) {
entity = new VersiaEntities.URICollection({
author: user.id,
items: [],
total: 0,
});
break;
}
const total = await db.$count(
Users,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`,
);
const following = await User.manyFromSql(
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${user.id} AND "Relationships"."subjectId" = ${Users.id} AND "Relationships"."following" = true)`,
undefined,
limit,
offset,
);
entity = new VersiaEntities.URICollection({
author: user.id,
items: following.map((followed) =>
followed.reference.toString(),
),
total,
});
break;
}
default: {
throw ApiError.notFound();
}
}
break;
}
}
if (!entity) {
throw ApiError.notFound();
}
const { headers } = await Instance.sign(
entity,
new URL(context.req.url),
"GET",
);
return context.json(entity, 200, headers.toJSON());
},
),
);

View file

@ -0,0 +1,178 @@
import type * as VersiaEntities from "@versia/sdk/entities";
import {
DislikeSchema,
EntitySchema,
LikeSchema,
NoteSchema,
ReactionSchema,
ShareSchema,
UserSchema,
} from "@versia/sdk/schemas";
import { ApiError } from "@versia-server/kit";
import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { Instance, Like, Note, Reaction, User } from "@versia-server/kit/db";
import { Likes, Notes } from "@versia-server/kit/tables";
import { and, eq, inArray, sql } from "drizzle-orm";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod";
export default apiRoute((app) =>
app.get(
"/.versia/v0.6/entities/:entity_type/:id",
describeRoute({
summary: "Retrieve the Versia representation of an entity.",
tags: ["Federation"],
responses: {
200: {
description: "Entity",
content: {
"application/json": {
schema: resolver(
z.union([
NoteSchema,
UserSchema,
LikeSchema,
DislikeSchema,
ReactionSchema,
ShareSchema,
]),
),
},
},
},
301: {
description:
"Redirect to user profile (for web browsers). Uses Accept header for detection.",
},
404: {
description:
"Entity not found, is remote, or the requester is not allowed to view it.",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
validator(
"param",
z.object({
entity_type: EntitySchema.shape.type,
id: EntitySchema.shape.id,
}),
handleZodError,
),
async (context) => {
const { entity_type, id } = context.req.valid("param");
let entity:
| VersiaEntities.Note
| VersiaEntities.User
| VersiaEntities.Like
| VersiaEntities.Dislike
| VersiaEntities.Reaction
| VersiaEntities.Share
| null = null;
switch (entity_type) {
case "pub.versia:notes/Note": {
const note = await Note.fromSql(
and(
eq(Notes.id, id),
inArray(Notes.visibility, ["public", "unlisted"]),
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.remote
) {
throw ApiError.noteNotFound();
}
entity = note.toVersia();
break;
}
case "pub.versia:users/User": {
const user = await User.fromId(id);
if (!user || user.remote) {
throw ApiError.accountNotFound();
}
entity = user.toVersia();
break;
}
case "pub.versia:likes/Like": {
// Don't fetch a like of a note that is not public or unlisted
// prevents leaking the existence of a private note
const like = await Like.fromSql(
and(
eq(Likes.id, id),
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
),
);
if (!like) {
throw ApiError.likeNotFound();
}
const liker = await User.fromId(like.data.likerId);
if (!liker || liker.remote) {
throw ApiError.accountNotFound();
}
entity = like.toVersia();
break;
}
case "pub.versia:likes/Dislike": {
// Versia Server does not support dislikes
throw ApiError.notFound();
}
case "pub.versia:shares/Share": {
const note = await Note.fromSql(
and(
eq(Notes.id, id),
inArray(Notes.visibility, ["public", "unlisted"]),
),
);
if (
!(note && (await note.isViewableByUser(null))) ||
note.remote ||
!note.data.reblogId
) {
throw ApiError.notFound();
}
entity = note.toVersiaShare();
break;
}
case "pub.versia:reactions/Reaction": {
const reaction = await Reaction.fromId(id);
if (!reaction) {
throw ApiError.notFound();
}
entity = reaction.toVersia();
break;
}
}
if (!entity) {
throw ApiError.notFound();
}
const { headers } = await Instance.sign(
entity,
new URL(context.req.url),
"GET",
);
return context.json(entity, 200, headers.toJSON());
},
),
);

View file

@ -0,0 +1,433 @@
import { afterAll, describe, expect, test } from "bun:test";
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { Instance, Note, Reaction, User } from "@versia-server/kit/db";
import { Notes, Reactions, Users } from "@versia-server/kit/tables";
import {
fakeRequest,
generateClient,
getTestUsers,
} from "@versia-server/tests";
import { randomUUIDv7, sleep } from "bun";
import {
clearMocks,
disableRealRequests,
enableRealRequests,
mock,
} from "bun-bagel";
import { and, eq, isNull } from "drizzle-orm";
const instanceUrl = new URL("https://versia.example.com");
const noteId = randomUUIDv7();
const userId = randomUUIDv7();
const shareId = randomUUIDv7();
const reactionId = randomUUIDv7();
const reaction2Id = randomUUIDv7();
const instanceKeys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const inboxUrl = new URL("/.versia/v0.6/inbox", config.http.base_url);
const { users, deleteUsers } = await getTestUsers(1);
disableRealRequests();
mock(new URL("/.well-known/versia", instanceUrl).href, {
response: {
headers: {
"Content-Type": "application/json",
},
data: new VersiaEntities.InstanceMetadata({
type: "InstanceMetadata",
name: "Versia",
description: "Versia instance",
created_at: new Date().toISOString(),
domain: instanceUrl.hostname,
software: {
name: "Versia",
version: "1.0.0",
},
compatibility: {
extensions: [],
versions: ["0.6.0"],
},
public_key: {
algorithm: "ed25519",
key: Buffer.from(
await crypto.subtle.exportKey(
"spki",
instanceKeys.publicKey,
),
).toString("base64"),
},
}).toJSON(),
},
});
mock(new URL(`/.versia/v0.6/entities/User/${userId}`, instanceUrl).href, {
response: {
headers: {
"Content-Type": "application/json",
},
data: new VersiaEntities.User({
id: userId,
created_at: "2025-04-18T10:32:01.427Z",
type: "User",
username: "testuser",
fields: [],
manually_approves_followers: false,
indexable: true,
}).toJSON(),
},
});
afterAll(async () => {
// Delete the instance in database
const instance = await Instance.resolve(instanceUrl.hostname);
if (!instance) {
throw new Error("Instance not found");
}
await instance.delete();
await deleteUsers();
clearMocks();
enableRealRequests();
});
describe("Inbox Tests", () => {
test("should correctly process inbox request", async () => {
const exampleNote = new VersiaEntities.Note({
id: noteId,
created_at: "2025-04-18T10:32:01.427Z",
type: "Note",
extensions: {
"pub.versia:custom_emojis": {
emojis: [],
},
},
previews: [],
attachments: [],
author: userId,
content: {
"text/html": {
content: "<p>Hello!</p>",
remote: false,
},
"text/plain": {
content: "Hello!",
remote: false,
},
},
group: "public",
is_sensitive: false,
mentions: [],
quotes: null,
replies_to: null,
subject: "",
});
const signedRequest = await sign(
instanceKeys.privateKey,
new URL(exampleNote.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type":
"application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleNote.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
// Check if note was created in the database
const note = await Note.fromSql(
eq(Notes.remoteId, exampleNote.data.id),
);
expect(note).not.toBeNull();
});
test("should correctly process Share", async () => {
const exampleRequest = new VersiaEntities.Share({
id: shareId,
created_at: "2025-04-18T10:32:01.427Z",
type: "pub.versia:share/Share",
author: userId,
shared: noteId,
});
const signedRequest = await sign(
instanceKeys.privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type":
"application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
if (!dbNote) {
throw new Error("DBNote not found");
}
// Check if share was created in the database
const share = await Note.fromSql(
and(
eq(Notes.reblogId, dbNote.id),
eq(Notes.remoteId, shareId),
eq(Notes.authorId, dbNote.data.authorId),
),
);
expect(share).not.toBeNull();
});
test("should correctly process Reaction", async () => {
const exampleRequest = new VersiaEntities.Reaction({
id: reactionId,
created_at: "2025-04-18T10:32:01.427Z",
type: "pub.versia:reactions/Reaction",
author: userId,
object: noteId,
content: "👍",
});
const signedRequest = await sign(
instanceKeys.privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type":
"application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
if (!dbNote) {
throw new Error("DBNote not found");
}
// Find the remote user who reacted by URI
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
if (!remoteUser) {
throw new Error("Remote user not found");
}
// Check if reaction was created in the database
const reaction = await Reaction.fromSql(
and(
eq(Reactions.noteId, dbNote.id),
eq(Reactions.authorId, remoteUser.id),
eq(Reactions.emojiText, "👍"),
),
);
expect(reaction).not.toBeNull();
// Check if API returns the reaction correctly
await using client = await generateClient(users[1]);
const { data, ok } = await client.getStatusReactions(dbNote.id);
expect(ok).toBe(true);
expect(data).toContainEqual(
expect.objectContaining({
name: "👍",
count: 1,
me: false,
remote: false,
}),
);
});
test("should correctly process Reaction with custom emoji", async () => {
const exampleRequest = new VersiaEntities.Reaction({
id: reaction2Id,
created_at: "2025-04-18T10:32:01.427Z",
type: "pub.versia:reactions/Reaction",
author: userId,
object: noteId,
content: ":neocat:",
extensions: {
"pub.versia:custom_emojis": {
emojis: [
{
name: ":neocat:",
url: {
"image/webp": {
hash: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
size: 4664,
width: 256,
height: 256,
remote: true,
content:
"https://cdn.cpluspatch.com/versia-cpp/e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649/neocat.webp",
},
},
},
],
},
},
});
const signedRequest = await sign(
instanceKeys.privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type":
"application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
const dbNote = await Note.fromSql(eq(Notes.remoteId, noteId));
if (!dbNote) {
throw new Error("DBNote not found");
}
// Find the remote user who reacted by URI
const remoteUser = await User.fromSql(eq(Users.remoteId, userId));
if (!remoteUser) {
throw new Error("Remote user not found");
}
// Check if reaction was created in the database
const reaction = await Reaction.fromSql(
and(
eq(Reactions.noteId, dbNote.id),
eq(Reactions.authorId, remoteUser.id),
isNull(Reactions.emojiText), // Custom emoji reactions have emojiText as NULL
),
);
expect(reaction).not.toBeNull();
// Check if API returns the reaction correctly
await using client = await generateClient(users[1]);
const { data, ok } = await client.getStatusReactions(dbNote.id);
expect(ok).toBe(true);
expect(data).toContainEqual(
expect.objectContaining({
name: ":neocat@versia.example.com:",
count: 1,
me: false,
remote: true,
}),
);
});
test("should correctly process Delete", async () => {
// First check that the note exists in the database
const noteToDelete = await Note.fromSql(eq(Notes.remoteId, noteId));
expect(noteToDelete).not.toBeNull();
// Create a Delete request
const exampleRequest = new VersiaEntities.Delete({
created_at: new Date().toISOString(),
type: "Delete",
author: userId,
deleted_type: "Note",
deleted: noteId,
});
const signedRequest = await sign(
instanceKeys.privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type":
"application/vnd.versia+json; charset=utf-8",
Accept: "application/vnd.versia+json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
// Verify that the note was deleted from the database
const noteExists = await Note.fromSql(eq(Notes.remoteId, noteId));
expect(noteExists).toBeNull();
});
});

View file

@ -0,0 +1,59 @@
import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { describeRoute, validator } from "hono-openapi";
import z from "zod";
import { InboxJobType, inboxQueue } from "~/packages/kit/queues/inbox/queue";
export default apiRoute((app) =>
app.get(
"/.versia/v0.6/inbox",
describeRoute({
summary: "Instance inbox endpoint",
tags: ["Federation"],
responses: {
200: {
description: "Request processing initiated",
},
},
}),
validator(
"header",
z.object({
"versia-signature": z.string(),
"versia-signed-at": z.coerce.number(),
"versia-signed-by": z.string(),
authorization: z.string().optional(),
}),
handleZodError,
),
async (context) => {
const body = await context.req.json();
const {
"versia-signature": signature,
"versia-signed-at": signedAt,
"versia-signed-by": signedBy,
authorization,
} = context.req.valid("header");
await inboxQueue.add(InboxJobType.ProcessEntity, {
data: body,
headers: {
"versia-signature": signature,
"versia-signed-at": signedAt,
"versia-signed-by": signedBy,
authorization,
},
request: {
body: await context.req.text(),
method: context.req.method,
url: context.req.url,
},
ip: context.env?.ip ?? null,
});
return context.body(
"Request processing initiated.\nImplement the Instance Messaging Extension to receive any eventual feedback (errors, etc.)",
200,
);
},
),
);

View file

@ -0,0 +1,88 @@
import {
type ImageContentFormatSchema,
InstanceMetadataSchema,
} from "@versia/sdk/schemas";
import { config } from "@versia-server/config";
import { apiRoute } from "@versia-server/kit/api";
import { User } from "@versia-server/kit/db";
import { Users } from "@versia-server/kit/tables";
import { asc } from "drizzle-orm";
import { describeRoute, resolver } from "hono-openapi";
import type z from "zod";
import { urlToContentFormat } from "@/content_types";
import pkg from "../../../../../package.json" with { type: "json" };
export default apiRoute((app) =>
app.get(
"/.versia/v0.6/instance",
describeRoute({
summary: "Get instance metadata",
tags: ["Federation"],
responses: {
200: {
description: "Instance metadata",
content: {
"application/json": {
schema: resolver(InstanceMetadataSchema),
},
},
},
},
}),
async (context) => {
// Get date of first user creation
const firstUser = await User.fromSql(
undefined,
asc(Users.createdAt),
);
const publicKey = Buffer.from(
await crypto.subtle.exportKey(
"spki",
config.instance.keys.public,
),
).toString("base64");
return context.json(
{
type: "InstanceMetadata" as const,
compatibility: {
extensions: [
"pub.versia:custom_emojis",
"pub.versia:instance_messaging",
"pub.versia:likes",
"pub.versia:shares",
"pub.versia:reactions",
],
versions: ["0.6.0"],
},
domain: config.http.base_url.hostname,
name: config.instance.name,
description: config.instance.description,
public_key: {
key: publicKey,
algorithm: "ed25519" as const,
},
software: {
name: "Versia Server",
version: pkg.version,
},
banner: config.instance.branding.banner
? (urlToContentFormat(
config.instance.branding.banner,
) as z.infer<typeof ImageContentFormatSchema>)
: undefined,
logo: config.instance.branding.logo
? (urlToContentFormat(
config.instance.branding.logo,
) as z.infer<typeof ImageContentFormatSchema>)
: undefined,
created_at:
firstUser?.data.createdAt.toISOString() ||
"1970-01-01T00:00:00Z",
} satisfies z.infer<typeof InstanceMetadataSchema>,
200,
);
},
),
);