From b85a71d86effd8ef46d96a3fad9d9eb449dd781a Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 21:13:07 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20shared/=20is=20documented=20like=20api/?= =?UTF-8?q?=20=E2=80=94=20`deno=20doc=20--lint`=20over=20api/=20and=20shar?= =?UTF-8?q?ed/=20goes=20from=20385=20errors=20to=20none=20=E2=80=94=20ever?= =?UTF-8?q?y=20module=20of=20shared/=20opens=20with=20a=20`/**=20=E2=80=A6?= =?UTF-8?q?=20@module=20*/`=20block;=20every=20export=20and=20every=20fiel?= =?UTF-8?q?d=20of=20an=20exported=20interface=20has=20JSDoc,=20each=20requ?= =?UTF-8?q?est=20body=20naming=20the=20endpoint=20that=20takes=20it=20and?= =?UTF-8?q?=20each=20response=20its=20endpoint;=20the=20`//`=20comments=20?= =?UTF-8?q?that=20described=20declarations=20(the=20mention=20pattern,=20t?= =?UTF-8?q?he=20permission=20union=20and=20table,=20four=20socket=20messag?= =?UTF-8?q?es,=20the=20error=20message's=20optimistic-action=20context)=20?= =?UTF-8?q?are=20`/**=20*/`=20now,=20and=20the=20socket=20protocol's=20hea?= =?UTF-8?q?ding=20comment,=20which=20sat=20on=20no=20declaration,=20is=20i?= =?UTF-8?q?ts=20module=20doc.=20The=20lint=20wanted=20explicit=20types=20o?= =?UTF-8?q?n=20MENTION=5FRE=20and=20DUMP=5FMAX=5FFILE=5FSIZE=5FBYTES.=20Al?= =?UTF-8?q?ong=20the=20way:=20the=20chat=20edit=20is=20allowed=20to=20mode?= =?UTF-8?q?rators=20too,=20which=20the=20new=20ChatEditMessage=20doc=20say?= =?UTF-8?q?s;=20UpdateUserRequest=20says=20that=20PATCH=20/api/users/me=20?= =?UTF-8?q?takes=20only=20its=20description=20and=20email;=20playlist-serv?= =?UTF-8?q?ice's=20reorderPlaylist=20@throws=20is=20rewrapped.=20mod.ts=20?= =?UTF-8?q?and=20the=20README=20no=20longer=20call=20shared/=20undocumente?= =?UTF-8?q?d.=20No=20behaviour=20changes;=20205=20backend=20tests=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +++-- api/services/playlist-service.ts | 3 +- mod.ts | 3 +- shared/api.ts | 29 ++++- shared/category.ts | 22 ++++ shared/chat.ts | 27 ++++- shared/comment.ts | 26 +++++ shared/dump.ts | 56 +++++++++ shared/follow.ts | 9 ++ shared/mention.ts | 17 ++- shared/notification.ts | 60 ++++++++++ shared/permissions.ts | 22 +++- shared/player.ts | 39 ++++++- shared/playlist.ts | 40 +++++++ shared/search.ts | 10 ++ shared/user.ts | 52 +++++++++ shared/validation.ts | 24 +++- shared/ws.ts | 190 +++++++++++++++++++++++++++---- 18 files changed, 599 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 999b5e23..b0506095 100644 --- a/README.md +++ b/README.md @@ -158,23 +158,22 @@ place that fetches from other sites. Tests mirror the source one file per module, so the tests of `services/chat-service.ts` are in `test/services/chat-service.test.ts`. -The backend is documented for `deno doc`, the same way in every file. Each -module opens with a `/** … @module */` block saying what it is for. Every -export has a JSDoc comment, and so does every field of an exported interface; a -comment that describes a declaration is always `/** */`, and `//` is only for -notes on the code inside. Functions open with a verb ("Returns…", "Checks…"), -everything else with a noun phrase. Parameters are named in the prose, in -backticks. `{@link}` points at symbols the file already has in scope. A -function that can throw to its caller says so with `@throws`: for an +The backend and `shared/` are documented for `deno doc`, the same way in every +file. Each module opens with a `/** … @module */` block saying what it is for. +Every export has a JSDoc comment, and so does every field of an exported +interface; a comment that describes a declaration is always `/** */`, and `//` +is only for notes on the code inside. Functions open with a verb ("Returns…", +"Checks…"), everything else with a noun phrase. Parameters are named in the +prose, in backticks. `{@link}` points at symbols the file already has in scope. +A function that can throw to its caller says so with `@throws`: for an `APIException`, the statuses and when. A router's JSDoc lists its endpoints, one line each: method and path, who may call it, what it does. `deno task doc` -builds the HTML reference into `docs/` from every module of `api/` and -`shared/` but the tests. Its index page is the module doc of `mod.ts` at the -root, which introduces the repository's three parts and links every documented -module (`mod.test.ts` checks it), and the `docs` workflow publishes it on every -push to `main` that touches them. `deno task doc:lint` checks -the rules the linter can see; `shared/` is not documented yet, so it still -fails there. +builds the HTML reference into `docs/` from every module of `api/` and `shared/` +but the tests. Its index page is the module doc of `mod.ts` at the root, which +introduces the repository's three parts and links every documented module +(`mod.test.ts` checks it), and the `docs` workflow publishes it on every push to +`main` that touches them. `deno task doc:lint` checks the rules the linter can +see. Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is split into the same domain folders — `auth`, `category`, `chat`, `comment`, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 0c3ab919..0643dc35 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -406,7 +406,8 @@ export function removeDumpFromPlaylist( * Puts the playlist's members the requester can see in the order of * `dumpIds`, and announces the change. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 400 when `dumpIds` isn't exactly the + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the * members they can see. */ export function reorderPlaylist( diff --git a/mod.ts b/mod.ts index eecbbe2a..8c30d2db 100644 --- a/mod.ts +++ b/mod.ts @@ -137,8 +137,7 @@ * * The types and constants both halves use: the API envelope, each domain's * types and request bodies, the socket protocol, the validation limits and the - * role permissions. It imports nothing from either half. Its modules aren't - * documented yet. + * role permissions. It imports nothing from either half. * * Modules: [api](./shared/api.ts/index.html), * [category](./shared/category.ts/index.html), diff --git a/shared/api.ts b/shared/api.ts index d4aeb8cc..2588bce8 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -1,39 +1,66 @@ /** - * The envelope every endpoint answers with, and the error codes it carries. + * The envelope every endpoint answers with, the error codes it carries, the + * shape of a paginated list, and {@link Wire}, which maps a type to what JSON + * makes of it. + * + * @module */ +/** The error codes an {@link APIError} carries, by name. */ export const APIErrorCode = { + /** The request is malformed or not allowed in the current state. */ BAD_REQUEST: "BAD_REQUEST", + /** What the request names doesn't exist, or the caller may not see it. */ NOT_FOUND: "NOT_FOUND", + /** The server failed. */ SERVER_ERROR: "SERVER_ERROR", + /** A remote site took too long to answer. */ TIMEOUT: "TIMEOUT", + /** The caller isn't signed in, or may not do this. */ UNAUTHORIZED: "UNAUTHORIZED", + /** The request body failed validation. */ VALIDATION_ERROR: "VALIDATION_ERROR", } as const; +/** One of the {@link APIErrorCode} values. */ export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode]; +/** What went wrong, in a failed response. */ export interface APIError { + /** The kind of failure. */ code: APIErrorCode; + /** A human-readable explanation. */ message: string; } +/** A successful response, carrying its payload. */ export interface APISuccess { + /** Always true. */ success: true; + /** The payload. */ data: T; + /** Never set on success. */ error?: never; } +/** A failed response, carrying its error. */ export interface APIFailure { + /** Always false. */ success: false; + /** Never set on failure. */ data?: never; + /** What went wrong. */ error: APIError; } +/** What every endpoint answers with, narrowed by `success`. */ export type APIResponse = APISuccess | APIFailure; +/** One page of a list. */ export interface PaginatedData { + /** The page's items. */ items: T[]; + /** Whether another page follows. */ hasMore: boolean; /** The whole list's size — only where it's shown (search results). */ total?: number; diff --git a/shared/category.ts b/shared/category.ts index c6bbfc0e..90964c2f 100644 --- a/shared/category.ts +++ b/shared/category.ts @@ -1,20 +1,42 @@ +/** + * Categories, the admin-managed topics a dump can be filed under, and the + * bodies that create and edit them. + * + * @module + */ + +/** A category, which lists its dumps at `/`. */ export interface Category { + /** The category's id. */ id: string; + /** The category's URL slug, at the site root. */ slug: string; + /** The category's display name. */ name: string; + /** The category's place in the list, ascending. */ position: number; + /** When the category was created. */ createdAt: Date; + /** When the category was last changed, if ever. */ updatedAt?: Date; } +/** The body of `POST /api/categories`. */ export interface CreateCategoryRequest { + /** The new category's slug. */ slug: string; + /** The new category's display name. */ name: string; + /** Its place in the list; 0 when left out. */ position?: number; } +/** The body of `PUT /api/categories/:id`: the fields to change. */ export interface UpdateCategoryRequest { + /** The new slug. */ slug?: string; + /** The new display name. */ name?: string; + /** The new place in the list. */ position?: number; } diff --git a/shared/chat.ts b/shared/chat.ts index 97c796cb..0679d0b5 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -1,16 +1,35 @@ +/** + * A message of the site-wide chat. + * + * @module + */ + +/** A chat message, with what the chat shows of its author and its reply. */ export interface ChatMessage { + /** The message's id. */ id: string; + /** The author's id. */ userId: string; + /** The message, in Markdown. */ body: string; + /** When the message was sent. */ createdAt: Date; + /** When the message was last edited, if ever. */ updatedAt?: Date; + /** The author's name. */ authorUsername: string; + /** The author's avatar type, undefined without one. */ authorAvatarMime?: string; - /** Id of the message this one replies to, if any. Retained even when the - * target is later deleted (the preview fields below then go undefined). */ + /** + * Id of the message this one replies to, if any. Retained even when the + * target is later deleted (the preview fields below then go undefined). + */ replyToId?: string; - /** Author/snippet of the replied-to message, for a subtle inline reference. - * Absent when there's no reply, or the target has since been deleted. */ + /** + * Author of the replied-to message, for a subtle inline reference. Absent + * when there's no reply, or the target has since been deleted. + */ replyToAuthor?: string; + /** Body of the replied-to message, absent under the same conditions. */ replyToBody?: string; } diff --git a/shared/comment.ts b/shared/comment.ts index 00992d4b..dadde6b3 100644 --- a/shared/comment.ts +++ b/shared/comment.ts @@ -1,22 +1,48 @@ +/** + * Comments under a dump, and the bodies that post and edit them. + * + * @module + */ + +/** A comment, with what the thread shows of its author. */ export interface Comment { + /** The comment's id. */ id: string; + /** The id of the dump it is posted under. */ dumpId: string; + /** The author's id. */ userId: string; + /** The id of the comment it replies to; undefined at the top level. */ parentId?: string; + /** The comment, in Markdown; empty once deleted. */ body: string; + /** When the comment was posted. */ createdAt: Date; + /** When the comment was last edited, if ever. */ updatedAt?: Date; + /** + * Whether the comment was deleted: it stays in the thread, emptied, so its + * replies keep their place. + */ deleted: boolean; + /** How many users like the comment. */ likeCount: number; + /** The author's name. */ authorUsername: string; + /** The author's avatar type, undefined without one. */ authorAvatarMime?: string; } +/** The body of `POST /api/dumps/:dumpId/comments`. */ export interface CreateCommentRequest { + /** The comment, in Markdown. */ body: string; + /** The id of the comment it replies to, for a reply. */ parentId?: string; } +/** The body of `PATCH /api/comments/:commentId`. */ export interface UpdateCommentRequest { + /** The new text, in Markdown. */ body: string; } diff --git a/shared/dump.ts b/shared/dump.ts index 601612ea..127e9dd6 100644 --- a/shared/dump.ts +++ b/shared/dump.ts @@ -1,8 +1,23 @@ +/** + * Dumps — the links and files users post — their link previews, and the bodies + * that post and edit them. + * + * @module + */ + +/** What a link's page says about itself, as its preview card shows it. */ export interface RichContent { + /** + * Which provider read it: `youtube`, `bandcamp`, `soundcloud` or `generic`. + */ type: string; + /** The link the preview is of. */ url: string; + /** The site's name. */ siteName?: string; + /** The page's title. */ title?: string; + /** The page's description. */ description?: string; /** * A real preview image (og:image, a large content image, a video still). @@ -14,28 +29,49 @@ export interface RichContent { faviconUrl?: string; /** The page's declared brand color, normalized to `#rrggbb`. */ accentColor?: string; + /** The YouTube video's id, for a YouTube link. */ videoId?: string; + /** The URL of the page's embeddable player, if it has one. */ embedUrl?: string; } +/** A dump: a link or a file a user posted. */ export interface Dump { + /** The dump's id. */ id: string; + /** `url` for a link, `file` for an upload. */ kind: "url" | "file"; + /** The dump's title. */ title: string; + /** The dump's URL slug. */ slug?: string; + /** The poster's description, in Markdown. */ comment?: string; + /** The poster's id. */ userId: string; + /** When the dump was posted. */ createdAt: Date; + /** When the dump was last edited, if ever. */ updatedAt?: Date; + /** The link, for a URL dump. */ url?: string; + /** The link's preview, for a URL dump. */ richContent?: RichContent; + /** The uploaded file's name, for a file dump. */ fileName?: string; + /** The uploaded file's type, for a file dump. */ fileMime?: string; + /** The uploaded file's size in bytes, for a file dump. */ fileSize?: number; + /** How many users upvoted the dump. */ voteCount: number; + /** How many comments it has, not counting deleted ones. */ commentCount: number; + /** Whether only the poster sees the dump. */ isPrivate: boolean; + /** The uploaded thumbnail's type, undefined without one. */ thumbnailMime?: string; + /** The ids of the categories it is filed under. */ categoryIds?: string[]; } @@ -45,12 +81,19 @@ export interface Dump { * `findDumpsByUrl`. */ export interface DumpUrlMatch { + /** The dump's id. */ id: string; + /** The dump's URL slug. */ slug?: string; + /** The dump's title. */ title: string; + /** The poster's name. */ username: string; + /** When the dump was posted. */ createdAt: Date; + /** How many users upvoted the dump. */ voteCount: number; + /** How many comments it has. */ commentCount: number; } @@ -60,29 +103,42 @@ export interface DumpUrlMatch { * page has no preview" from "this link is dead". */ export interface LinkPreview { + /** Whether the page could be fetched. */ reached: boolean; + /** The preview, null when there is none at all. */ richContent: RichContent | null; } +/** The JSON body of `POST /api/dumps`, which posts a link. */ export interface CreateUrlDumpRequest { + /** The link. */ url: string; /** Overrides the title scraped from the page — the poster edited the preview. */ title?: string; + /** The poster's description, in Markdown. */ comment?: string; + /** Whether only the poster will see it. */ isPrivate?: boolean; + /** The ids of the categories to file it under. */ categoryIds?: string[]; } +/** The body of `PUT /api/dumps/:dumpId`: the fields to change. */ export interface UpdateDumpRequest { + /** The new link, for a URL dump. */ url?: string; + /** The new title. */ title?: string; /** `null` removes the note; leaving it out keeps it. */ comment?: string | null; + /** Whether only the poster will see it. */ isPrivate?: boolean; + /** The ids of the categories to file it under, replacing the current ones. */ categoryIds?: string[]; } /** `POST /api/attachments`: the id the uploaded image is served under. */ export interface AttachmentResponse { + /** The attachment's id, for `GET /api/attachments/:attachmentId`. */ id: string; } diff --git a/shared/follow.ts b/shared/follow.ts index ef7082b4..ebe8d6a6 100644 --- a/shared/follow.ts +++ b/shared/follow.ts @@ -1,4 +1,13 @@ +/** + * What a user follows. + * + * @module + */ + +/** `GET /api/follows/status`: the ids of what the caller follows. */ export interface FollowStatus { + /** The ids of the users they follow. */ followedUserIds: string[]; + /** The ids of the playlists they follow. */ followedPlaylistIds: string[]; } diff --git a/shared/mention.ts b/shared/mention.ts index 2d091883..8aa2a6ae 100644 --- a/shared/mention.ts +++ b/shared/mention.ts @@ -1,4 +1,13 @@ -// Matches @username not already inside a markdown link ([...] or (...)) and -// not mid-word, so an email address doesn't mention its domain. The server -// notifies whoever it matches; the chat highlights the same names. -export const MENTION_RE = /(? = { admin: ALL_PERMISSIONS, moderator: [ @@ -32,6 +45,7 @@ export const ROLE_PERMISSIONS: Record = { user: [], }; +/** Checks whether `role` grants `permission`. */ export function hasPermission(role: Role, permission: Permission): boolean { return ROLE_PERMISSIONS[role].includes(permission); } diff --git a/shared/player.ts b/shared/player.ts index f46e6f59..2fcf32e3 100644 --- a/shared/player.ts +++ b/shared/player.ts @@ -1,41 +1,68 @@ -/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */ +/** + * What the player's resolvers answer: a Bandcamp release's streams and a + * YouTube playlist's tracks. + * + * @module + */ + +/** A track of a Bandcamp release, as {@link Tralbum} lists it. */ export interface TralbumTrack { - /** Position in the parsed array — the stable handle used to re-resolve a - * track after its signed URL expires. */ + /** + * Position in the parsed array — the stable handle used to re-resolve a + * track after its signed URL expires. + */ index: number; + /** The track's number on the release. */ trackNum?: number; + /** The track's title. */ title: string; /** Seconds, fractional. */ duration?: number; + /** The signed stream URL, null when the track can't be played. */ streamUrl: string | null; - /** False for preorder, unreleased and still-encoding tracks. They stay in - * the list so numbering matches the release, but never enter the queue. */ + /** + * False for preorder, unreleased and still-encoding tracks. They stay in + * the list so numbering matches the release, but never enter the queue. + */ streamable: boolean; /** Bandcamp caps free plays per track; a capped track may serve a clip. */ capped: boolean; } +/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */ export interface Tralbum { + /** The page it was resolved from. */ sourceUrl: string; + /** Whether the page is an album or a single track. */ itemType: "album" | "track"; + /** The release's title. */ title?: string; + /** The artist's name. */ artist?: string; + /** The release's cover. */ artworkUrl?: string; + /** The release's tracks, in order. */ tracks: TralbumTrack[]; /** Epoch ms of the fetch that produced these signed URLs. */ resolvedAt: number; } -/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */ +/** A video of a YouTube playlist, as {@link YouTubePlaylist} lists it. */ export interface YouTubePlaylistTrack { + /** The video's id. */ videoId: string; + /** The video's title. */ title: string; /** Seconds. */ duration?: number; } +/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */ export interface YouTubePlaylist { + /** The playlist's id. */ listId: string; + /** The playlist's title. */ title?: string; + /** Its videos, in order. */ tracks: YouTubePlaylistTrack[]; } diff --git a/shared/playlist.ts b/shared/playlist.ts index e698ce22..f977666f 100644 --- a/shared/playlist.ts +++ b/shared/playlist.ts @@ -1,40 +1,80 @@ +/** + * Playlists — ordered collections of dumps — and the bodies that create, edit + * and reorder them. + * + * @module + */ + import type { Dump } from "./dump.ts"; +/** A playlist. */ export interface Playlist { + /** The playlist's id. */ id: string; + /** The owner's id. */ userId: string; + /** The playlist's title. */ title: string; + /** The playlist's URL slug. */ slug?: string; + /** The owner's description, in Markdown. */ description?: string; + /** Whether anyone may see it, or only its owner. */ isPublic: boolean; + /** When the playlist was created. */ createdAt: Date; + /** When the playlist was last changed, if ever. */ updatedAt?: Date; + /** The cover image's type, undefined without one. */ imageMime?: string; + /** + * How many dumps it holds, private ones included, where the query counts + * them. + */ dumpCount?: number; + /** The owner's name, where the query joins it. */ ownerUsername?: string; } +/** A playlist with the dumps the caller may see in it, in order. */ export interface PlaylistWithDumps extends Playlist { + /** The dumps, in the playlist's order. */ dumps: Dump[]; } +/** + * `GET /api/playlists/by-dump/:dumpId/memberships`: one of the caller's + * playlists, and whether it holds the dump. + */ export interface PlaylistMembership { + /** The playlist. */ playlist: Playlist; + /** Whether it holds the dump. */ hasDump: boolean; } +/** The body of `POST /api/playlists`. */ export interface CreatePlaylistRequest { + /** The new playlist's title. */ title: string; + /** Its description, in Markdown. */ description?: string; + /** Whether anyone may see it. */ isPublic: boolean; } +/** The body of `PUT /api/playlists/:playlistId`: the fields to change. */ export interface UpdatePlaylistRequest { + /** The new title. */ title?: string; + /** The new description; `null` removes it. */ description?: string | null; + /** Whether anyone may see it. */ isPublic?: boolean; } +/** The body of `PUT /api/playlists/:playlistId/order`. */ export interface ReorderPlaylistRequest { + /** Every dump of the playlist the caller can see, in the new order. */ dumpIds: string[]; } diff --git a/shared/search.ts b/shared/search.ts index 93829061..2af19390 100644 --- a/shared/search.ts +++ b/shared/search.ts @@ -1,10 +1,20 @@ +/** + * What a search answers. + * + * @module + */ + import type { PaginatedData } from "./api.ts"; import type { Dump } from "./dump.ts"; import type { Playlist } from "./playlist.ts"; import type { PublicUser } from "./user.ts"; +/** `GET /api/search`: what matches the query, in each kind. */ export interface SearchResults { + /** A page of the matching dumps, with their total. */ dumps: PaginatedData; + /** The matching users. */ users: PublicUser[]; + /** The matching playlists. */ playlists: Playlist[]; } diff --git a/shared/user.ts b/shared/user.ts index a4ae668c..420f5152 100644 --- a/shared/user.ts +++ b/shared/user.ts @@ -1,7 +1,17 @@ +/** + * Users as the API shows them, their roles, and the bodies and answers of + * sign-up, sign-in, profile edits and invites. + * + * @module + */ + +/** What a user may do beyond the basics; see `ROLE_PERMISSIONS`. */ export type Role = "user" | "moderator" | "admin"; +/** Every {@link Role}, least to most privileged. */ export const ROLES: readonly Role[] = ["user", "moderator", "admin"]; +/** Checks whether `value` is a {@link Role}. */ export function isRole(value: unknown): value is Role { return typeof value === "string" && (ROLES as readonly string[]).includes(value); @@ -9,69 +19,111 @@ export function isRole(value: unknown): value is Role { /** A user as anyone may see them: no password hash, no email. */ export interface PublicUser { + /** The user's id. */ id: string; + /** The user's name, fixed at sign-up. */ username: string; + /** The user's role. */ role: Role; + /** When the user signed up. */ createdAt: Date; + /** When the user last changed, if ever. */ updatedAt?: Date; + /** The avatar's type, undefined without one. */ avatarMime?: string; + /** The user's description of themselves, in Markdown. */ description?: string; + /** The name of whoever invited them. */ invitedByUsername?: string; } /** A user as they see themselves: everything but the password hash. */ export interface SelfUser extends PublicUser { + /** The address password resets are sent to. */ email: string; } /** A match for the @mention autocomplete. */ export interface UserSuggestion { + /** The user's id. */ id: string; + /** The user's name. */ username: string; + /** The avatar's type, null without one. */ avatarMime: string | null; } /** Someone a user invited, directly or down the line. */ export interface Invitee { + /** The user's id. */ id: string; + /** The user's name. */ username: string; + /** The avatar's type, undefined without one. */ avatarMime?: string; + /** The id of whoever invited them, to build the tree. */ invitedById: string; + /** When they signed up. */ createdAt: Date; } +/** The body of `POST /api/users/login`. */ export interface LoginUserRequest { + /** The user's name. */ username: string; + /** The user's password. */ password: string; } +/** The body of `POST /api/users/register`. */ export interface RegisterUserRequest { + /** The name to sign up with. */ username: string; + /** The password to sign up with. */ password: string; + /** The invite's token. */ inviteToken: string; + /** The address password resets will be sent to. */ email: string; } +/** + * Fields of a user to change. `PATCH /api/users/me` takes only `description` + * and `email` from it; the role and the password change through their own + * endpoints. + */ export interface UpdateUserRequest { + /** The new name. */ username?: string; + /** The new password. */ password?: string; + /** The new role. */ role?: Role; + /** The new description; `null` removes it. */ description?: string | null; + /** The new email address. */ email?: string; } /** The session token, plus the media-only one for file and thumbnail URLs. */ export interface AuthTokens { + /** The session token, sent as a bearer token. */ token: string; + /** The token media URLs carry as `?token=`, good for nothing else. */ mediaToken: string; } +/** What sign-up and sign-in answer: the user and their tokens. */ export interface AuthResponse extends AuthTokens { + /** The signed-in user. */ user: SelfUser; } +/** `GET /api/users/me/media-token`: a fresh media token. */ export type MediaTokenResponse = Pick; +/** `POST /api/invites`: the new invite's token. */ export interface InviteResponse { + /** The token, which the invite link carries. */ token: string; } diff --git a/shared/validation.ts b/shared/validation.ts index 07168431..c864e386 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -1,19 +1,39 @@ +/** + * The limits on what users submit, one set for the server that enforces them + * and the forms that check them before sending. + * + * @module + */ + /** Limits the server enforces and the forms check before sending. */ export const VALIDATION = { + /** Shortest username, in characters. */ USERNAME_MIN: 1, + /** Longest username. */ USERNAME_MAX: 32, + /** Shortest password. */ PASSWORD_MIN: 8, + /** Longest password. */ PASSWORD_MAX: 128, + /** Longest dump title. */ DUMP_TITLE_MAX: 200, + /** Longest dump description. */ DUMP_COMMENT_MAX: 5000, + /** Longest playlist title. */ PLAYLIST_TITLE_MAX: 100, + /** Longest playlist description. */ PLAYLIST_DESCRIPTION_MAX: 2000, + /** Longest comment. */ COMMENT_BODY_MAX: 5000, + /** Longest profile description. */ USER_DESCRIPTION_MAX: 2000, + /** Longest category name. */ CATEGORY_NAME_MAX: 50, + /** Longest category slug. */ CATEGORY_SLUG_MAX: 50, + /** Longest chat message. */ CHAT_MESSAGE_MAX: 2000, } as const; -/** Largest file a file dump may hold. */ -export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB +/** Largest file a file dump may hold, in bytes (50 MB). */ +export const DUMP_MAX_FILE_SIZE_BYTES: number = 50 * 1024 * 1024; diff --git a/shared/ws.ts b/shared/ws.ts index a09058c1..a4bd5080 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -1,3 +1,11 @@ +/** + * The socket protocol. Every message has a `type` discriminant, so each union + * narrows with a switch. The server sends these as JSON, so the client reads + * `Wire`: its Dates arrive as strings. + * + * @module + */ + import type { ChatMessage } from "./chat.ts"; import type { Comment } from "./comment.ts"; import type { Dump } from "./dump.ts"; @@ -5,83 +13,117 @@ import type { Notification } from "./notification.ts"; import type { Playlist } from "./playlist.ts"; import type { PublicUser } from "./user.ts"; -/** - * The socket protocol. Every message has a `type` discriminant, so each union - * narrows with a switch. The server sends these as JSON, so the client reads - * `Wire`: its Dates arrive as strings. - */ - // ── Client → Server ────────────────────────────────────────────────────────── +/** A keepalive; the other side answers {@link PongMessage}. Both directions. */ export interface PingMessage { + /** The discriminant. */ type: "ping"; } +/** The answer to a {@link PingMessage}. Both directions. */ export interface PongMessage { + /** The discriminant. */ type: "pong"; } +/** Upvotes a dump; answered by {@link VoteAckMessage}. */ export interface VoteCastMessage { + /** The discriminant. */ type: "vote_cast"; + /** The dump's id. */ dumpId: string; } +/** Withdraws an upvote; answered by {@link VoteAckMessage}. */ export interface VoteRemoveMessage { + /** The discriminant. */ type: "vote_remove"; + /** The dump's id. */ dumpId: string; } +/** Likes a comment; answered by {@link CommentLikeAckMessage}. */ export interface CommentLikeCastMessage { + /** The discriminant. */ type: "comment_like_cast"; + /** The comment's id. */ commentId: string; } +/** Withdraws a like; answered by {@link CommentLikeAckMessage}. */ export interface CommentLikeRemoveMessage { + /** The discriminant. */ type: "comment_like_remove"; + /** The comment's id. */ commentId: string; } +/** Posts a chat message; broadcast back as {@link ChatMessageMessage}. */ export interface ChatSendMessage { + /** The discriminant. */ type: "chat_send"; + /** The message, in Markdown. */ body: string; /** Id of the message being replied to, if this is a reply. */ replyToId?: string; - /** Client-chosen id echoed on the broadcast (or on the error), so the - * sender can match the outcome to its pending message. */ + /** + * Client-chosen id echoed on the broadcast (or on the error), so the + * sender can match the outcome to its pending message. + */ nonce?: string; } -// Sent while the user is composing (true) and when they stop (false). The -// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate. +/** + * Sent while the user is composing (true) and when they stop (false). The + * server rebroadcasts the live set of typers to everyone as + * {@link ChatTypingUpdateMessage}. + */ export interface ChatTypingMessage { + /** The discriminant. */ type: "chat_typing"; + /** Whether the user is composing. */ typing: boolean; } -// Tells the server whether this client currently has the chatbox open, so that -// mention notifications can be skipped for users who are already reading chat. +/** + * Tells the server whether this client currently has the chatbox open, so that + * mention notifications can be skipped for users who are already reading chat. + */ export interface ChatFocusMessage { + /** The discriminant. */ type: "chat_focus"; + /** Whether the chatbox is open. */ open: boolean; } +/** Edits a chat message: the user's own, or any with `chat:moderate`. */ export interface ChatEditMessage { + /** The discriminant. */ type: "chat_edit"; + /** The message's id. */ id: string; + /** The new text, in Markdown. */ body: string; } +/** Deletes a chat message: the user's own, or any with `chat:moderate`. */ export interface ChatDeleteMessage { + /** The discriminant. */ type: "chat_delete"; + /** The message's id. */ id: string; } -// The user has seen the chat up to the message created at `at`. +/** The user has seen the chat up to the message created at `at`. */ export interface ChatReadMessage { + /** The discriminant. */ type: "chat_read"; + /** The creation time of the last message seen, as an ISO string. */ at: string; } +/** Any message a client sends. */ export type ClientToServerMessage = | PingMessage | PongMessage @@ -98,93 +140,156 @@ export type ClientToServerMessage = // ── Server → Client ────────────────────────────────────────────────────────── +/** A signed-in user with at least one socket open. */ export interface OnlineUser { + /** The user's id. */ userId: string; + /** The user's name. */ username: string; + /** Whether the user has an avatar. */ hasAvatar: boolean; + /** When the avatar last changed, to bust the client's cache. */ avatarVersion?: number; } +/** The first message on a new socket: the state the client starts from. */ export interface WelcomeMessage { + /** The discriminant. */ type: "welcome"; + /** Who is online. */ users: OnlineUser[]; + /** The ids of the dumps the user upvoted; empty when signed out. */ myVotes: string[]; + /** The ids of the comments the user likes; empty when signed out. */ myCommentLikes: string[]; + /** How many notifications the user hasn't read. */ unreadNotificationCount: number; - /** The user's chat read marker and the unread count behind it (signed-in - * users only). */ + /** + * The user's chat read marker (signed-in users only), as an ISO string. + */ chatReadAt?: string; + /** How many chat messages came after the read marker. */ unreadChatCount: number; } +/** Who is online, sent to everyone whenever it changes. */ export interface PresenceUpdateMessage { + /** The discriminant. */ type: "presence_update"; + /** Who is online. */ users: OnlineUser[]; } +/** + * A dump's vote count changed; sent to everyone, or only to the poster for a + * private dump. + */ export interface VotesUpdateMessage { + /** The discriminant. */ type: "votes_update"; + /** The dump's id. */ dumpId: string; + /** Its new vote count. */ voteCount: number; + /** Who voted. */ voterId: string; + /** Whether the vote was cast or withdrawn. */ action: "cast" | "remove"; } +/** Confirms the sender's own vote, with the count it led to. */ export interface VoteAckMessage { + /** The discriminant. */ type: "vote_ack"; + /** The dump's id. */ dumpId: string; + /** Whether the vote was cast or withdrawn. */ action: "cast" | "remove"; + /** The dump's new vote count. */ voteCount: number; } +/** A comment's like count changed. */ export interface CommentLikesUpdateMessage { + /** The discriminant. */ type: "comment_likes_update"; + /** The comment's id. */ commentId: string; + /** Its new like count. */ likeCount: number; + /** Who liked it. */ likerId: string; + /** Whether the like was cast or withdrawn. */ action: "cast" | "remove"; } +/** Confirms the sender's own like, with the count it led to. */ export interface CommentLikeAckMessage { + /** The discriminant. */ type: "comment_like_ack"; + /** The comment's id. */ commentId: string; + /** Whether the like was cast or withdrawn. */ action: "cast" | "remove"; + /** The comment's new like count. */ likeCount: number; } +/** A dump was posted. */ export interface DumpCreatedMessage { + /** The discriminant. */ type: "dump_created"; + /** The new dump. */ dump: Dump; } +/** A dump changed. */ export interface DumpUpdatedMessage { + /** The discriminant. */ type: "dump_updated"; + /** The dump as it is now. */ dump: Dump; } +/** A dump was deleted. */ export interface DumpDeletedMessage { + /** The discriminant. */ type: "dump_deleted"; + /** The deleted dump's id. */ dumpId: string; } +/** A playlist was created; sent to whoever may see it. */ export interface PlaylistCreatedMessage { + /** The discriminant. */ type: "playlist_created"; + /** The new playlist. */ playlist: Playlist; } +/** A playlist changed; sent to whoever may see it, or could before. */ export interface PlaylistUpdatedMessage { + /** The discriminant. */ type: "playlist_updated"; + /** The playlist as it is now. */ playlist: Playlist; } +/** A playlist was deleted; sent to whoever could see it. */ export interface PlaylistDeletedMessage { + /** The discriminant. */ type: "playlist_deleted"; + /** The deleted playlist's id. */ playlistId: string; + /** Its owner's id. */ userId: string; } +/** A playlist's dumps changed; each recipient gets the ones they may see. */ export interface PlaylistDumpsUpdatedMessage { + /** The discriminant. */ type: "playlist_dumps_updated"; + /** The playlist's id. */ playlistId: string; /** The members this recipient can see, in order. */ dumpIds: string[]; @@ -192,39 +297,64 @@ export interface PlaylistDumpsUpdatedMessage { dumpCount: number; } +/** A user's public profile changed. */ export interface UserUpdatedMessage { + /** The discriminant. */ type: "user_updated"; + /** The user as they are now. */ user: PublicUser; } +/** A comment was posted. */ export interface CommentCreatedMessage { + /** The discriminant. */ type: "comment_created"; + /** The new comment. */ comment: Comment; } +/** A comment was edited. */ export interface CommentUpdatedMessage { + /** The discriminant. */ type: "comment_updated"; + /** The comment as it is now. */ comment: Comment; } +/** A comment was deleted. */ export interface CommentDeletedMessage { + /** The discriminant. */ type: "comment_deleted"; + /** The deleted comment's id. */ commentId: string; + /** The id of the dump it was under. */ dumpId: string; } +/** A notification for the recipient. */ export interface NotificationCreatedMessage { + /** The discriminant. */ type: "notification_created"; + /** The notification. */ notification: Notification; } +/** An action the client sent failed. */ export interface ErrorMessage { + /** The discriminant. */ type: "error"; + /** What went wrong. */ message?: string; - // Context for optimistic-action failures so the client can revert the exact - // pending vote/like immediately instead of waiting for its ACK timeout. + /** + * The dump of a failed vote. With `commentId` and `action`, it gives the + * client the context of an optimistic-action failure, so it can revert the + * exact pending vote or like immediately instead of waiting for its ACK + * timeout. + */ dumpId?: string; + /** The comment of a failed like. */ commentId?: string; + /** The failed vote's or like's action. */ action?: "cast" | "remove"; /** Set when a chat action failed, so the client can surface it in the chat. */ scope?: "chat"; @@ -232,41 +362,61 @@ export interface ErrorMessage { nonce?: string; } +/** The session ended — the account was deleted or its token revoked. */ export interface ForceLogoutMessage { + /** The discriminant. */ type: "force_logout"; } +/** A chat message was posted. */ export interface ChatMessageMessage { + /** The discriminant. */ type: "chat_message"; + /** The new message. */ message: ChatMessage; /** The sender's nonce, so its pending copy can be retired. */ nonce?: string; } +/** A chat message was edited. */ export interface ChatMessageUpdatedMessage { + /** The discriminant. */ type: "chat_message_updated"; + /** The message as it is now. */ message: ChatMessage; } +/** A chat message was deleted. */ export interface ChatMessageDeletedMessage { + /** The discriminant. */ type: "chat_message_deleted"; + /** The deleted message's id. */ id: string; } -// The current set of users composing a chat message. Reuses OnlineUser so the -// client can render the same avatars as the presence row. +/** + * The current set of users composing a chat message. Reuses + * {@link OnlineUser} so the client can render the same avatars as the + * presence row. + */ export interface ChatTypingUpdateMessage { + /** The discriminant. */ type: "chat_typing_update"; + /** Who is composing. */ users: OnlineUser[]; } -// The user's read marker moved (from any of their tabs or devices). +/** The user's read marker moved (from any of their tabs or devices). */ export interface ChatReadUpdateMessage { + /** The discriminant. */ type: "chat_read_update"; + /** The new marker, as an ISO string. */ readAt: string; + /** How many messages came after it. */ unread: number; } +/** Any message the server sends. */ export type ServerToClientMessage = | PingMessage | PongMessage