Files
gerbeur/api/model/interfaces.ts
khannurien 2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00

952 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { VALIDATION } from "../config.ts";
/**
* Backend
*/
export interface RichContent {
type: string;
url: string;
siteName?: string;
title?: string;
description?: string;
/**
* A real preview image (og:image, a large content image, a video still).
* Never a favicon — an absent value means "this page offers no artwork",
* which is what makes the generated placeholder possible.
*/
thumbnailUrl?: string;
/** The page's own icon, used as the placeholder's glyph. */
faviconUrl?: string;
/** The page's declared brand color, normalized to `#rrggbb`. */
accentColor?: string;
videoId?: string;
embedUrl?: string;
}
export interface Dump {
id: string;
kind: "url" | "file";
title: string;
slug?: string;
comment?: string;
userId: string;
createdAt: Date;
updatedAt?: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
fileMime?: string;
fileSize?: number;
voteCount: number;
commentCount: number;
isPrivate: boolean;
thumbnailMime?: string;
categoryIds?: string[];
}
/**
* Categories
*/
export interface Category {
id: string;
slug: string;
name: string;
position: number;
createdAt: Date;
updatedAt?: Date;
}
export interface CreateCategoryRequest {
slug: string;
name: string;
position?: number;
}
export interface UpdateCategoryRequest {
slug?: string;
name?: string;
position?: number;
}
export function isCreateCategoryRequest(
obj: unknown,
): obj is CreateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if (typeof o.slug !== "string") return false;
if (typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
export function isUpdateCategoryRequest(
obj: unknown,
): obj is UpdateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("slug" in o && typeof o.slug !== "string") return false;
if ("name" in o && typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
/**
* Authentication
*/
export type Role = "user" | "moderator" | "admin";
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
export function isRole(value: unknown): value is Role {
return typeof value === "string" &&
(ROLES as readonly string[]).includes(value);
}
export interface User {
id: string;
username: string;
passwordHash: string;
role: Role;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
description?: string;
invitedByUsername?: string;
email: string;
}
export interface LoginUserRequest {
username: string;
password: string;
}
export interface RegisterUserRequest {
username: string;
password: string;
inviteToken: string;
email: string;
}
export interface UpdateUserRequest {
username?: string;
password?: string;
role?: Role;
description?: string | null;
email?: string;
}
export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
return !!obj && typeof obj === "object" &&
"username" in obj && typeof obj.username === "string" &&
"password" in obj && typeof obj.password === "string";
}
export function isRegisterUserRequest(
obj: unknown,
): obj is RegisterUserRequest {
return validateRegisterUserRequest(obj) === null;
}
/** Returns a human-readable error string, or null if the request is valid. */
export function validateRegisterUserRequest(obj: unknown): string | null {
if (
!obj || typeof obj !== "object" ||
!("username" in obj) || typeof obj.username !== "string" ||
!("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string" ||
!("email" in obj) || typeof obj.email !== "string"
) return "Invalid request";
const { username, password, email } = obj as RegisterUserRequest;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "Invalid email address";
}
if (
!new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
)
.test(username)
) {
return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`;
}
if (password.length < VALIDATION.PASSWORD_MIN) {
return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`;
}
if (password.length > VALIDATION.PASSWORD_MAX) {
return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`;
}
return null;
}
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("username" in o) {
if (typeof o.username !== "string") return false;
if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false;
}
if ("password" in o) {
if (typeof o.password !== "string") return false;
const len = (o.password as string).length;
if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) {
return false;
}
}
if ("role" in o && !isRole(o.role)) return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if ("email" in o) {
if (typeof o.email !== "string") return false;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false;
}
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
) return false;
return true;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface AuthPayload {
userId: string;
username: string;
role: Role;
exp: number;
iat?: number;
}
export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj &&
typeof obj === "object" &&
// Purpose-scoped tokens (password reset, media) are never session tokens.
!("purpose" in obj) &&
"userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" &&
"role" in obj && isRole(obj.role) &&
"exp" in obj && typeof obj.exp === "number";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;
exp: number;
}
export function isPasswordResetPayload(
obj: unknown,
): obj is PasswordResetPayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj &&
(obj as Record<string, unknown>).purpose === "password-reset" &&
"userId" in obj &&
typeof (obj as Record<string, unknown>).userId === "string";
}
/**
* API
*/
export enum APIErrorCode {
BAD_REQUEST = "BAD_REQUEST",
NOT_FOUND = "NOT_FOUND",
SERVER_ERROR = "SERVER_ERROR",
TIMEOUT = "TIMEOUT",
UNAUTHORIZED = "UNAUTHORIZED",
VALIDATION_ERROR = "VALIDATION_ERROR",
}
export interface APIError {
code: APIErrorCode;
message: string;
}
export interface APISuccess<T> {
success: true;
data: T;
error?: never;
}
export interface APIFailure {
success: false;
data?: never;
error: APIError;
}
export type APIResponse<T> = APISuccess<T> | APIFailure;
export interface PaginatedData<T> {
items: T[];
total: number;
hasMore: boolean;
}
export class APIException extends Error {
readonly code: APIErrorCode;
readonly status: number;
constructor(code: APIErrorCode, status: number, message: string) {
super(message);
this.code = code;
this.status = status;
}
}
/**
* Comments
*/
export interface Comment {
id: string;
dumpId: string;
userId: string;
parentId?: string;
body: string;
createdAt: Date;
updatedAt?: Date;
deleted: boolean;
likeCount: number;
authorUsername: string;
authorAvatarMime?: string;
}
export interface CreateCommentRequest {
body: string;
parentId?: string;
}
/**
* Chat
*/
export interface ChatMessage {
id: string;
userId: string;
body: string;
createdAt: Date;
updatedAt?: Date;
authorUsername: string;
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). */
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. */
replyToAuthor?: string;
replyToBody?: string;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawChatMessage = Omit<ChatMessage, "createdAt"> & {
createdAt: string;
};
export function isCreateCommentRequest(
obj: unknown,
): obj is CreateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX &&
(!("parentId" in o) || typeof o.parentId === "string" ||
o.parentId === null);
}
export interface UpdateCommentRequest {
body: string;
}
export function isUpdateCommentRequest(
obj: unknown,
): obj is UpdateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX;
}
/**
* Playlists
*/
export interface Playlist {
id: string;
userId: string;
title: string;
slug?: string;
description?: string;
isPublic: boolean;
createdAt: Date;
updatedAt?: Date;
imageMime?: string;
dumpCount?: number;
ownerUsername?: string;
}
export interface PlaylistWithDumps extends Playlist {
dumps: Dump[];
}
export interface PlaylistMembership {
playlist: Playlist;
hasDump: boolean;
}
export interface CreatePlaylistRequest {
title: string;
description?: string;
isPublic: boolean;
}
export interface UpdatePlaylistRequest {
title?: string;
description?: string | null;
isPublic?: boolean;
}
export interface ReorderPlaylistRequest {
dumpIds: string[];
}
export function isCreatePlaylistRequest(
obj: unknown,
): obj is CreatePlaylistRequest {
if (
!obj || typeof obj !== "object" ||
!("title" in obj) || typeof obj.title !== "string" ||
!("isPublic" in obj) || typeof obj.isPublic !== "boolean"
) return false;
const o = obj as Record<string, unknown>;
if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
return true;
}
export function isUpdatePlaylistRequest(
obj: unknown,
): obj is UpdatePlaylistRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("title" in o) {
if (typeof o.title !== "string") return false;
if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
}
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
return true;
}
export function isReorderPlaylistRequest(
obj: unknown,
): obj is ReorderPlaylistRequest {
return !!obj && typeof obj === "object" &&
"dumpIds" in obj && Array.isArray(obj.dumpIds) &&
(obj.dumpIds as unknown[]).every((id) => typeof id === "string");
}
/**
* Request DTOs
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
export interface CreateUrlDumpRequest {
url: string;
/** Overrides the title scraped from the page — the poster edited the preview. */
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
/**
* An existing dump pointing at the same URL, as shown by the create form's
* duplicate hint. Display-only projection — see `findDumpsByUrl`.
*/
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export function isCreateUrlDumpRequest(
obj: unknown,
): obj is CreateUrlDumpRequest {
if (
!obj || typeof obj !== "object" ||
!("url" in obj) || typeof obj.url !== "string"
) return false;
const o = obj as Record<string, unknown>;
if (
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("title" in o && typeof o.title !== "string" && o.title !== null) {
return false;
}
if (
typeof o.title === "string" &&
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}
export interface UpdateDumpRequest {
url?: string;
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("url" in o && typeof o.url !== "string" && o.url !== null) return false;
if ("title" in o) {
if (typeof o.title !== "string") return false;
const trimmed = (o.title as string).trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return false;
}
if (
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}
/**
* WebSockets
*/
// ── Client → Server ──────────────────────────────────────────────────────────
export interface PingMessage {
type: "ping";
}
export interface PongMessage {
type: "pong";
}
export interface VoteCastMessage {
type: "vote_cast";
dumpId: string;
}
export interface VoteRemoveMessage {
type: "vote_remove";
dumpId: string;
}
export interface CommentLikeCastMessage {
type: "comment_like_cast";
commentId: string;
}
export interface CommentLikeRemoveMessage {
type: "comment_like_remove";
commentId: string;
}
export interface ChatSendMessage {
type: "chat_send";
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. */
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.
export interface ChatTypingMessage {
type: "chat_typing";
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.
export interface ChatFocusMessage {
type: "chat_focus";
open: boolean;
}
export interface ChatEditMessage {
type: "chat_edit";
id: string;
body: string;
}
export interface ChatDeleteMessage {
type: "chat_delete";
id: string;
}
// The user has seen the chat up to the message created at `at`.
export interface ChatReadMessage {
type: "chat_read";
at: string;
}
export type ClientToServerMessage =
| PingMessage
| PongMessage
| VoteCastMessage
| VoteRemoveMessage
| CommentLikeCastMessage
| CommentLikeRemoveMessage
| ChatSendMessage
| ChatTypingMessage
| ChatFocusMessage
| ChatEditMessage
| ChatDeleteMessage
| ChatReadMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
export interface OnlineUser {
userId: string;
username: string;
hasAvatar: boolean;
avatarVersion?: number;
}
export interface WelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number;
/** The user's chat read marker and the unread count behind it (signed-in
* users only). */
chatReadAt?: string;
unreadChatCount: number;
}
export interface PresenceUpdateMessage {
type: "presence_update";
users: OnlineUser[];
}
export interface VotesUpdateMessage {
type: "votes_update";
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
}
export interface VoteAckMessage {
type: "vote_ack";
dumpId: string;
action: "cast" | "remove";
voteCount: number;
}
export interface CommentLikesUpdateMessage {
type: "comment_likes_update";
commentId: string;
likeCount: number;
likerId: string;
action: "cast" | "remove";
}
export interface CommentLikeAckMessage {
type: "comment_like_ack";
commentId: string;
action: "cast" | "remove";
likeCount: number;
}
export interface DumpCreatedMessage {
type: "dump_created";
dump: Dump;
}
export interface DumpUpdatedMessage {
type: "dump_updated";
dump: Dump;
}
export interface DumpDeletedMessage {
type: "dump_deleted";
dumpId: string;
}
export interface PlaylistCreatedMessage {
type: "playlist_created";
playlist: Playlist;
}
export interface PlaylistUpdatedMessage {
type: "playlist_updated";
playlist: Playlist;
}
export interface PlaylistDeletedMessage {
type: "playlist_deleted";
playlistId: string;
userId: string;
}
export interface PlaylistDumpsUpdatedMessage {
type: "playlist_dumps_updated";
playlistId: string;
dumpIds: string[];
}
export interface UserUpdatedMessage {
type: "user_updated";
user: Omit<User, "passwordHash" | "email">;
}
export interface CommentCreatedMessage {
type: "comment_created";
comment: Comment;
}
export interface CommentUpdatedMessage {
type: "comment_updated";
comment: Comment;
}
export interface CommentDeletedMessage {
type: "comment_deleted";
commentId: string;
dumpId: string;
}
export interface NotificationCreatedMessage {
type: "notification_created";
notification: RawNotification;
}
export interface ErrorMessage {
type: "error";
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.
dumpId?: string;
commentId?: string;
action?: "cast" | "remove";
/** Set when a chat action failed, so the client can surface it in the chat. */
scope?: "chat";
/** Echo of the failed chat_send's nonce. */
nonce?: string;
}
export interface ForceLogoutMessage {
type: "force_logout";
}
export interface ChatMessageMessage {
type: "chat_message";
message: ChatMessage;
/** The sender's nonce, so its pending copy can be retired. */
nonce?: string;
}
export interface ChatMessageUpdatedMessage {
type: "chat_message_updated";
message: ChatMessage;
}
export interface ChatMessageDeletedMessage {
type: "chat_message_deleted";
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.
export interface ChatTypingUpdateMessage {
type: "chat_typing_update";
users: OnlineUser[];
}
// The user's read marker moved (from any of their tabs or devices).
export interface ChatReadUpdateMessage {
type: "chat_read_update";
readAt: string;
unread: number;
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
| PresenceUpdateMessage
| VotesUpdateMessage
| VoteAckMessage
| CommentLikesUpdateMessage
| CommentLikeAckMessage
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
| PlaylistCreatedMessage
| PlaylistUpdatedMessage
| PlaylistDeletedMessage
| PlaylistDumpsUpdatedMessage
| UserUpdatedMessage
| CommentCreatedMessage
| CommentUpdatedMessage
| CommentDeletedMessage
| NotificationCreatedMessage
| ErrorMessage
| ForceLogoutMessage
| ChatMessageMessage
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage
| ChatTypingUpdateMessage
| ChatReadUpdateMessage;
/**
* Follows
*/
export interface FollowStatus {
followedUserIds: string[];
followedPlaylistIds: string[];
}
/**
* Notifications
*/
export type NotificationType =
| "playlist_followed"
| "user_followed"
| "user_dump_posted"
| "playlist_dump_added"
| "dump_upvoted"
| "user_mentioned"
| "dump_commented"
| "comment_liked";
export interface PlaylistFollowedData {
followerId: string;
followerUsername: string;
playlistId: string;
playlistTitle: string;
}
export interface UserFollowedData {
followerId: string;
followerUsername: string;
}
export interface UserDumpPostedData {
dumperId: string;
dumperUsername: string;
dumpId: string;
dumpTitle: string;
}
export interface PlaylistDumpAddedData {
dumpId: string;
dumpTitle: string;
playlistId: string;
playlistTitle: string;
}
export interface DumpUpvotedData {
voterId: string;
voterUsername: string;
dumpId: string;
dumpTitle: string;
}
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist" | "chat";
contextId: string;
contextTitle: string;
dumpId?: string;
}
export interface DumpCommentedData {
commenterId: string;
commenterUsername: string;
commentId: string;
dumpId: string;
dumpTitle: string;
}
export interface CommentLikedData {
likerId: string;
likerUsername: string;
commentId: string;
dumpId: string;
dumpTitle: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
| UserDumpPostedData
| PlaylistDumpAddedData
| DumpUpvotedData
| UserMentionedData
| DumpCommentedData
| CommentLikedData;
export interface Notification {
id: string;
userId: string;
type: NotificationType;
data: NotificationData;
read: boolean;
createdAt: Date;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawNotification = Omit<Notification, "createdAt"> & {
createdAt: string;
};