v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The user and account routes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
|
||||
import {
|
||||
@@ -64,14 +70,54 @@ import {
|
||||
getFollowedUsersByUser,
|
||||
} from "../services/follow-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
/**
|
||||
* The user and account routes. Public profiles never carry the password hash or
|
||||
* the email:
|
||||
*
|
||||
* - `POST /api/users/register` — public, with a valid invite. Creates an
|
||||
* account, sends the welcome email, and answers the user and their tokens.
|
||||
* - `POST /api/users/login` — public. Answers the user and their tokens;
|
||||
* rate-limited per account.
|
||||
* - `GET /api/users/me` — signed in. Answers the caller's own user.
|
||||
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a
|
||||
* session that predates them.
|
||||
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
|
||||
* the account with that address, if any; always answers 200.
|
||||
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
|
||||
* password.
|
||||
* - `POST /api/users/me/change-password` — signed in, with the current
|
||||
* password. Sets a new one, ending every other session, and answers fresh
|
||||
* tokens.
|
||||
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
|
||||
* description and email.
|
||||
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
|
||||
* user's role.
|
||||
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
|
||||
* starts with `q`, for @mention suggestions.
|
||||
* - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id.
|
||||
* - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a
|
||||
* page of the users they follow.
|
||||
* - `GET /api/users/:username/followed-playlists?page=&limit=` — public.
|
||||
* Answers a page of the public playlists they follow.
|
||||
* - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in.
|
||||
* Answers a page of their playlists, the private ones only to themselves.
|
||||
* - `GET /api/users/:username/invitees` — public. Answers the tree of users
|
||||
* they invited.
|
||||
* - `GET /api/users/:username` — public. Answers a user's profile.
|
||||
* - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a
|
||||
* page of their dumps, the private ones only to themselves.
|
||||
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
|
||||
* page of the dumps they upvoted, private ones only to themselves.
|
||||
*/
|
||||
const router: Router = new Router({ prefix: "/api/users" });
|
||||
|
||||
// A burst of 10 attempts, then one every 30s.
|
||||
/** Login attempts per account: a burst of 10, then one every 30s. */
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
// The session token, plus the media-only token that goes in file and
|
||||
// thumbnail URLs (see createMediaToken).
|
||||
/**
|
||||
* Issues `user` a session token, plus the media-only token that goes in file
|
||||
* and thumbnail URLs (see {@link createMediaToken}).
|
||||
*/
|
||||
async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
@@ -83,7 +129,6 @@ async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
};
|
||||
}
|
||||
|
||||
// Register a new user (requires a valid invite token)
|
||||
router.post("/register", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
@@ -129,7 +174,6 @@ router.post("/register", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
@@ -179,7 +223,6 @@ router.post("/login", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
try {
|
||||
if (!ctx.state.user) {
|
||||
@@ -206,7 +249,6 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
}
|
||||
});
|
||||
|
||||
// A media token for a session that predates them (see createMediaToken).
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||||
success: true,
|
||||
@@ -215,7 +257,6 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Request a password reset email (unauthenticated; always returns 200)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
@@ -229,7 +270,6 @@ router.post("/request-password-reset", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Consume a reset token and set a new password (unauthenticated)
|
||||
router.post("/reset-password", async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const token = fieldOf(body, "token");
|
||||
@@ -246,7 +286,6 @@ router.post("/reset-password", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Change current user's password (requires current password verification)
|
||||
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const currentPassword = fieldOf(body, "currentPassword");
|
||||
@@ -293,7 +332,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Update current user profile (description, etc.)
|
||||
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateUserRequest(body)) {
|
||||
@@ -320,7 +358,6 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Set another user's role. Requires the user:manage permission.
|
||||
router.patch(
|
||||
"/:userId/role",
|
||||
authMiddleware,
|
||||
@@ -355,7 +392,6 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
// User search for @mention autocomplete
|
||||
router.get("/search", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
const responseBody: APIResponse<UserSuggestion[]> = {
|
||||
@@ -365,7 +401,6 @@ router.get("/search", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
@@ -374,7 +409,6 @@ router.get("/by-id/:userId", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed users for a user (public)
|
||||
router.get("/:username/followed-users", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -389,7 +423,6 @@ router.get("/:username/followed-users", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed playlists for a user (public only)
|
||||
router.get("/:username/followed-playlists", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
@@ -401,7 +434,6 @@ router.get("/:username/followed-playlists", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Playlists by user (optional auth: include private only if requester === owner)
|
||||
router.get("/:username/playlists", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
@@ -419,7 +451,6 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Invite tree for a user (public)
|
||||
router.get("/:username/invitees", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
@@ -429,7 +460,6 @@ router.get("/:username/invitees", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
router.get("/:username", (ctx) => {
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
@@ -438,7 +468,6 @@ router.get("/:username", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||
router.get("/:username/dumps", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
@@ -457,7 +486,6 @@ router.get("/:username/dumps", async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||
router.get("/:username/votes", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
|
||||
Reference in New Issue
Block a user