v3: shared/ is documented like api/ — deno doc --lint over api/ and shared/ goes from 385 errors to none — every module of shared/ opens with a /** … @module */ block; every export and every field of an exported interface has JSDoc, each request body naming the endpoint that takes it and each response its endpoint; the // comments that described declarations (the mention pattern, the permission union and table, four socket messages, the error message's optimistic-action context) are /** */ now, and the socket protocol's heading comment, which sat on no declaration, is its module doc. The lint wanted explicit types on MENTION_RE and DUMP_MAX_FILE_SIZE_BYTES. Along the way: the chat edit is allowed to moderators too, which the new ChatEditMessage doc says; UpdateUserRequest says that PATCH /api/users/me takes only its description and email; playlist-service's reorderPlaylist @throws is rewrapped. mod.ts and the README no longer call shared/ undocumented. No behaviour changes; 205 backend tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 21:13:07 +00:00
parent d62e4a9883
commit b85a71d86e
18 changed files with 599 additions and 59 deletions

View File

@@ -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`,

View File

@@ -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(

3
mod.ts
View File

@@ -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),

View File

@@ -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<T> {
/** 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<T> = APISuccess<T> | APIFailure;
/** One page of a list. */
export interface PaginatedData<T> {
/** 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;

View File

@@ -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 `/<slug>`. */
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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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 = /(?<![[(\w])@(\w+)/g;
/**
* The @mention pattern, one for the server and the chat.
*
* @module
*/
/**
* Matches @username not already inside a markdown link ([...] or (...)) and
* not mid-word, so an email address doesn't mention its domain; the name is
* the first group. The server notifies whoever it matches; the chat highlights
* the same names.
*/
export const MENTION_RE: RegExp = /(?<![[(\w])@(\w+)/g;

View File

@@ -1,3 +1,10 @@
/**
* Notifications: their types, and the data each type carries.
*
* @module
*/
/** What a notification is about. */
export type NotificationType =
| "playlist_followed"
| "user_followed"
@@ -8,64 +15,110 @@ export type NotificationType =
| "dump_commented"
| "comment_liked";
/** `playlist_followed`: someone followed one of the user's playlists. */
export interface PlaylistFollowedData {
/** The follower's id. */
followerId: string;
/** The follower's name. */
followerUsername: string;
/** The playlist's id. */
playlistId: string;
/** The playlist's title. */
playlistTitle: string;
}
/** `user_followed`: someone followed the user. */
export interface UserFollowedData {
/** The follower's id. */
followerId: string;
/** The follower's name. */
followerUsername: string;
}
/** `user_dump_posted`: a user they follow posted a dump. */
export interface UserDumpPostedData {
/** The poster's id. */
dumperId: string;
/** The poster's name. */
dumperUsername: string;
/** The dump's id. */
dumpId: string;
/** The dump's title. */
dumpTitle: string;
}
/** `playlist_dump_added`: a dump was added to a playlist they follow. */
export interface PlaylistDumpAddedData {
/** The dump's id. */
dumpId: string;
/** The dump's title. */
dumpTitle: string;
/** The playlist's id. */
playlistId: string;
/** The playlist's title. */
playlistTitle: string;
}
/** `dump_upvoted`: someone upvoted one of the user's dumps. */
export interface DumpUpvotedData {
/** The voter's id. */
voterId: string;
/** The voter's name. */
voterUsername: string;
/** The dump's id. */
dumpId: string;
/** The dump's title. */
dumpTitle: string;
}
/** `user_mentioned`: someone @mentioned the user. */
export interface UserMentionedData {
/** The mentioner's id. */
mentionerId: string;
/** The mentioner's name. */
mentionerUsername: string;
/** Where the mention was written. */
contextType: "comment" | "dump" | "playlist" | "chat";
/** The id of the comment, dump, playlist or chat message. */
contextId: string;
/**
* The title of the dump or playlist, or of the dump a comment is under;
* empty for the chat.
*/
contextTitle: string;
/** The dump a comment is under, for a mention in a comment. */
dumpId?: string;
}
/** `dump_commented`: someone commented on one of the user's dumps. */
export interface DumpCommentedData {
/** The commenter's id. */
commenterId: string;
/** The commenter's name. */
commenterUsername: string;
/** The comment's id. */
commentId: string;
/** The dump's id. */
dumpId: string;
/** The dump's title. */
dumpTitle: string;
}
/** `comment_liked`: someone liked one of the user's comments. */
export interface CommentLikedData {
/** The liker's id. */
likerId: string;
/** The liker's name. */
likerUsername: string;
/** The comment's id. */
commentId: string;
/** The id of the dump it is under. */
dumpId: string;
/** The title of the dump it is under. */
dumpTitle: string;
}
/** The data of any notification; its {@link NotificationType} says which. */
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
@@ -76,11 +129,18 @@ export type NotificationData =
| DumpCommentedData
| CommentLikedData;
/** A notification sent to a user. */
export interface Notification {
/** The notification's id. */
id: string;
/** The id of the user it is for. */
userId: string;
/** What it is about. */
type: NotificationType;
/** Its details, shaped by `type`. */
data: NotificationData;
/** Whether the user has read it. */
read: boolean;
/** When it was sent. */
createdAt: Date;
}

View File

@@ -1,7 +1,16 @@
/**
* The role permissions, one table for the server that enforces them and the
* client that hides what a role can't do.
*
* @module
*/
import type { Role } from "./user.ts";
// Named capabilities checked at enforcement points. Extend this union as new
// gated capabilities are added (e.g. "invite:manage").
/**
* Named capabilities checked at enforcement points. Extend this union as new
* gated capabilities are added (e.g. "invite:manage").
*/
export type Permission =
| "dump:moderate"
| "comment:moderate"
@@ -10,6 +19,7 @@ export type Permission =
| "user:manage"
| "category:manage";
/** Every {@link Permission}. */
const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
@@ -19,8 +29,11 @@ const ALL_PERMISSIONS: readonly Permission[] = [
"category:manage",
];
// Roles are fixed in code. `admin` is a superset of every permission (current
// and future); `moderator` gets the content-moderation permissions only.
/**
* What each role may do. Roles are fixed in code. `admin` is a superset of
* every permission (current and future); `moderator` gets the
* content-moderation permissions only.
*/
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
moderator: [
@@ -32,6 +45,7 @@ export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
user: [],
};
/** Checks whether `role` grants `permission`. */
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role].includes(permission);
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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<Dump>;
/** The matching users. */
users: PublicUser[];
/** The matching playlists. */
playlists: Playlist[];
}

View File

@@ -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<AuthTokens, "mediaToken">;
/** `POST /api/invites`: the new invite's token. */
export interface InviteResponse {
/** The token, which the invite link carries. */
token: string;
}

View File

@@ -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;

View File

@@ -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<ServerToClientMessage>`: 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<ServerToClientMessage>`: 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