diff --git a/bun.lockb b/bun.lockb index 7635f41b..94e2d483 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/lysand-types/index.ts b/packages/lysand-types/index.ts deleted file mode 100644 index 7cc7b9ae..00000000 --- a/packages/lysand-types/index.ts +++ /dev/null @@ -1,251 +0,0 @@ -export interface ContentFormat { - [contentType: string]: { - content: string; - description?: string; - size?: number; - hash?: { - md5?: string; - sha1?: string; - sha256?: string; - sha512?: string; - [key: string]: string | undefined; - }; - blurhash?: string; - fps?: number; - width?: number; - height?: number; - duration?: number; - }; -} - -export interface Emoji { - name: string; - alt?: string; - url: ContentFormat; -} - -export interface Collections { - first: string; - last: string; - total_count: number; - author: string; - next?: string; - prev?: string; - items: T[]; -} - -export interface ActorPublicKeyData { - public_key: string; - actor: string; -} - -export interface Entity { - id: string; - created_at: string; - uri: string; - type: string; - extensions?: { - "org.lysand:custom_emojis"?: { - emojis: Emoji[]; - }; - [key: string]: object | undefined; - }; -} - -export interface Publication extends Entity { - type: "Note" | "Patch"; - author: string; - content?: ContentFormat; - attachments?: ContentFormat[]; - replies_to?: string; - quotes?: string; - mentions?: string[]; - subject?: string; - is_sensitive?: boolean; - visibility: Visibility; - extensions?: Entity["extensions"] & { - "org.lysand:reactions"?: { - reactions: string; - }; - "org.lysand:polls"?: { - poll: { - options: ContentFormat[]; - votes: number[]; - multiple_choice?: boolean; - expires_at: string; - }; - }; - }; -} - -export enum Visibility { - Public = "public", - Unlisted = "unlisted", - Followers = "followers", - Direct = "direct", -} - -export interface Note extends Publication { - type: "Note"; -} - -export interface Patch extends Publication { - type: "Patch"; - patched_id: string; - patched_at: string; -} - -export interface User extends Entity { - type: "User"; - id: string; - uri: string; - created_at: string; - display_name?: string; - username: string; - avatar?: ContentFormat; - header?: ContentFormat; - indexable: boolean; - public_key: ActorPublicKeyData; - bio?: ContentFormat; - fields?: Field[]; - featured: string; - followers: string; - following: string; - likes: string; - dislikes: string; - inbox: string; - outbox: string; - extensions?: Entity["extensions"] & { - "org.lysand:vanity"?: VanityExtension; - }; -} - -export interface Field { - key: ContentFormat; - value: ContentFormat; -} - -export interface Action extends Entity { - type: - | "Like" - | "Dislike" - | "Follow" - | "FollowAccept" - | "FollowReject" - | "Announce" - | "Undo"; - author: string; -} - -export interface Like extends Action { - type: "Like"; - object: string; -} - -export interface Undo extends Action { - type: "Undo"; - object: string; -} - -export interface Dislike extends Action { - type: "Dislike"; - object: string; -} - -export interface Follow extends Action { - type: "Follow"; - followee: string; -} - -export interface FollowAccept extends Action { - type: "FollowAccept"; - follower: string; -} - -export interface FollowReject extends Action { - type: "FollowReject"; - follower: string; -} - -export interface Announce extends Action { - type: "Announce"; - object: string; -} - -// Specific extension types will extend from this -export interface Extension extends Entity { - type: "Extension"; - extension_type: string; -} - -export interface Reaction extends Extension { - extension_type: "org.lysand:reactions/Reaction"; - object: string; - content: string; -} - -export interface Poll extends Extension { - extension_type: "org.lysand:polls/Poll"; - options: ContentFormat[]; - votes: number[]; - multiple_choice?: boolean; - expires_at: string; -} - -export interface Vote extends Extension { - extension_type: "org.lysand:polls/Vote"; - poll: string; - option: number; -} - -export interface VoteResult extends Extension { - extension_type: "org.lysand:polls/VoteResult"; - poll: string; - votes: number[]; -} - -export interface Report extends Extension { - extension_type: "org.lysand:reports/Report"; - objects: string[]; - reason: string; - comment?: string; -} - -export interface VanityExtension { - avatar_overlay?: ContentFormat; - avatar_mask?: ContentFormat; - background?: ContentFormat; - audio?: ContentFormat; - pronouns?: { - [language: string]: (ShortPronoun | LongPronoun)[]; - }; - birthday?: string; - location?: string; - activitypub?: string; -} - -export type ShortPronoun = string; - -export interface LongPronoun { - subject: string; - object: string; - dependent_possessive: string; - independent_possessive: string; - reflexive: string; -} - -export interface ServerMetadata { - type: "ServerMetadata"; - name: string; - version: string; - description?: string; - website?: string; - moderators?: string[]; - admins?: string[]; - logo?: ContentFormat; - banner?: ContentFormat; - supported_extensions: string[]; - extensions?: { - [key: string]: object | undefined; - }; -} diff --git a/packages/lysand-types/package.json b/packages/lysand-types/package.json deleted file mode 100644 index c71d6621..00000000 --- a/packages/lysand-types/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "lysand-types", - "version": "2.0.0", - "description": "A collection of types for the Lysand protocol", - "main": "index.ts" -} diff --git a/packages/lysand-utils/index.ts b/packages/lysand-utils/index.ts deleted file mode 100644 index b41329ab..00000000 --- a/packages/lysand-utils/index.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type * as Lysand from "lysand-types"; -import { fromZodError } from "zod-validation-error"; -import { schemas } from "./schemas"; - -const types = [ - "Note", - "User", - "Reaction", - "Poll", - "Vote", - "VoteResult", - "Report", - "ServerMetadata", - "Like", - "Dislike", - "Follow", - "FollowAccept", - "FollowReject", - "Announce", - "Undo", -]; - -/** - * Validates an incoming Lysand object using Zod, and returns the object if it is valid. - */ -export class EntityValidator { - constructor(private entity: Lysand.Entity) {} - - /** - * Validates the entity. - */ - validate() { - // Check if type is valid - if (!this.entity.type) { - throw new Error("Entity type is required"); - } - - const schema = this.matchSchema(this.getType()); - - const output = schema.safeParse(this.entity); - - if (!output.success) { - throw fromZodError(output.error); - } - - return output.data as ExpectedType; - } - - getType() { - // Check if type is valid, return TypeScript type - if (!this.entity.type) { - throw new Error("Entity type is required"); - } - - if (!types.includes(this.entity.type)) { - throw new Error(`Unknown entity type: ${this.entity.type}`); - } - - return this.entity.type as (typeof types)[number]; - } - - matchSchema(type: string) { - switch (type) { - case "Note": - return schemas.Note; - case "User": - return schemas.User; - case "Reaction": - return schemas.Reaction; - case "Poll": - return schemas.Poll; - case "Vote": - return schemas.Vote; - case "VoteResult": - return schemas.VoteResult; - case "Report": - return schemas.Report; - case "ServerMetadata": - return schemas.ServerMetadata; - case "Like": - return schemas.Like; - case "Dislike": - return schemas.Dislike; - case "Follow": - return schemas.Follow; - case "FollowAccept": - return schemas.FollowAccept; - case "FollowReject": - return schemas.FollowReject; - case "Announce": - return schemas.Announce; - case "Undo": - return schemas.Undo; - default: - throw new Error(`Unknown entity type: ${type}`); - } - } -} - -export class SignatureValidator { - constructor( - private public_key: CryptoKey, - private signature: string, - private date: string, - private method: string, - private url: URL, - private body: string, - ) {} - - static async fromStringKey( - public_key: string, - signature: string, - date: string, - method: string, - url: URL, - body: string, - ) { - return new SignatureValidator( - await crypto.subtle.importKey( - "spki", - Buffer.from(public_key, "base64"), - "Ed25519", - false, - ["verify"], - ), - signature, - date, - method, - url, - body, - ); - } - async validate() { - const signature = this.signature - .split("signature=")[1] - .replace(/"/g, ""); - - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(this.body), - ); - - const expectedSignedString = - `(request-target): ${this.method.toLowerCase()} ${ - this.url.pathname - }\n` + - `host: ${this.url.host}\n` + - `date: ${this.date}\n` + - `digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString( - "base64", - )}\n`; - - // Check if signed string is valid - const isValid = await crypto.subtle.verify( - "Ed25519", - this.public_key, - Buffer.from(signature, "base64"), - new TextEncoder().encode(expectedSignedString), - ); - - return isValid; - } -} - -export class SignatureConstructor { - constructor( - private private_key: CryptoKey, - private url: URL, - private authorUri: URL, - ) {} - - static async fromStringKey(private_key: string, url: URL, authorUri: URL) { - return new SignatureConstructor( - await crypto.subtle.importKey( - "pkcs8", - Buffer.from(private_key, "base64"), - "Ed25519", - false, - ["sign"], - ), - url, - authorUri, - ); - } - - async sign(method: string, body: string) { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(body), - ); - - const date = new Date(); - - const signature = await crypto.subtle.sign( - "Ed25519", - this.private_key, - new TextEncoder().encode( - `(request-target): ${method.toLowerCase()} ${ - this.url.pathname - }\n` + - `host: ${this.url.host}\n` + - `date: ${date.toISOString()}\n` + - `digest: SHA-256=${Buffer.from( - new Uint8Array(digest), - ).toString("base64")}\n`, - ), - ); - - const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString( - "base64", - ); - - return { - date: date.toISOString(), - signature: `keyId="${this.authorUri.toString()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, - }; - } -} - -/** - * Extends native fetch with object signing - * Make sure to format your JSON in Canonical JSON format! - * @param url URL to fetch - * @param options Standard Web Fetch API options - * @param privateKey Author private key in base64 - * @param authorUri Author URI - * @param baseUrl Base URL of this server - * @returns Fetch response - */ -export const signedFetch = async ( - url: string | URL, - options: RequestInit, - privateKey: string, - authorUri: string | URL, - baseUrl: string | URL, -) => { - const urlObj = new URL(url); - const authorUriObj = new URL(authorUri); - - const signature = await SignatureConstructor.fromStringKey( - privateKey, - urlObj, - authorUriObj, - ); - - const { date, signature: signatureHeader } = await signature.sign( - options.method ?? "GET", - options.body?.toString() || "", - ); - - return fetch(url, { - ...options, - headers: { - Date: date, - Origin: new URL(baseUrl).origin, - Signature: signatureHeader, - "Content-Type": "application/json; charset=utf-8", - Accept: "application/json", - ...options.headers, - }, - }); -}; - -// Export all schemas as a single object -export default { - Note: schemas.Note, - User: schemas.User, - Reaction: schemas.Reaction, - Poll: schemas.Poll, - Vote: schemas.Vote, - VoteResult: schemas.VoteResult, - Report: schemas.Report, - ServerMetadata: schemas.ServerMetadata, - Like: schemas.Like, - Dislike: schemas.Dislike, - Follow: schemas.Follow, - FollowAccept: schemas.FollowAccept, - FollowReject: schemas.FollowReject, - Announce: schemas.Announce, - Undo: schemas.Undo, - Entity: schemas.Entity, -}; diff --git a/packages/lysand-utils/package.json b/packages/lysand-utils/package.json deleted file mode 100644 index a55e8787..00000000 --- a/packages/lysand-utils/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "lysand-utils", - "version": "0.0.0", - "main": "index.ts", - "dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.2.0" } -} diff --git a/packages/lysand-utils/schemas.ts b/packages/lysand-utils/schemas.ts deleted file mode 100644 index 88c9f218..00000000 --- a/packages/lysand-utils/schemas.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { emojiValidator } from "@api"; -import { - charIn, - createRegExp, - digit, - exactly, - letter, - oneOrMore, -} from "magic-regexp"; -import { types } from "mime-types"; -import { z } from "zod"; - -const ContentFormat = z.record( - z.enum(Object.values(types) as [string, ...string[]]), - z.object({ - content: z.string(), - description: z.string().optional(), - size: z.number().int().nonnegative().optional(), - hash: z.record(z.string(), z.string()).optional(), - blurhash: z.string().optional(), - fps: z.number().int().nonnegative().optional(), - width: z.number().int().nonnegative().optional(), - height: z.number().int().nonnegative().optional(), - duration: z.number().nonnegative().optional(), - }), -); - -const Entity = z.object({ - id: z.string().uuid(), - created_at: z.string(), - uri: z.string().url(), - type: z.string(), - extensions: z.object({ - "org.lysand:custom_emojis": z.object({ - emojis: z.array( - z.object({ - name: z.string().regex(emojiValidator), - url: ContentFormat, - }), - ), - }), - }), -}); - -const Visibility = z.enum(["public", "unlisted", "private", "direct"]); - -const Publication = Entity.extend({ - type: z.enum(["Note", "Patch"]), - author: z.string().url(), - content: ContentFormat.optional(), - attachments: z.array(ContentFormat).optional(), - replies_to: z.string().url().optional(), - quotes: z.string().url().optional(), - mentions: z.array(z.string().url()).optional(), - subject: z.string().optional(), - is_sensitive: z.boolean().optional(), - visibility: Visibility, - extensions: Entity.shape.extensions.extend({ - "org.lysand:reactions": z - .object({ - reactions: z.string(), - }) - .optional(), - "org.lysand:polls": z - .object({ - poll: z.object({ - options: z.array(ContentFormat), - votes: z.array(z.number().int().nonnegative()), - multiple_choice: z.boolean().optional(), - expires_at: z.string(), - }), - }) - .optional(), - }), -}); - -const Note = Publication.extend({ - type: z.literal("Note"), -}); - -const Patch = Publication.extend({ - type: z.literal("Patch"), - patched_id: z.string().uuid(), - patched_at: z.string(), -}); - -const ActorPublicKeyData = z.object({ - public_key: z.string(), - actor: z.string().url(), -}); - -const VanityExtension = z.object({ - avatar_overlay: ContentFormat.optional(), - avatar_mask: ContentFormat.optional(), - background: ContentFormat.optional(), - audio: ContentFormat.optional(), - 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(), - ]), - ), - ), - birthday: z.string().optional(), - location: z.string().optional(), - activitypub: z.string().optional(), -}); - -const User = Entity.extend({ - type: z.literal("User"), - display_name: z.string().optional(), - username: z.string(), - avatar: ContentFormat.optional(), - header: ContentFormat.optional(), - indexable: z.boolean(), - public_key: ActorPublicKeyData, - bio: ContentFormat.optional(), - fields: z - .array( - z.object({ - name: ContentFormat, - value: ContentFormat, - }), - ) - .optional(), - featured: z.string().url(), - followers: z.string().url(), - following: z.string().url(), - likes: z.string().url(), - dislikes: z.string().url(), - inbox: z.string().url(), - outbox: z.string().url(), - extensions: Entity.shape.extensions.extend({ - "org.lysand:vanity": VanityExtension.optional(), - }), -}); - -const Action = Entity.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"), - ]), - author: z.string().url(), -}); - -const Like = Action.extend({ - type: z.literal("Like"), - object: z.string().url(), -}); - -const Undo = Action.extend({ - type: z.literal("Undo"), - object: z.string().url(), -}); - -const Dislike = Action.extend({ - type: z.literal("Dislike"), - object: z.string().url(), -}); - -const Follow = Action.extend({ - type: z.literal("Follow"), - followee: z.string().url(), -}); - -const FollowAccept = Action.extend({ - type: z.literal("FollowAccept"), - follower: z.string().url(), -}); - -const FollowReject = Action.extend({ - type: z.literal("FollowReject"), - follower: z.string().url(), -}); - -const Announce = Action.extend({ - type: z.literal("Announce"), - object: z.string().url(), -}); - -const Extension = Entity.extend({ - type: z.literal("Extension"), - extension_type: z.string().regex( - 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("_-")))), - exactly("/"), - oneOrMore(exactly(letter.or(digit).or(charIn("_-")))), - ), - ), - "extension_type must be in the format ':extension_name/Extension_type', e.g. 'org.lysand:reactions/Reaction'. Notably, only the type can have uppercase letters.", - ), -}); - -const Reaction = Extension.extend({ - extension_type: z.literal("org.lysand:reactions/Reaction"), - object: z.string().url(), - content: z.string(), -}); - -const Poll = Extension.extend({ - extension_type: z.literal("org.lysand:polls/Poll"), - options: z.array(ContentFormat), - votes: z.array(z.number().int().nonnegative()), - multiple_choice: z.boolean().optional(), - expires_at: z.string(), -}); - -const Vote = Extension.extend({ - extension_type: z.literal("org.lysand:polls/Vote"), - poll: z.string().url(), - option: z.number(), -}); - -const VoteResult = Extension.extend({ - extension_type: z.literal("org.lysand:polls/VoteResult"), - poll: z.string().url(), - votes: z.array(z.number().int().nonnegative()), -}); - -const Report = Extension.extend({ - extension_type: z.literal("org.lysand:reports/Report"), - objects: z.array(z.string().url()), - reason: z.string(), - comment: z.string().optional(), -}); - -const ServerMetadata = Entity.extend({ - type: z.literal("ServerMetadata"), - name: z.string(), - version: z.string(), - description: z.string().optional(), - website: z.string().optional(), - moderators: z.array(z.string()).optional(), - admins: z.array(z.string()).optional(), - logo: ContentFormat.optional(), - banner: ContentFormat.optional(), - supported_extensions: z.array(z.string()), - extensions: z.record(z.string(), z.any()).optional(), -}); - -export const schemas = { - Entity, - ContentFormat, - Visibility, - Publication, - Note, - Patch, - ActorPublicKeyData, - VanityExtension, - User, - Action, - Like, - Undo, - Dislike, - Follow, - FollowAccept, - FollowReject, - Announce, - Extension, - Reaction, - Poll, - Vote, - VoteResult, - Report, - ServerMetadata, -};