Skip to content
6 changes: 3 additions & 3 deletions guides/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ isItdServerError(v)
`ACCOUNT_CURRENT_PASSWORD_INCORRECT`, `ACCOUNT_EMAIL_DOMAIN_NOT_ALLOWED`.
- **Сессия:** `SESSION_EXPIRED`, `SESSION_REVOKED`, `SESSION_INVALID_REFRESH_TOKEN`,
`SESSION_NOT_FOUND`, `REFRESH_TOKEN_MISSING`.
- **Профиль/контент:** `PROFILE_USERNAME_TAKEN`, `PROFILE_USERNAME_RESERVED`,
`PROFILE_RESTRICTION_ACTIVE`, `PROFILE_MODIFICATION_RESTRICTED`, `CONTENT_MODERATION_FAILED`,
`WRITE_ACCESS_RESTRICTED`.
- **Профиль/контент:** `USERNAME_TAKEN` (смена имени в `updateMe()`), `PROFILE_USERNAME_TAKEN`,
`PROFILE_USERNAME_RESERVED`, `PROFILE_RESTRICTION_ACTIVE`, `PROFILE_MODIFICATION_RESTRICTED`,
`CONTENT_MODERATION_FAILED`, `WRITE_ACCESS_RESTRICTED`.
- **Файлы:** `FILE_TOO_LARGE`, `UNSUPPORTED_FILE_TYPE`, `UPLOAD_FAILED`,
`VIDEO_REQUIRES_VERIFICATION`.
- **Телефон:** `PHONE_VERIFICATION_REQUIRED`.
42 changes: 34 additions & 8 deletions guides/reference/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

Особенности, о которых легко забыть:

- **`avatar` — это эмодзи, а не URL.** На итд.com аватар — символ клана (`🩵`, `🦎`).
Отрисовывать его нужно как текст. Поле `banner` содержит URL изображения или `null`.
- **`avatar` — обычно эмодзи.** Как правило, аватар — символ клана (`🩵`, `🦎`), его отрисовывают
как текст. Он может быть и URL изображения — тогда клан хранится в `clanAvatar` профиля.
Поле `banner` содержит URL изображения или `null`.
- **`UserRef`** = UUID или username; **`UserId`** = строго UUID.

