diff --git a/.vscode/settings.json b/.vscode/settings.json index 2679899..4b62e3e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "conventionalCommits.scopes": ["docs"] + "conventionalCommits.scopes": ["docs"] } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d8d8db0 --- /dev/null +++ b/biome.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", + "organizeImports": { + "enabled": true, + "ignore": ["node_modules", "dist"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "ignore": ["node_modules", "dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "ignore": ["node_modules", "dist"] + } +} diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..f08fc94 --- /dev/null +++ b/build.ts @@ -0,0 +1,17 @@ +import dts from "bun-plugin-dts"; +import ora from "ora"; + +const spinner = ora("Building...").start(); + +await Bun.build({ + entrypoints: ["federation/index.ts"], + outdir: "federation/dist", + format: "esm", + minify: true, + sourcemap: "external", + splitting: true, + target: "browser", + plugins: [dts()], +}); + +spinner.succeed("Built federation module"); diff --git a/bun.lockb b/bun.lockb index 363a67f..287f9ef 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/federation/biome.json b/federation/biome.json deleted file mode 100644 index 536d532..0000000 --- a/federation/biome.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", - "organizeImports": { - "enabled": true, - "ignore": ["node_modules", "dist"] - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - }, - "ignore": ["node_modules", "dist"] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 4, - "ignore": ["node_modules", "dist"] - } -} diff --git a/federation/index.ts b/federation/index.ts new file mode 100644 index 0000000..66b882c --- /dev/null +++ b/federation/index.ts @@ -0,0 +1,53 @@ +/** + * @file index.ts + * @fileoverview Main entrypoint and export for the module + * @module federation + * @see module:federation/schemas/base + */ + +import type { z } from "zod"; +import { + Action, + ActorPublicKeyData, + ContentFormat, + Dislike, + Entity, + Extension, + Follow, + FollowAccept, + FollowReject, + Like, + Note, + Patch, + Publication, + Report, + ServerMetadata, + Undo, + User, + VanityExtension, + Visibility, +} from "~/federation/schemas/base"; + +export type InferType = z.infer; + +export { + Entity, + ContentFormat, + Visibility, + Publication, + Note, + Patch, + ActorPublicKeyData, + VanityExtension, + User, + Action, + Like, + Undo, + Dislike, + Follow, + FollowAccept, + FollowReject, + Extension, + Report, + ServerMetadata, +}; diff --git a/federation/package.json b/federation/package.json index 0330d27..20664a8 100644 --- a/federation/package.json +++ b/federation/package.json @@ -1,63 +1,67 @@ { - "name": "@lysand-org/federation", - "displayName": "Lysand Federation", - "version": "3.0.0", - "author": { - "email": "jesse.wierzbinski@lysand.org", - "name": "Jesse Wierzbinski (CPlusPatch)", - "url": "https://cpluspatch.com" - }, - "readme": "README.md", - "repository": { - "type": "git", - "url": "https://github.com/lysand-org/api.git", - "directory": "federation" - }, - "bugs": { - "url": "https://github.com/lysand-org/api/issues" - }, - "license": "MIT", - "contributors": [ - { - "name": "Jesse Wierzbinski", - "email": "jesse.wierzbinski@lysand.org", - "url": "https://cpluspatch.com" - } - ], - "maintainers": [ - { - "name": "Jesse Wierzbinski", - "email": "jesse.wierzbinski@lysand.org", - "url": "https://cpluspatch.com" - } - ], - "description": "Type definitions for Lysand Federation, with validators.", - "categories": ["Other"], - "type": "module", - "engines": { - "bun": ">=1.1.8" - }, - "exports": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/lysand" - }, - "homepage": "https://lysand.org", - "keywords": [ - "lysand", - "federation", - "api", - "typescript", - "zod", - "validation" - ], - "packageManager": "bun@1.1.8", - "devDependencies": { - "@biomejs/biome": "^1.7.3" - }, - "trustedDependencies": ["@biomejs/biome"] + "name": "@lysand-org/federation", + "displayName": "Lysand Federation", + "version": "3.0.0", + "author": { + "email": "jesse.wierzbinski@lysand.org", + "name": "Jesse Wierzbinski (CPlusPatch)", + "url": "https://cpluspatch.com" + }, + "readme": "README.md", + "repository": { + "type": "git", + "url": "https://github.com/lysand-org/api.git", + "directory": "federation" + }, + "bugs": { + "url": "https://github.com/lysand-org/api/issues" + }, + "license": "MIT", + "contributors": [ + { + "name": "Jesse Wierzbinski", + "email": "jesse.wierzbinski@lysand.org", + "url": "https://cpluspatch.com" + } + ], + "maintainers": [ + { + "name": "Jesse Wierzbinski", + "email": "jesse.wierzbinski@lysand.org", + "url": "https://cpluspatch.com" + } + ], + "description": "Type definitions for Lysand Federation, with validators.", + "categories": ["Other"], + "type": "module", + "engines": { + "bun": ">=1.1.8" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/lysand" + }, + "homepage": "https://lysand.org", + "keywords": [ + "lysand", + "federation", + "api", + "typescript", + "zod", + "validation" + ], + "packageManager": "bun@1.1.8", + "dependencies": { + "@types/mime-types": "^2.1.4", + "magic-regexp": "^0.8.0", + "mime-types": "^2.1.35", + "zod": "^3.23.8", + "zod-validation-error": "^3.3.0" + } } diff --git a/federation/schemas/base.ts b/federation/schemas/base.ts new file mode 100644 index 0000000..f3cd237 --- /dev/null +++ b/federation/schemas/base.ts @@ -0,0 +1,188 @@ +import { z } from "zod"; +import { ContentFormat } from "./content_format"; +import { CustomEmojiExtension } from "./extensions/custom_emojis"; +import { VanityExtension } from "./extensions/vanity"; +import { extensionTypeRegex } from "./regex"; + +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": CustomEmojiExtension.optional(), + }), +}); + +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 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 Extension = Entity.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.", + ), +}); + +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 { + Entity, + Visibility, + Publication, + Note, + Patch, + ActorPublicKeyData, + VanityExtension, + User, + Action, + Like, + Undo, + Dislike, + Follow, + FollowAccept, + FollowReject, + Extension, + Report, + ServerMetadata, + ContentFormat, + CustomEmojiExtension, +}; diff --git a/federation/schemas/content_format.ts b/federation/schemas/content_format.ts new file mode 100644 index 0000000..305f547 --- /dev/null +++ b/federation/schemas/content_format.ts @@ -0,0 +1,17 @@ +import { types } from "mime-types"; +import { z } from "zod"; + +export 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(), + }), +); diff --git a/federation/schemas/extensions/custom_emojis.ts b/federation/schemas/extensions/custom_emojis.ts new file mode 100644 index 0000000..a848e44 --- /dev/null +++ b/federation/schemas/extensions/custom_emojis.ts @@ -0,0 +1,50 @@ +/** + * Custom emojis extension. + * @module federation/schemas/extensions/custom_emojis + * @see module:federation/schemas/base + * @see https://lysand.org/extensions/custom-emojis + */ +import { z } from "zod"; +import { ContentFormat } from "../content_format"; +import { emojiRegex } from "../regex"; + +/** + * @description Used to validate the properties the extension's custom field + * @see https://lysand.org/extensions/custom-emojis + * @example + * { + * // ... + * "extensions": { + * "org.lysand:custom_emojis": { + * "emojis": [ + * { + * "name": "happy_face", + * "url": { + * "image/png": { + * "content": "https://cdn.example.com/emojis/happy_face.png", + * "content_type": "image/png" + * } + * } + * }, + * // ... + * ] + * } + * } + * // ... + * } + */ +export const CustomEmojiExtension = 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: ContentFormat, + }), + ), +}); diff --git a/federation/schemas/extensions/polls.ts b/federation/schemas/extensions/polls.ts new file mode 100644 index 0000000..c10697b --- /dev/null +++ b/federation/schemas/extensions/polls.ts @@ -0,0 +1,92 @@ +/** + * Polls extension + * @module federation/schemas/extensions/polls + * @see module:federation/schemas/base + * @see https://lysand.org/extensions/polls + */ +import { z } from "zod"; +import { Extension } from "../base"; +import { ContentFormat } from "../content_format"; + +/** + * @description Poll extension entity + * @see https://lysand.org/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 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(), +}); + +/** + * @description Vote extension entity + * @see https://lysand.org/extensions/polls + * @example + * { + * "type": "Extension", + * "id": "31c4de70-e266-4f61-b0f7-3767d3ccf565", + * "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", + * "option": 1 + * } + */ +export const Vote = Extension.extend({ + extension_type: z.literal("org.lysand:polls/Vote"), + poll: z.string().url(), + option: z.number(), +}); + +/** + * @description Vote result extension entity + * @see https://lysand.org/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 VoteResult = Extension.extend({ + extension_type: z.literal("org.lysand:polls/VoteResult"), + poll: z.string().url(), + votes: z.array(z.number().int().nonnegative()), +}); diff --git a/federation/schemas/extensions/reactions.ts b/federation/schemas/extensions/reactions.ts new file mode 100644 index 0000000..ed6c9ea --- /dev/null +++ b/federation/schemas/extensions/reactions.ts @@ -0,0 +1,28 @@ +/** + * Reactions extension + * @module federation/schemas/extensions/reactions + * @see module:federation/schemas/base + * @see https://lysand.org/extensions/reactions + */ +import { z } from "zod"; +import { Extension } from "../base"; + +/** + * @description Reaction extension entity + * @see https://lysand.org/extensions/reactions + * @example + * { + * "type": "Extension", + * "id": "d6eb84ea-cd13-43f9-9c54-01244da8e5e3", + * "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": "👍" + * } + */ +export const Reaction = Extension.extend({ + extension_type: z.literal("org.lysand:reactions/Reaction"), + object: z.string().url(), + content: z.string(), +}); diff --git a/federation/schemas/extensions/vanity.ts b/federation/schemas/extensions/vanity.ts new file mode 100644 index 0000000..0fabc46 --- /dev/null +++ b/federation/schemas/extensions/vanity.ts @@ -0,0 +1,93 @@ +/** + * Vanity extension schema. + * @module federation/schemas/extensions/vanity + * @see module:federation/schemas/base + * @see https://lysand.org/extensions/vanity + */ + +import { z } from "zod"; +import { ContentFormat } from "../content_format"; + +/** + * @description Vanity extension entity + * @see https://lysand.org/extensions/vanity + * @example + * { + * // ... + * "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" + * } + * }, + * "avatar_mask": { + * "image/png": { + * "content": "https://cdn.example.com/d8c42be1-d0f7-43ef-b4ab-5f614e1beba4/rounded_square.jpeg", + * "content_type": "image/jpeg" + * } + * }, + * "background": { + * "image/png": { + * "content": "https://cdn.example.com/6492ddcd-311e-4921-9567-41b497762b09/untitled-file-0019822.png", + * "content_type": "image/png" + * } + * }, + * "audio": { + * "audio/mpeg": { + * "content": "https://cdn.example.com/4da2f0d4-4728-4819-83e4-d614e4c5bebc/michael-jackson-thriller.mp3", + * "content_type": "audio/mpeg" + * } + * }, + * "pronouns": { + * "en-us": [ + * "he/him", + * { + * "subject": "they", + * "object": "them", + * "dependent_possessive": "their", + * "independent_possessive": "theirs", + * "reflexive": "themself" + * }, + * ] + * }, + * "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" + * ] + * } + * } + * } + */ +export 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(), +}); diff --git a/federation/schemas/regex.ts b/federation/schemas/regex.ts new file mode 100644 index 0000000..f7cf362 --- /dev/null +++ b/federation/schemas/regex.ts @@ -0,0 +1,40 @@ +/** + * Regular expressions for matching various strings. + * @module federation/schemas/regex + * @see module:federation/schemas/base + */ + +import { + caseInsensitive, + charIn, + createRegExp, + digit, + exactly, + global, + letter, + oneOrMore, +} from "magic-regexp"; + +/** + * Regular expression for matching emojis. + */ +export const emojiRegex = createRegExp( + // A-Z a-z 0-9 _ - + oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), + [caseInsensitive, global], +); + +/** + * Regular expression for matching an extension_type + * @example org.lysand:custom_emojis/Emoji + */ +export const extensionTypeRegex = 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("_-")))), + ), +); diff --git a/federation/tests/index.test.ts b/federation/tests/index.test.ts new file mode 100644 index 0000000..25ce70a --- /dev/null +++ b/federation/tests/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { Note } from "../schemas/base"; + +describe("Package testing", () => { + it("should not validate a bad Note", () => { + const badObject = { + IamBad: "Note", + }; + + expect(Note.parseAsync(badObject)).rejects.toThrow(); + }); +}); diff --git a/federation/tsconfig.json b/federation/tsconfig.json new file mode 100644 index 0000000..8fc1e6a --- /dev/null +++ b/federation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "paths": { + "~/*": ["./*"] + } + } +} diff --git a/package.json b/package.json index d4883b9..8072c1f 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,19 @@ { - "name": "lysand-api", - "private": true, - "workspaces": ["federation"], - "scripts": { - "lint": "bunx @biomejs/biome check .", - "build": "echo 'Not implemented :(' && exit 1" - } + "name": "lysand-api", + "private": true, + "workspaces": ["federation"], + "scripts": { + "lint": "bunx @biomejs/biome check .", + "build": "bun run build.ts" + }, + "devDependencies": { + "@biomejs/biome": "^1.7.3", + "bun-plugin-dts": "^0.2.3", + "bun-types": "^1.1.8" + }, + "trustedDependencies": ["@biomejs/biome"], + "dependencies": { + "chalk": "^5.3.0", + "ora": "^8.0.1" + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2f1460a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "noImplicitAny": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "verbatimModuleSyntax": true, + "types": [ + "bun-types" // add Bun global + ], + "paths": { + "~/*": ["./*"] + } + }, + "include": ["*.ts", "*.d.ts", "**/*.ts", "**/*.d.ts"] +}