All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
7.4 KiB
TypeScript
280 lines
7.4 KiB
TypeScript
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type Invitee,
|
|
type PublicUser,
|
|
type RegisterUserRequest,
|
|
toPublicUser,
|
|
type UpdateUserRequest,
|
|
type User,
|
|
type UserSuggestion,
|
|
} from "../model/interfaces.ts";
|
|
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
|
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
|
|
|
import { hashPassword } from "../lib/jwt.ts";
|
|
import { linkAttachments } from "./attachment-service.ts";
|
|
|
|
const USER_SELECT =
|
|
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email,
|
|
i.username as invited_by_username
|
|
FROM users u
|
|
LEFT JOIN users i ON i.id = u.invited_by`;
|
|
|
|
export async function createUser(
|
|
request: RegisterUserRequest,
|
|
inviterId: string | null,
|
|
): Promise<User> {
|
|
const userId = crypto.randomUUID();
|
|
const createdAt = new Date();
|
|
|
|
const existingUser = db.prepare(
|
|
"SELECT id FROM users WHERE username = ?;",
|
|
).get(request.username);
|
|
|
|
if (existingUser) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
400,
|
|
"Username already exists",
|
|
);
|
|
}
|
|
|
|
assertEmailAvailable(request.email);
|
|
|
|
const passwordHash = await hashPassword(request.password);
|
|
|
|
try {
|
|
db.prepare(
|
|
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
userId,
|
|
request.username,
|
|
passwordHash,
|
|
"user",
|
|
createdAt.toISOString(),
|
|
inviterId,
|
|
request.email,
|
|
);
|
|
} catch (err) {
|
|
// The check above ran before the hash yielded, so a parallel registration
|
|
// can still take the name first; the UNIQUE constraint is the real guard.
|
|
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
400,
|
|
"Username already exists",
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
id: userId,
|
|
username: request.username,
|
|
passwordHash,
|
|
role: "user",
|
|
createdAt,
|
|
email: request.email,
|
|
};
|
|
}
|
|
|
|
export function getUserById(userId: string): User {
|
|
const userRow = db.prepare(
|
|
`${USER_SELECT} WHERE u.id = ?`,
|
|
).get(userId);
|
|
|
|
if (!userRow || !isUserRow(userRow)) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
|
}
|
|
|
|
return userRowToApi(userRow);
|
|
}
|
|
|
|
export function getUserByUsername(username: string): User {
|
|
const userRow = db.prepare(
|
|
`${USER_SELECT} WHERE u.username = ?`,
|
|
).get(username);
|
|
|
|
if (!userRow || !isUserRow(userRow)) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
|
}
|
|
|
|
return userRowToApi(userRow);
|
|
}
|
|
|
|
/**
|
|
* Password resets are looked up by email, so an address can belong to one
|
|
* account only — otherwise which account a reset link resets is up to row order.
|
|
*/
|
|
function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
|
const row = db.prepare(
|
|
`SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`,
|
|
).get(email, exceptUserId ?? "");
|
|
if (row) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
409,
|
|
"Email already in use",
|
|
);
|
|
}
|
|
}
|
|
|
|
export function getUserByEmail(email: string): User | null {
|
|
const userRow = db.prepare(
|
|
`${USER_SELECT} WHERE u.email = ?`,
|
|
).get(email);
|
|
|
|
if (!userRow || !isUserRow(userRow)) return null;
|
|
return userRowToApi(userRow);
|
|
}
|
|
|
|
export function searchUsers(
|
|
query: string,
|
|
limit: number,
|
|
): UserSuggestion[] {
|
|
if (!query) return [];
|
|
const rows = db.prepare(
|
|
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`,
|
|
).all(`${query}%`, limit) as {
|
|
id: string;
|
|
username: string;
|
|
avatar_mime: string | null;
|
|
}[];
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
username: r.username,
|
|
avatarMime: r.avatar_mime,
|
|
}));
|
|
}
|
|
|
|
// For the public search page: profiles only, never hashes or emails.
|
|
export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
|
|
if (!query.trim()) return [];
|
|
const pattern = `%${query}%`;
|
|
const rows = db.prepare(
|
|
`${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`,
|
|
).all(pattern, pattern, limit);
|
|
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
|
|
}
|
|
|
|
export function listUsers(): User[] {
|
|
const userRows = db.prepare(
|
|
`${USER_SELECT}`,
|
|
).all();
|
|
|
|
if (!userRows || !userRows.every(isUserRow)) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No user found");
|
|
}
|
|
|
|
return userRows.map(userRowToApi);
|
|
}
|
|
|
|
export async function updateUser(
|
|
userId: string,
|
|
request: UpdateUserRequest,
|
|
): Promise<User> {
|
|
const user = getUserById(userId);
|
|
|
|
const { password, description, email, ...requestFields } = request;
|
|
if (email !== undefined && email !== user.email) {
|
|
assertEmailAvailable(email, userId);
|
|
}
|
|
|
|
const now = new Date();
|
|
const updatedUser: User = {
|
|
...user,
|
|
passwordHash: password ? await hashPassword(password) : user.passwordHash,
|
|
...requestFields,
|
|
description: description ?? user.description,
|
|
email: email ?? user.email,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const updatedUserRow = userApiToRow(updatedUser);
|
|
|
|
const userResult = db.prepare(
|
|
`UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`,
|
|
).run(
|
|
updatedUserRow.username,
|
|
updatedUserRow.password_hash,
|
|
updatedUserRow.role,
|
|
updatedUserRow.description,
|
|
updatedUserRow.email,
|
|
now.toISOString(),
|
|
updatedUserRow.id,
|
|
);
|
|
|
|
if (userResult.changes === 0) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
|
}
|
|
|
|
// A new password ends every session opened with the old one.
|
|
if (password) {
|
|
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
|
now.toISOString(),
|
|
userId,
|
|
);
|
|
closeUserSockets(userId, "Password changed");
|
|
}
|
|
|
|
if (updatedUser.description) {
|
|
linkAttachments(updatedUser.description, userId);
|
|
}
|
|
|
|
return updatedUser;
|
|
}
|
|
|
|
export function updateUserAvatar(userId: string, mime: string): void {
|
|
const result = db.prepare(
|
|
`UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`,
|
|
).run(mime, new Date().toISOString(), userId);
|
|
|
|
if (result.changes === 0) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
|
}
|
|
}
|
|
|
|
export function getInviteTree(userId: string): Invitee[] {
|
|
const rows = db.prepare(`
|
|
WITH RECURSIVE tree AS (
|
|
SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth
|
|
FROM users
|
|
WHERE invited_by = ?
|
|
UNION ALL
|
|
SELECT u.id, u.username, u.avatar_mime, u.invited_by, u.created_at, t.depth + 1
|
|
FROM users u
|
|
INNER JOIN tree t ON u.invited_by = t.id
|
|
WHERE t.depth < 10
|
|
)
|
|
SELECT * FROM tree ORDER BY created_at;
|
|
`).all(userId) as {
|
|
id: string;
|
|
username: string;
|
|
avatar_mime: string | null;
|
|
invited_by: string;
|
|
created_at: string;
|
|
}[];
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
username: r.username,
|
|
avatarMime: r.avatar_mime ?? undefined,
|
|
invitedById: r.invited_by,
|
|
createdAt: new Date(r.created_at),
|
|
}));
|
|
}
|
|
|
|
export function deleteUser(userId: string): void {
|
|
disconnectUser(userId);
|
|
|
|
const result = db.prepare(
|
|
`DELETE FROM users WHERE id = ?;`,
|
|
).run(userId);
|
|
|
|
if (result.changes === 0) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
|
}
|
|
}
|