Compare commits

..

3 commits

Author SHA1 Message Date
Jesse Wierzbinski ae5c3bb281
chore(federation): 🔖 [publish] Release 0.2.0
Some checks failed
Test Build / build (push) Failing after 5s
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 5s
Lint & Format / tests (push) Failing after 5s
Mirror to Codeberg / Mirror (push) Failing after 1s
Build & Publish Package / publish (push) Failing after 5s
Test Publish / build (client) (push) Failing after 5s
Test Publish / build (federation) (push) Failing after 5s
Run Tests / build (push) Failing after 4s
2025-02-13 18:04:28 +01:00
Jesse Wierzbinski 5114df4454
feat(federation): 👽 Update to Versia 0.5 2025-02-13 18:03:04 +01:00
Jesse Wierzbinski afec384a51
feat(federation): 👽 Update cryptography code to Versia 0.5 2025-02-13 17:39:16 +01:00
12 changed files with 278 additions and 84 deletions

View file

@ -7,7 +7,7 @@ describe("SignatureValidator", () => {
let publicKey: CryptoKey; let publicKey: CryptoKey;
let body: string; let body: string;
let signature: string; let signature: string;
let nonce: string; let timestamp: Date;
beforeAll(async () => { beforeAll(async () => {
const keys = await crypto.subtle.generateKey("Ed25519", true, [ const keys = await crypto.subtle.generateKey("Ed25519", true, [
@ -25,8 +25,8 @@ describe("SignatureValidator", () => {
"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51", "https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51",
).sign("GET", new URL("https://example.com"), body); ).sign("GET", new URL("https://example.com"), body);
signature = headers.get("X-Signature") ?? ""; signature = headers.get("Versia-Signature") ?? "";
nonce = headers.get("X-Nonce") ?? ""; timestamp = new Date(Number(headers.get("Versia-Signed-At")) * 1000);
}); });
test("fromStringKey", async () => { test("fromStringKey", async () => {
@ -46,8 +46,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -59,8 +59,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": "invalid", "Versia-Signature": "invalid",
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -70,16 +70,16 @@ describe("SignatureValidator", () => {
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
test("should throw with missing nonce", async () => { test("should throw with missing timestamp", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
}, },
body: body, body: body,
}); });
expect(() => validator.validate(request)).toThrow( expect(() => validator.validate(request)).toThrow(
"Headers are missing in request: X-Nonce", "Headers are missing in request: Versia-Signed-At",
); );
}); });
@ -87,8 +87,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: "different", body: "different",
}); });
@ -101,8 +101,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": "thisIsNotbase64OhNo$^ù", "Versia-Signature": "thisIsNotbase64OhNo$^ù",
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -151,11 +151,11 @@ describe("SignatureConstructor", () => {
test("should correctly sign ", async () => { test("should correctly sign ", async () => {
const url = new URL("https://example.com"); const url = new URL("https://example.com");
headers = (await ctor.sign("GET", url, body)).headers; headers = (await ctor.sign("GET", url, body)).headers;
expect(headers.get("X-Signature")).toBeDefined(); expect(headers.get("Versia-Signature")).toBeDefined();
expect(headers.get("X-Nonce")).toBeDefined(); expect(headers.get("Versia-Signed-At")).toBeDefined();
expect(headers.get("X-Nonce")?.length).toBeGreaterThan(10); expect(headers.get("Versia-Signed-At")?.length).toBeGreaterThan(10);
expect(headers.get("X-Signature")?.length).toBeGreaterThan(10); expect(headers.get("Versia-Signature")?.length).toBeGreaterThan(10);
}); });
test("should correctly sign a Request", async () => { test("should correctly sign a Request", async () => {
@ -167,8 +167,8 @@ describe("SignatureConstructor", () => {
const { request: newRequest } = await ctor.sign(request); const { request: newRequest } = await ctor.sign(request);
headers = newRequest.headers; headers = newRequest.headers;
expect(headers.get("X-Signature")).toBeDefined(); expect(headers.get("Versia-Signature")).toBeDefined();
expect(headers.get("X-Nonce")).toBeDefined(); expect(headers.get("Versia-Signed-At")).toBeDefined();
expect(await newRequest.text()).toBe(body); expect(await newRequest.text()).toBe(body);
}); });

View file

@ -16,9 +16,6 @@ const base64ToArrayBuffer = (base64: string) =>
const arrayBufferToBase64 = (arrayBuffer: ArrayBuffer) => const arrayBufferToBase64 = (arrayBuffer: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const uint8ArrayToBase64 = (uint8Array: Uint8Array) =>
btoa(String.fromCharCode(...uint8Array));
const checkEvironmentSupport = () => { const checkEvironmentSupport = () => {
// Check if WebCrypto is supported // Check if WebCrypto is supported
if (!globalThis.crypto?.subtle) { if (!globalThis.crypto?.subtle) {
@ -78,7 +75,7 @@ export class SignatureValidator {
/** /**
* Validates the signature of a request. * Validates the signature of a request.
* @param signature The signature string. * @param signature The signature string.
* @param nonce Signature nonce. * @param timestamp Signature timestamp.
* @param method The HTTP verb. * @param method The HTTP verb.
* @param url The URL object. * @param url The URL object.
* @param body The request body. * @param body The request body.
@ -86,15 +83,15 @@ export class SignatureValidator {
* @throws TypeError if any required parameters are missing or empty. * @throws TypeError if any required parameters are missing or empty.
* @example * @example
* const signature = "k4QNt5Grl40KK8orIdiaq118Z+P5pa6vIeArq55wsvfL7wNy4cE3f2fhsGcpZql+PStm+x2ZjZIhudrAC/32Cg=="; * const signature = "k4QNt5Grl40KK8orIdiaq118Z+P5pa6vIeArq55wsvfL7wNy4cE3f2fhsGcpZql+PStm+x2ZjZIhudrAC/32Cg==";
* const nonce = "bJzyhTNK2RXUCetKIpm0Fw=="; * const date = new Date(1549312452000)
* const method = "GET"; * const method = "GET";
* const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480"); * const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480");
* const body = "{ ... }"; * const body = "{ ... }";
* const isValid = await validator.validate(signature, nonce, method, url, body); * const isValid = await validator.validate(signature, date, method, url, body);
*/ */
async validate( async validate(
signature: string, signature: string,
nonce: string, timestamp: Date,
method: HttpVerb, method: HttpVerb,
url: URL, url: URL,
body: string, body: string,
@ -102,25 +99,28 @@ export class SignatureValidator {
async validate( async validate(
requestOrSignature: Request | string, requestOrSignature: Request | string,
nonce?: string, timestamp?: Date,
method?: HttpVerb, method?: HttpVerb,
url?: URL, url?: URL,
body?: string, body?: string,
): Promise<boolean> { ): Promise<boolean> {
if (requestOrSignature instanceof Request) { if (requestOrSignature instanceof Request) {
const signature = requestOrSignature.headers.get("X-Signature"); const signature =
const nonce = requestOrSignature.headers.get("X-Nonce"); requestOrSignature.headers.get("Versia-Signature");
const timestampHeader =
requestOrSignature.headers.get("Versia-Signed-At");
const timestamp = new Date(Number(timestampHeader) * 1000);
const url = new URL(requestOrSignature.url); const url = new URL(requestOrSignature.url);
const body = await requestOrSignature.text(); const body = await requestOrSignature.text();
const method = requestOrSignature.method as HttpVerb; const method = requestOrSignature.method as HttpVerb;
const missingHeaders = [ const missingHeaders = [
!signature && "X-Signature", !signature && "Versia-Signature",
!nonce && "X-Nonce", !timestampHeader && "Versia-Signed-At",
].filter(Boolean); ].filter(Boolean);
// Check if all headers are present // Check if all headers are present
if (!(signature && nonce && method && url && body)) { if (!(signature && timestampHeader && method && url && body)) {
// Say which headers are missing // Say which headers are missing
throw new TypeError( throw new TypeError(
`Headers are missing in request: ${missingHeaders.join( `Headers are missing in request: ${missingHeaders.join(
@ -129,12 +129,12 @@ export class SignatureValidator {
); );
} }
return this.validate(signature, nonce, method, url, body); return this.validate(signature, timestamp, method, url, body);
} }
if (!(nonce && method && url && body)) { if (!(timestamp && method && url && body)) {
throw new TypeError( throw new TypeError(
"Missing or empty required parameters: nonce, method, url or body", "Missing or empty required parameters: timestamp, method, url or body",
); );
} }
@ -152,7 +152,7 @@ export class SignatureValidator {
new TextEncoder().encode(body), new TextEncoder().encode(body),
); );
const expectedSignedString = `${method.toLowerCase()} ${encodeURIComponent(url.pathname)} ${nonce} ${arrayBufferToBase64(digest)}`; const expectedSignedString = `${method.toLowerCase()} ${encodeURIComponent(url.pathname)} ${timestamp.getTime() / 1000} ${arrayBufferToBase64(digest)}`;
// Check if signed string is valid // Check if signed string is valid
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
@ -232,7 +232,7 @@ export class SignatureConstructor {
* @param url The URL object. * @param url The URL object.
* @param body The request body. * @param body The request body.
* @param headers The request headers. * @param headers The request headers.
* @param nonce The signature nonce (optional). * @param timestamp The signature timestamp (optional).
* @returns A Promise that resolves to the signed headers, and the signed string. * @returns A Promise that resolves to the signed headers, and the signed string.
* @throws TypeError if any required parameters are missing or empty. * @throws TypeError if any required parameters are missing or empty.
* @example * @example
@ -246,7 +246,7 @@ export class SignatureConstructor {
url: URL, url: URL,
body?: string, body?: string,
headers?: Headers, headers?: Headers,
nonce?: string, timestamp?: Date,
): Promise<{ ): Promise<{
headers: Headers; headers: Headers;
signedString: string; signedString: string;
@ -257,7 +257,7 @@ export class SignatureConstructor {
url?: URL, url?: URL,
body?: string, body?: string,
headers: Headers = new Headers(), headers: Headers = new Headers(),
nonce?: string, timestamp?: Date,
): Promise< ): Promise<
| { | {
headers: Headers; headers: Headers;
@ -270,19 +270,23 @@ export class SignatureConstructor {
> { > {
if (requestOrMethod instanceof Request) { if (requestOrMethod instanceof Request) {
const request = requestOrMethod.clone(); const request = requestOrMethod.clone();
const signedAt = requestOrMethod.headers.get("Versia-Signed-At");
const { headers, signedString } = await this.sign( const { headers, signedString } = await this.sign(
requestOrMethod.method as HttpVerb, requestOrMethod.method as HttpVerb,
new URL(requestOrMethod.url), new URL(requestOrMethod.url),
await requestOrMethod.text(), await requestOrMethod.text(),
requestOrMethod.headers, requestOrMethod.headers,
requestOrMethod.headers.get("X-Nonce") ?? undefined, signedAt ? new Date(Number(signedAt) * 1000) : undefined,
); );
request.headers.set("X-Nonce", headers.get("X-Nonce") ?? "");
request.headers.set( request.headers.set(
"X-Signature", "Versia-Signed-At",
headers.get("X-Signature") ?? "", headers.get("Versia-Signed-At") ?? "",
);
request.headers.set(
"Versia-Signature",
headers.get("Versia-Signature") ?? "",
); );
return { request, signedString }; return { request, signedString };
@ -294,9 +298,7 @@ export class SignatureConstructor {
); );
} }
const finalNonce = const finalTimestamp = timestamp || new Date();
nonce ||
uint8ArrayToBase64(crypto.getRandomValues(new Uint8Array(16)));
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
@ -305,7 +307,7 @@ export class SignatureConstructor {
const signedString = `${requestOrMethod.toLowerCase()} ${encodeURIComponent( const signedString = `${requestOrMethod.toLowerCase()} ${encodeURIComponent(
url.pathname, url.pathname,
)} ${finalNonce} ${arrayBufferToBase64(digest)}`; )} ${finalTimestamp.getTime() / 1000} ${arrayBufferToBase64(digest)}`;
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
"Ed25519", "Ed25519",
@ -315,9 +317,12 @@ export class SignatureConstructor {
const signatureBase64 = arrayBufferToBase64(signature); const signatureBase64 = arrayBufferToBase64(signature);
headers.set("X-Nonce", finalNonce); headers.set(
headers.set("X-Signature", signatureBase64); "Versia-Signed-At",
headers.set("X-Signed-By", this.authorUri.toString()); String(finalTimestamp.getTime() / 1000),
);
headers.set("Versia-Signature", signatureBase64);
headers.set("Versia-Signed-By", this.authorUri.toString());
return { return {
headers, headers,

View file

@ -4,7 +4,10 @@ import type {
Follow, Follow,
FollowAccept, FollowAccept,
FollowReject, FollowReject,
Group, GroupExtensionSubscribe,
GroupExtensionSubscribeAccept,
GroupExtensionSubscribeReject,
GroupExtensionUnsubscribe,
InstanceMetadata, InstanceMetadata,
LikeExtension, LikeExtension,
Note, Note,
@ -28,7 +31,18 @@ type ParserCallbacks<T> = {
"pub.versia:likes/Dislike": (dislike: DislikeExtension) => MaybePromise<T>; "pub.versia:likes/Dislike": (dislike: DislikeExtension) => MaybePromise<T>;
delete: (undo: Delete) => MaybePromise<T>; delete: (undo: Delete) => MaybePromise<T>;
instanceMetadata: (instanceMetadata: InstanceMetadata) => MaybePromise<T>; instanceMetadata: (instanceMetadata: InstanceMetadata) => MaybePromise<T>;
group: (group: Group) => MaybePromise<T>; "pub.versia:groups/Subscribe": (
groupSubscribe: GroupExtensionSubscribe,
) => MaybePromise<T>;
"pub.versia:groups/SubscribeAccept": (
groupSubscribeAccept: GroupExtensionSubscribeAccept,
) => MaybePromise<T>;
"pub.versia:groups/SubscribeReject": (
groupSubscribeReject: GroupExtensionSubscribeReject,
) => MaybePromise<T>;
"pub.versia:groups/Unsubscribe": (
groupUnsubscribe: GroupExtensionUnsubscribe,
) => MaybePromise<T>;
"pub.versia:reactions/Reaction": ( "pub.versia:reactions/Reaction": (
reaction: ReactionExtension, reaction: ReactionExtension,
) => MaybePromise<T>; ) => MaybePromise<T>;
@ -182,11 +196,52 @@ export class RequestParserHandler {
break; break;
} }
case "Group": { case "pub.versia:groups/Subscribe": {
const group = await this.validator.Group(this.body); const groupSubscribe = await this.validator.GroupSubscribe(
this.body,
);
if (callbacks.group) { if (callbacks["pub.versia:groups/Subscribe"]) {
return await callbacks.group(group); return await callbacks["pub.versia:groups/Subscribe"](
groupSubscribe,
);
}
break;
}
case "pub.versia:groups/SubscribeAccept": {
const groupSubscribeAccept =
await this.validator.GroupSubscribeAccept(this.body);
if (callbacks["pub.versia:groups/SubscribeAccept"]) {
return await callbacks["pub.versia:groups/SubscribeAccept"](
groupSubscribeAccept,
);
}
break;
}
case "pub.versia:groups/SubscribeReject": {
const groupSubscribeReject =
await this.validator.GroupSubscribeReject(this.body);
if (callbacks["pub.versia:groups/SubscribeReject"]) {
return await callbacks["pub.versia:groups/SubscribeReject"](
groupSubscribeReject,
);
}
break;
}
case "pub.versia:groups/Unsubscribe": {
const groupUnsubscribe = await this.validator.GroupUnsubscribe(
this.body,
);
if (callbacks["pub.versia:groups/Unsubscribe"]) {
return await callbacks["pub.versia:groups/Unsubscribe"](
groupUnsubscribe,
);
} }
break; break;

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@versia/federation", "name": "@versia/federation",
"version": "0.1.4", "version": "0.2.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./types": "./types.ts", "./types": "./types.ts",

View file

@ -1,7 +1,7 @@
{ {
"name": "@versia/federation", "name": "@versia/federation",
"displayName": "Versia Federation", "displayName": "Versia Federation",
"version": "0.1.4", "version": "0.2.0",
"author": { "author": {
"email": "jesse.wierzbinski@lysand.org", "email": "jesse.wierzbinski@lysand.org",
"name": "Jesse Wierzbinski (CPlusPatch)", "name": "Jesse Wierzbinski (CPlusPatch)",

View file

@ -11,7 +11,7 @@ export {
FollowAcceptSchema as FollowAccept, FollowAcceptSchema as FollowAccept,
FollowRejectSchema as FollowReject, FollowRejectSchema as FollowReject,
FollowSchema as Follow, FollowSchema as Follow,
GroupSchema as Group, URICollectionSchema as URICollection,
InstanceMetadataSchema as InstanceMetadata, InstanceMetadataSchema as InstanceMetadata,
NoteSchema as Note, NoteSchema as Note,
UnfollowSchema as Unfollow, UnfollowSchema as Unfollow,
@ -20,6 +20,13 @@ export {
export { ContentFormatSchema as ContentFormat } from "./schemas/content_format.ts"; export { ContentFormatSchema as ContentFormat } from "./schemas/content_format.ts";
export { ExtensionPropertySchema as EntityExtensionProperty } from "./schemas/extensions.ts"; export { ExtensionPropertySchema as EntityExtensionProperty } from "./schemas/extensions.ts";
export { CustomEmojiExtensionSchema as CustomEmojiExtension } from "./schemas/extensions/custom_emojis.ts"; export { CustomEmojiExtensionSchema as CustomEmojiExtension } from "./schemas/extensions/custom_emojis.ts";
export {
GroupSchema as GroupExtension,
GroupSubscribeSchema as GroupExtensionSubscribe,
GroupSubscribeAcceptSchema as GroupExtensionSubscribeAccept,
GroupSubscribeRejectSchema as GroupExtensionSubscribeReject,
GroupUnsubscribeSchema as GroupExtensionUnsubscribe,
} from "./schemas/extensions/groups.ts";
export { export {
DislikeSchema as DislikeExtension, DislikeSchema as DislikeExtension,
LikeSchema as LikeExtension, LikeSchema as LikeExtension,

View file

@ -10,6 +10,8 @@ import { extensionRegex, isISOString, semverRegex } from "./regex.ts";
export const EntitySchema = z export const EntitySchema = z
.object({ .object({
// biome-ignore lint/style/useNamingConvention:
$schema: z.string().url().optional().nullable(),
id: z.string().max(512), id: z.string().max(512),
created_at: z created_at: z
.string() .string()
@ -37,6 +39,17 @@ export const NoteSchema = EntitySchema.extend({
.optional() .optional()
.nullable(), .nullable(),
content: TextOnlyContentFormatSchema.optional().nullable(), content: TextOnlyContentFormatSchema.optional().nullable(),
collections: z.object({
replies: z.string().url(),
quotes: z.string().url(),
"pub.versia:reactions/Reactions": z
.string()
.url()
.optional()
.nullable(),
"pub.versia:likes/Likes": z.string().url().optional().nullable(),
"pub.versia:likes/Dislikes": z.string().url().optional().nullable(),
}),
device: z device: z
.object({ .object({
name: z.string(), name: z.string(),
@ -72,13 +85,6 @@ export const NoteSchema = EntitySchema.extend({
replies_to: z.string().url().optional().nullable(), replies_to: z.string().url().optional().nullable(),
subject: z.string().optional().nullable(), subject: z.string().optional().nullable(),
extensions: ExtensionPropertySchema.extend({ extensions: ExtensionPropertySchema.extend({
"pub.versia:reactions": z
.object({
reactions: z.string().url(),
})
.strict()
.optional()
.nullable(),
"pub.versia:polls": z "pub.versia:polls": z
.object({ .object({
options: z.array(TextOnlyContentFormatSchema), options: z.array(TextOnlyContentFormatSchema),
@ -119,6 +125,10 @@ export const CollectionSchema = z.object({
items: z.array(z.any()), items: z.array(z.any()),
}); });
export const URICollectionSchema = CollectionSchema.extend({
items: z.array(z.string().url()),
});
export const PublicKeyDataSchema = z export const PublicKeyDataSchema = z
.object({ .object({
key: z.string().min(1), key: z.string().min(1),
@ -147,8 +157,8 @@ export const UserSchema = EntitySchema.extend({
.string() .string()
.min(1) .min(1)
.regex( .regex(
/^[a-z0-9_-]+$/, /^[a-zA-Z0-9_-]+$/,
"must be lowercase, alphanumeric, and may contain _ or -", "must be alphanumeric, and may contain _ or -",
), ),
header: ImageOnlyContentFormatSchema.optional().nullable(), header: ImageOnlyContentFormatSchema.optional().nullable(),
public_key: PublicKeyDataSchema, public_key: PublicKeyDataSchema,
@ -208,14 +218,6 @@ export const UnfollowSchema = EntitySchema.extend({
followee: z.string().url(), followee: z.string().url(),
}); });
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({ export const InstanceMetadataSchema = EntitySchema.extend({
type: z.literal("InstanceMetadata"), type: z.literal("InstanceMetadata"),
id: z.null().optional(), id: z.null().optional(),

View file

@ -0,0 +1,40 @@
import { z } from "zod";
import { EntitySchema } from "../base.ts";
import { TextOnlyContentFormatSchema } from "../content_format.ts";
export const GroupSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/Group"),
name: TextOnlyContentFormatSchema.optional().nullable(),
description: TextOnlyContentFormatSchema.optional().nullable(),
open: z.boolean().optional().nullable(),
members: z.string().url(),
notes: z.string().url().optional().nullable(),
});
export const GroupSubscribeSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/Subscribe"),
uri: z.null().optional(),
subscriber: z.string().url(),
group: z.string().url(),
});
export const GroupUnsubscribeSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/Unsubscribe"),
uri: z.null().optional(),
subscriber: z.string().url(),
group: z.string().url(),
});
export const GroupSubscribeAcceptSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/SubscribeAccept"),
uri: z.null().optional(),
subscriber: z.string().url(),
group: z.string().url(),
});
export const GroupSubscribeRejectSchema = EntitySchema.extend({
type: z.literal("pub.versia:groups/SubscribeReject"),
uri: z.null().optional(),
subscriber: z.string().url(),
group: z.string().url(),
});

View file

@ -10,7 +10,7 @@ import {
AudioOnlyContentFormatSchema, AudioOnlyContentFormatSchema,
ImageOnlyContentFormatSchema, ImageOnlyContentFormatSchema,
} from "../content_format.ts"; } from "../content_format.ts";
import { isISOString } from "../regex.ts"; import { ianaTimezoneRegex, isISOString } from "../regex.ts";
/** /**
* @description Vanity extension entity * @description Vanity extension entity
@ -103,5 +103,10 @@ export const VanityExtensionSchema = z
.nullable(), .nullable(),
location: z.string().optional().nullable(), location: z.string().optional().nullable(),
aliases: z.array(z.string().url()).optional().nullable(), aliases: z.array(z.string().url()).optional().nullable(),
timezone: z
.string()
.regex(ianaTimezoneRegex, "must be a valid IANA timezone")
.optional()
.nullable(),
}) })
.strict(); .strict();

View file

@ -66,3 +66,5 @@ export const isISOString = (val: string | Date) => {
const d = new Date(val); const d = new Date(val);
return !Number.isNaN(d.valueOf()); return !Number.isNaN(d.valueOf());
}; };
export const ianaTimezoneRegex = /^(?:[A-Za-z]+(?:\/[A-Za-z_]+)+|UTC)$/;

View file

@ -11,15 +11,22 @@ import type {
FollowAcceptSchema, FollowAcceptSchema,
FollowRejectSchema, FollowRejectSchema,
FollowSchema, FollowSchema,
GroupSchema,
InstanceMetadataSchema, InstanceMetadataSchema,
NoteSchema, NoteSchema,
URICollectionSchema,
UnfollowSchema, UnfollowSchema,
UserSchema, UserSchema,
} from "./schemas/base.ts"; } from "./schemas/base.ts";
import type { ContentFormatSchema } from "./schemas/content_format.ts"; import type { ContentFormatSchema } from "./schemas/content_format.ts";
import type { ExtensionPropertySchema } from "./schemas/extensions.ts"; import type { ExtensionPropertySchema } from "./schemas/extensions.ts";
import type { CustomEmojiExtensionSchema } from "./schemas/extensions/custom_emojis.ts"; import type { CustomEmojiExtensionSchema } from "./schemas/extensions/custom_emojis.ts";
import type {
GroupSchema,
GroupSubscribeAcceptSchema,
GroupSubscribeRejectSchema,
GroupSubscribeSchema,
GroupUnsubscribeSchema,
} from "./schemas/extensions/groups.ts";
import type { DislikeSchema, LikeSchema } from "./schemas/extensions/likes.ts"; import type { DislikeSchema, LikeSchema } from "./schemas/extensions/likes.ts";
import type { VoteSchema } from "./schemas/extensions/polls.ts"; import type { VoteSchema } from "./schemas/extensions/polls.ts";
import type { ReactionSchema } from "./schemas/extensions/reactions.ts"; import type { ReactionSchema } from "./schemas/extensions/reactions.ts";
@ -33,6 +40,7 @@ type InferType<T extends AnyZod> = z.infer<T>;
export type Note = InferType<typeof NoteSchema>; export type Note = InferType<typeof NoteSchema>;
export type Collection = InferType<typeof CollectionSchema>; export type Collection = InferType<typeof CollectionSchema>;
export type URICollection = InferType<typeof URICollectionSchema>;
export type EntityExtensionProperty = InferType<typeof ExtensionPropertySchema>; export type EntityExtensionProperty = 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>;
@ -43,9 +51,19 @@ export type ContentFormat = InferType<typeof ContentFormatSchema>;
export type CustomEmojiExtension = InferType<typeof CustomEmojiExtensionSchema>; export type CustomEmojiExtension = InferType<typeof CustomEmojiExtensionSchema>;
export type Entity = InferType<typeof EntitySchema>; export type Entity = InferType<typeof EntitySchema>;
export type Delete = InferType<typeof DeleteSchema>; export type Delete = InferType<typeof DeleteSchema>;
export type Group = InferType<typeof GroupSchema>;
export type InstanceMetadata = InferType<typeof InstanceMetadataSchema>; export type InstanceMetadata = InferType<typeof InstanceMetadataSchema>;
export type Unfollow = InferType<typeof UnfollowSchema>; export type Unfollow = InferType<typeof UnfollowSchema>;
export type GroupExtension = InferType<typeof GroupSchema>;
export type GroupExtensionSubscribe = InferType<typeof GroupSubscribeSchema>;
export type GroupExtensionSubscribeAccept = InferType<
typeof GroupSubscribeAcceptSchema
>;
export type GroupExtensionSubscribeReject = InferType<
typeof GroupSubscribeRejectSchema
>;
export type GroupExtensionUnsubscribe = InferType<
typeof GroupUnsubscribeSchema
>;
export type LikeExtension = InferType<typeof LikeSchema>; export type LikeExtension = InferType<typeof LikeSchema>;
export type DislikeExtension = InferType<typeof DislikeSchema>; export type DislikeExtension = InferType<typeof DislikeSchema>;
export type PollVoteExtension = InferType<typeof VoteSchema>; export type PollVoteExtension = InferType<typeof VoteSchema>;

View file

@ -7,15 +7,22 @@ import {
FollowAcceptSchema, FollowAcceptSchema,
FollowRejectSchema, FollowRejectSchema,
FollowSchema, FollowSchema,
GroupSchema,
InstanceMetadataSchema, InstanceMetadataSchema,
NoteSchema, NoteSchema,
URICollectionSchema,
UnfollowSchema, UnfollowSchema,
UserSchema, UserSchema,
} from "./schemas/base.ts"; } from "./schemas/base.ts";
import { ContentFormatSchema } from "./schemas/content_format.ts"; import { ContentFormatSchema } from "./schemas/content_format.ts";
import { ExtensionPropertySchema } from "./schemas/extensions.ts"; import { ExtensionPropertySchema } from "./schemas/extensions.ts";
import { CustomEmojiExtensionSchema } from "./schemas/extensions/custom_emojis.ts"; import { CustomEmojiExtensionSchema } from "./schemas/extensions/custom_emojis.ts";
import {
GroupSchema,
GroupSubscribeAcceptSchema,
GroupSubscribeRejectSchema,
GroupSubscribeSchema,
GroupUnsubscribeSchema,
} from "./schemas/extensions/groups.ts";
import { DislikeSchema, LikeSchema } from "./schemas/extensions/likes.ts"; import { DislikeSchema, LikeSchema } from "./schemas/extensions/likes.ts";
import { VoteSchema } from "./schemas/extensions/polls.ts"; import { VoteSchema } from "./schemas/extensions/polls.ts";
import { ReactionSchema } from "./schemas/extensions/reactions.ts"; import { ReactionSchema } from "./schemas/extensions/reactions.ts";
@ -32,18 +39,22 @@ import type {
Follow, Follow,
FollowAccept, FollowAccept,
FollowReject, FollowReject,
Group, GroupExtension,
GroupExtensionSubscribe,
GroupExtensionSubscribeAccept,
GroupExtensionSubscribeReject,
GroupExtensionUnsubscribe,
InstanceMetadata, InstanceMetadata,
LikeExtension, LikeExtension,
Note, Note,
PollVoteExtension, PollVoteExtension,
ReactionExtension, ReactionExtension,
ShareExtension, ShareExtension,
URICollection,
Unfollow, Unfollow,
User, User,
VanityExtension, VanityExtension,
} from "./types.ts"; } from "./types.ts";
// biome-ignore lint/suspicious/noExplicitAny: Used only as a base type // biome-ignore lint/suspicious/noExplicitAny: Used only as a base type
type AnyZod = z.ZodType<any, any, any>; type AnyZod = z.ZodType<any, any, any>;
@ -110,6 +121,15 @@ export class EntityValidator {
return this.validate(CollectionSchema, data); return this.validate(CollectionSchema, data);
} }
/**
* Validates a URICollection entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public URICollection(data: unknown): Promise<URICollection> {
return this.validate(URICollectionSchema, data);
}
/** /**
* Validates a VanityExtension entity. * Validates a VanityExtension entity.
* @param data - The data to validate * @param data - The data to validate
@ -207,10 +227,50 @@ export class EntityValidator {
* @param data - The data to validate * @param data - The data to validate
* @returns A promise that resolves to the validated data. * @returns A promise that resolves to the validated data.
*/ */
public Group(data: unknown): Promise<Group> { public Group(data: unknown): Promise<GroupExtension> {
return this.validate(GroupSchema, data); return this.validate(GroupSchema, data);
} }
/**
* Validates a GroupSubscribe entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public GroupSubscribe(data: unknown): Promise<GroupExtensionSubscribe> {
return this.validate(GroupSubscribeSchema, data);
}
/**
* Validates a GroupSubscribeAccept entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public GroupSubscribeAccept(
data: unknown,
): Promise<GroupExtensionSubscribeAccept> {
return this.validate(GroupSubscribeAcceptSchema, data);
}
/**
* Validates a GroupSubscribeReject entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public GroupSubscribeReject(
data: unknown,
): Promise<GroupExtensionSubscribeReject> {
return this.validate(GroupSubscribeRejectSchema, data);
}
/**
* Validates a GroupUnsubscribe entity.
* @param data - The data to validate
* @returns A promise that resolves to the validated data.
*/
public GroupUnsubscribe(data: unknown): Promise<GroupExtensionUnsubscribe> {
return this.validate(GroupUnsubscribeSchema, data);
}
/** /**
* Validates an InstanceMetadata entity. * Validates an InstanceMetadata entity.
* @param data - The data to validate * @param data - The data to validate