```ts
Expand All @@ -26,7 +27,7 @@ interface Author {
id: UserId;
username: string;
displayName: string;
avatar: string; // эмодзи, не URL
avatar: string; // обычно эмодзи, может быть URL
verified: boolean;
pin?: Pin | null; // активный значок
hasNuksta?: boolean; // премиум-подписка
Expand Down Expand Up @@ -72,7 +73,8 @@ interface UserSummary {
```ts
interface MyProfile {
id: UserId; username: string; displayName: string;
avatar: string; banner: string | null; bio: string;
avatar: string; clanAvatar?: string; // эмодзи клана
banner: string | null; bio: string;
verified: boolean; pin?: Pin | null;
wallAccess: WallAccess; // кто может писать на стену
likesVisibility: LikesVisibility; // кто видит реакции
Expand All @@ -99,7 +101,7 @@ interface SubscriptionState {
```ts
interface AuthUser {
id: UserId; username: string; displayName: string;
avatar: string; bio: string; verified: boolean;
avatar: string; clanAvatar?: string; bio: string; verified: boolean;
isPhoneVerified: boolean;
roles: string[];
}
Expand All @@ -120,7 +122,8 @@ interface AuthState {
```ts
interface PublicProfile {
id: UserId; username: string; displayName: string;
avatar: string; banner: string | null; bio: string;
avatar: string; clanAvatar?: string; // эмодзи клана
banner: string | null; bio: string;
verified: boolean; pin?: Pin | null;
wallAccess: WallAccess; likesVisibility: LikesVisibility;
followersCount: number; followingCount: number; postsCount: number;
Expand Down Expand Up @@ -168,6 +171,7 @@ interface PrivacySettings {
isPrivate: boolean; // подписка требует одобрения
wallAccess: WallAccess;
likesVisibility: LikesVisibility;
messageAccess: MessageAccess; // кто может писать личные сообщения
showLastSeen: boolean;
}
```
Expand All @@ -186,7 +190,7 @@ interface FollowResult {

```ts
interface Clan {
avatar: string; // эмодзи клана
avatar: string; // эмодзи клана = clanAvatar участников
memberCount: number;
}
```
Expand All @@ -206,7 +210,7 @@ interface Post {
wallRecipientId: UserId | null; // чья стена, если пост не у себя
wallRecipient?: Author | null; // владелец стены
isLiked: boolean; isReposted: boolean; isViewed?: boolean; isOwner: boolean;
originalPost?: Post | null; // если это репост
originalPost?: OriginalPost | null; // если это репост
poll?: Poll | null;
dominantEmoji?: string | null; // преобладающая реакция
editedAt?: IsoDate | null;
Expand All @@ -216,6 +220,27 @@ interface Post {
}
```

### OriginalPost

Пост, на который ссылается репост. Сервер отдаёт его на один уровень: у репоста репоста
`originalPost` — промежуточный репост, без собственного `originalPost`. `createdAt` сервер
присылает в формате PostgreSQL (`2026-10-04 22:32:04.381097+03`), библиотека приводит его к ISO.

```ts
interface OriginalPost {
id: string;
content: string;
spans: Span[];
author: Author;
attachments: Attachment[];
likesCount: number; commentsCount: number; repostsCount: number; viewsCount: number;
isDeleted: boolean;
dominantEmoji?: string | null;
createdAt: IsoDate;
vs?: string;
}
```

### Comment

```ts
Expand Down Expand Up @@ -359,6 +384,7 @@ interface Notification {
interface NotificationSettings {
enabled: boolean; // общий выключатель
sound: boolean;
messages: boolean; // личные сообщения
follows: boolean;
wallPosts: boolean;
likes: boolean;
Expand Down
22 changes: 15 additions & 7 deletions guides/reference/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ get(user: UserRef): Promise<PublicProfile>
Профиль пользователя по UUID или имени. См. [`PublicProfile`](./models.md#publicprofile).

```ts
checkUsername(username: string): Promise<boolean>
checkUsername(username: string): Promise<UsernameAvailability>
```
Свободно ли имя пользователя.
Свободно ли имя пользователя. Своё текущее имя сервер считает занятым; при неверном формате
в ответе есть `reason: UsernameUnavailableReason.InvalidFormat`.

## Подписки

Expand Down Expand Up @@ -93,10 +94,12 @@ iterateFollowing(user: UserRef, params?: UserListParams): Paginator<UserSummary>
```
Подписчики и подписки. См. [`UserSummary`](./models.md#usersummary).

> ⚠️ **Сервер эти списки не листает.** Возвращаются первые 20 записей: `page` игнорируется,
Свои списки листаются обычным образом: `page`, `limit` до 20, `hasMore` и `total`.

> ⚠️ **Чужие списки сервер не листает.** Возвращаются первые 20 записей: `page` игнорируется,
> `limit` больше 20 молча уменьшается, `hasMore` всегда `false`. Полю `total` доверять тоже
> нельзя — оно расходится с `followersCount` из профиля. Методы-итераторы закончатся после
> первых 20 записей и оставлены на случай, если пагинацию починят.
> нельзя — оно расходится с `followersCount` из профиля. Итератор по чужому списку закончится
> после первых 20 записей.

## Блокировки

Expand Down Expand Up @@ -154,15 +157,20 @@ topClans(): Promise<Clan[]>
interface UpdateProfileInput {
displayName?: string;
username?: string;
avatar?: string; // эмодзи-символ клана, а не URL картинки
avatar?: string; // эмодзи-символ клана
bio?: string;
bannerId?: string | null; // ID загруженного файла; null удаляет баннер
}

type UpdatePrivacyInput = Partial<PrivacySettings>;

interface UsernameAvailability {
available: boolean;
reason?: UsernameUnavailableReason; // только при неверном формате
}

interface UserListParams {
limit?: number; // > 20 сервер зажимает до 20
page?: number; // сервер игнорирует
page?: number; // для чужого профиля сервер игнорирует
}
```
32 changes: 26 additions & 6 deletions guides/web/packages/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,18 +181,38 @@ const itd = new ItdClient({

Поддерживаются:

- свой и чужой профиль, обновление и деактивация;
- создание, чтение, изменение, удаление и восстановление записей;
- лента и стена с устойчивой курсорной пагинацией;
- свой и чужой профиль, обновление, смена имени, `checkUsername()` и деактивация;
- подписка и отписка, списки подписчиков и подписок, топ кланов;
- ленты: общая, подписок и клана, стена и лайкнутые записи с устойчивой курсорной пагинацией;
- создание, чтение, изменение, удаление и восстановление записей, разметка `spans`;
- запись на чужую стену по `wallAccess`;
- репосты, в том числе репост репоста, и их отмена;
- комментарии, ответы, их изменение, удаление и восстановление;
- реакции на записи и комментарии;
- подписка и отписка;
- хэштеги из разметки записей, тренды и записи по хэштегу;
- поиск пользователей и хэштегов;
- список, счётчик и отметка уведомлений прочитанными;
- несколько клиентов с одним состоянием;
- `snapshot()` для независимого снимка и `reset()` для возврата к исходным данным.

Сервер покрывает перечисленные выше пользовательские сценарии. Файлы, медиа, особенности
старых ответов и событийные API проверяются отдельными сценариями. Неизвестный маршрут
Ссылки между пользователями, записями и уведомлениями хранятся по `id`. После смены имени
`clientOptions({ as })` находит пользователя по `id` или по новому имени, а уведомления
показывают участника с текущим именем.

Модель сервера упрощает API в нескольких местах:

- ленты не моделируют рекомендации: `popular` — все записи по дате, тренды — хэштеги по дате
последней записи;
- клан — `clanAvatar` пользователя, а без него — эмодзи-аватар;
- списки подписчиков и подписок листаются по `page` для любого профиля;
- `likesVisibility` и `wallAccess` применяются без заявок закрытого профиля;
- деактивированный пользователь виден только себе: остальные получают `404`, его записи и
комментарии скрыты, а сам он может только читать и восстановить аккаунт;
- вложения и опросы при создании записи отклоняются с `400`.

Файлы, медиа, приватность, блокировки, закрепление записей, настройки уведомлений и
событийные API сервер не моделирует: их проверяют через `createMockFetch()` или
`createMockOperations()`. Неизвестный маршрут
возвращает `501` и код `MOCK_ROUTE_NOT_IMPLEMENTED`; в конце теста можно вызвать
`server.assertNoUnsupportedRequests()`.

Expand Down
1 change: 1 addition & 0 deletions packages/crypto/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ declare module 'itd-api' {
}

interface Post extends CryptoDecodedObject {}
interface OriginalPost extends CryptoDecodedObject {}
interface PostUpdateResult extends CryptoDecodedObject {}
interface Comment extends CryptoDecodedObject {}
interface CommentUpdateResult extends CryptoDecodedObject {}
Expand Down
28 changes: 28 additions & 0 deletions packages/crypto/test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ describe('реестр cipher', () => {
});

describe('шифрование запроса', () => {
it('расшифровывает исходный пост внутри репоста', async () => {
const { itd, calls } = makeClient((call, index) =>
index === 0
? { id: 'root', ...call.body }
: {
id: 'repost',
content: '',
spans: [],
originalPost: {
id: 'root',
content: calls[0]?.body.content,
spans: calls[0]?.body.spans,
createdAt: '2026-10-04 22:32:04+03',
},
},
);
itd.use(crypt());
await itd.posts.create({
content: 'видно секрет',
spans: [{ type: 'crypto', cipher: 'invisible', offset: 6, length: 6 }],
});

const post = await itd.posts.get('repost');

expect(post.originalPost?.decoded?.content?.text).toBe('видно секрет');
expect(post.originalPost?.createdAt).toBe('2026-10-04T19:32:04.000Z');
});

it('заменяет crypto span на frame и восстанавливает plaintext без мутации входа', async () => {
const inputSpans: Span[] = [
{ type: 'bold', offset: 6, length: 6 },
Expand Down
1 change: 1 addition & 0 deletions packages/hydrate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
type HydratedNotificationFilter,
type HydratedNotificationSelector,
type HydratedNotificationsResource,
type HydratedOriginalPost,
type HydratedPage,
type HydratedPaginator,
type HydratedPost,
Expand Down
63 changes: 36 additions & 27 deletions packages/hydrate/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
NotificationUpdate,
NotificationUpdateOfType,
NotificationUpdateType,
OriginalPost,
Page,
Paginator,
Post,
Expand Down Expand Up @@ -253,6 +254,12 @@ export type HydratedComment<T extends Comment = Comment> = HydratedModel<T, Hydr
/** Пост с действиями и гидратированными вложенными моделями. */
export type HydratedPost<T extends Post = Post> = HydratedModel<T, HydratedPostActions>;

/** Пост, на который ссылается репост, с теми же методами, что и у поста. */
export type HydratedOriginalPost<T extends OriginalPost = OriginalPost> = HydratedModel<
T,
HydratedPostActions
>;

/** Ссылка на комментарий из уведомления. */
export type HydratedCommentReference = Readonly<{ id: string }> & HydratedCommentActions;

Expand Down Expand Up @@ -306,33 +313,35 @@ export type HydrateValue<T> = T extends
? HydratedPage<Item>
: T extends Post
? HydratedPost<T>
: T extends Comment
? HydratedComment<T>
: T extends Notification
? HydratedNotification<T>
: T extends ShopDeliveryCity
? HydratedShopDeliveryCity<T>
: T extends MyProfile | PublicProfile
? HydratedProfile<T>
: T extends Author
? HydratedAuthor<T>
: T extends UserSummary
? HydratedUserSummary<T>
: T extends Actor
? HydratedActor<T>
: T extends CommentReplyTo
? HydratedCommentReplyTo<T>
: T extends Attachment
? HydratedAttachment<T>
: T extends { userId: string } | { username: string }
? HydratedUserReference<T>
: T extends readonly unknown[]
? { [Key in keyof T]: HydrateValue<T[Key]> }
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [Key in keyof T]: HydrateValue<T[Key]> }
: T;
: T extends OriginalPost
? HydratedOriginalPost<T>
: T extends Comment
? HydratedComment<T>
: T extends Notification
? HydratedNotification<T>
: T extends ShopDeliveryCity
? HydratedShopDeliveryCity<T>
: T extends MyProfile | PublicProfile
? HydratedProfile<T>
: T extends Author
? HydratedAuthor<T>
: T extends UserSummary
? HydratedUserSummary<T>
: T extends Actor
? HydratedActor<T>
: T extends CommentReplyTo
? HydratedCommentReplyTo<T>
: T extends Attachment
? HydratedAttachment<T>
: T extends { userId: string } | { username: string }
? HydratedUserReference<T>
: T extends readonly unknown[]
? { [Key in keyof T]: HydrateValue<T[Key]> }
: T extends (...args: never[]) => unknown
? T
: T extends object
? { [Key in keyof T]: HydrateValue<T[Key]> }
: T;

/** Событие уведомления с гидратированной моделью. */
export type HydratedNotificationEvent = HydratedModel<NotificationEvent>;
Expand Down
Loading
Loading