Перейти к основному содержимому

API-контракты

Зачем нужно

Документ фиксирует DTO, validation, permissions, error codes и примеры для ключевых endpoints identity. Если endpoint есть в api-map.md, его contract должен быть здесь или в профильном документе.

Endpoint coverage

api-map.md является endpoint registry. Этот файл не дублирует полный DTO для каждой CRUD-строки; все endpoints из api-map.md покрываются platform envelope, pagination, error и idempotency rules плюс DTO/feature docs из таблицы.

API map sectionEndpoint rows coveredContract source
Auth API/api/v2/identity/auth/*Auth; features/auth.md
Profile and users API/api/v2/identity/me*, /api/v2/identity/admin/users*User and profile, Admin users; features/users.md
RBAC API/api/v2/identity/admin/roles*, /api/v2/identity/admin/permissions, /api/v2/identity/admin/users/{id}/roles*, /api/v2/identity/me/permissionsRBAC; features/rbac.md
Family API/api/v2/identity/families*Family; features/family.md
Organizations API/api/v2/identity/organization*, /api/v2/identity/admin/organization*Organizations; features/organizations.md
OAuth/OIDC API/.well-known/*, /oauth/*, /api/v2/identity/admin/oauth/clients*OAuth/OIDC; features/oauth-server.md
Notifications, events, audit/api/v2/platform/notifications*, /api/v2/platform/admin/notifications*, /api/v2/identity/admin/audit-logs, /api/v2/identity/admin/eventsNotifications; features/notifications.md, events.md
Platform, plugins, transports/api/v2/platform/settings/public, /api/v2/platform/navigation, /api/v2/platform/pages, /api/v2/platform/admin/settings, /api/v2/platform/admin/plugins*, /api/v2/platform/admin/transportsPlugins, common platform DTO rules; features/admin.md, integrations.md

Общие DTO

Pagination

Query:

type PaginationQuery = {
page?: number; // default 1, min 1
perPage?: number; // default 25, min 1, max 200
sort?: string; // поле или -поле
};

Response meta:

type PaginationMeta = {
page: number;
perPage: number;
total: number;
};

Error

type ApiError = {
code: string;
message: string;
details?: Record<string, unknown>;
};

type ApiEnvelope<TData, TMeta = Record<string, unknown>> = {
data: TData | null;
meta?: TMeta;
error?: ApiError;
};

Error codes

КодHTTPКогда
platform.validation_failed400неверный DTO
identity.auth.invalid_credentials401неверный логин/пароль/код
identity.auth.flow_expired400auth flow истёк
identity.auth.code_expired400verification/OAuth code истёк
identity.auth.code_consumed400code уже использован
identity.auth.rate_limited429превышен лимит
identity.session.revoked401session отозвана
identity.refresh_token.reuse_detected401повторный refresh token
platform.forbidden403нет permission
platform.not_found404объект не найден или скрыт
platform.conflict409уникальность/состояние не позволяет действие
identity.organization.duplicate_possible409похожая организация уже существует
identity.organization.not_claimed409у организации нет подтверждённого владельца
identity.organization.already_claimed409организация уже имеет владельца
identity.organization.disputed409организация в спорном статусе
identity.organization.merged409организация склеена с другой организацией
identity.organization.owner_required400active-организация не может остаться без владельца
identity.organization.membership_not_active403membership не активен
identity.organization.ownership_claim_pending409заявка на владение уже ожидает решения
identity.organization.transfer_expired400передача владения истекла
identity.organization.merge_forbidden403merge доступен только системному администратору
identity.organization_student.duplicate_possible409найден похожий ученик организации
identity.oauth.invalid_client401client невалиден
identity.oauth.invalid_request400неверный OAuth request
identity.oauth.invalid_scope400scope не разрешён
platform.plugin.validation_failed400manifest/contract невалиден

Auth

POST /api/v2/identity/auth/register

Permission: public. Rate limit: auth.register.

Request:

type RegisterRequest = {
identifier: string; // email или phone
password: string;
displayName?: string;
locale?: string;
timezone?: string;
acceptTerms: boolean;
};

Validation:

  • identifier обязателен, email или E.164 phone.
  • password соответствует password policy.
  • acceptTerms = true.

Response 201:

type RegisterResponse = {
user: UserSummary;
accessToken: string;
refreshToken?: string;
requiresContactVerification: boolean;
};

Audit/events: identity.user.created, identity.user.registered, identity.login.succeeded.

POST /api/v2/identity/auth/flow/start

Request:

type AuthFlowStartRequest = {
flowType: 'login' | 'register' | 'reset_password';
identifier?: string;
returnTo?: string;
};

Response:

type AuthFlowStartResponse = {
flowId: string;
currentStep: string;
expiresAt: string;
availableMethods: string[];
maskedDestination?: string;
};

POST /api/v2/identity/auth/flow/next

Request:

type AuthFlowNextRequest = {
flowId: string;
step: string;
payload: Record<string, unknown>;
};

Response:

type AuthFlowNextResponse =
| { status: 'next'; currentStep: string; payload?: Record<string, unknown> }
| { status: 'completed'; user: UserSummary; accessToken: string; refreshToken?: string };

POST /api/v2/identity/auth/refresh

Request:

type RefreshRequest = {
refreshToken?: string; // отсутствует при cookie-based режиме
};

Response:

type RefreshResponse = {
accessToken: string;
refreshToken?: string;
expiresIn: number;
};

Правила:

  • refresh token хранится как hash;
  • успешный refresh всегда ротирует token;
  • reuse старого token переводит token family в compromised.

Password reset

POST /api/v2/identity/auth/password/reset/start

type PasswordResetStartRequest = {
identifier: string;
};

Response всегда нейтральный:

type PasswordResetStartResponse = {
accepted: true;
};

POST /api/v2/identity/auth/password/reset/confirm

type PasswordResetConfirmRequest = {
code: string;
newPassword: string;
};

User and profile

type UserSummary = {
id: string;
status: 'pending' | 'active' | 'blocked' | 'deleted';
displayName: string;
avatarUrl?: string;
locale: string;
timezone: string;
};

GET /api/v2/identity/me

Response:

type MeResponse = {
user: UserSummary;
emails: ContactDto[];
phones: ContactDto[];
roles: EffectiveRoleDto[];
familyContexts: FamilyContextDto[];
organizationContexts: OrganizationContextDto[];
};

PATCH /api/v2/identity/me/profile

type UpdateProfileRequest = {
displayName?: string;
firstName?: string;
lastName?: string;
avatarUrl?: string | null;
locale?: string;
timezone?: string;
fields?: Record<string, unknown>;
};

Admin users

GET /api/v2/identity/admin/users

Permission: identity.users.read.

Query:

type UsersListQuery = PaginationQuery & {
q?: string;
status?: 'pending' | 'active' | 'blocked' | 'deleted';
role?: string;
organizationId?: string;
createdFrom?: string;
createdTo?: string;
};

POST /api/v2/identity/admin/users

Permission: identity.users.manage.

type AdminCreateUserRequest = {
email?: string;
phone?: string;
displayName: string;
password?: string;
status?: 'pending' | 'active';
roles?: Array<{ roleKey: string; scopeType: 'global' | 'organization' | 'team'; scopeId?: string }>;
mustChangePassword?: boolean;
};

RBAC

POST /api/v2/identity/admin/users/{id}/roles

Permission: identity.roles.assign.

type AssignRoleRequest = {
roleKey: string;
scopeType: 'global' | 'organization' | 'team';
scopeId?: string;
expiresAt?: string;
};

Validation:

  • scopeId обязателен для organization и team;
  • назначающий пользователь должен иметь право назначать роль в этом scope;
  • нельзя назначить системную роль выше собственного уровня.

Audit: identity.role.assigned.

Family

POST /api/v2/identity/families/{id}/delegated-sessions

type StartDelegatedSessionRequest = {
studentProfileId: string;
serviceKey?: string;
};

Validation:

  • actorUserId определяется из JWT sub и не принимается от клиента;
  • studentProfileId обязателен и должен принадлежать familyId;
  • текущий user должен быть active adult этой семьи;
  • serviceKey задаёт optional service/domain scope, но не является доступом к сервису сам по себе;
  • linked subjectUserId возвращается только если у student_profile есть linked_user_id.

Response:

type DelegatedSessionResponse = {
id: string;
actorUserId: string;
subjectStudentProfileId: string;
subjectUserId?: string;
familyGroupId: string;
serviceKey?: string;
expiresAt: string;
};

POST /api/v2/identity/families/{familyId}/student-profiles/{studentProfileId}/device-authorizations

type ChildDeviceAuthorizationStatus =
| 'created'
| 'approved'
| 'completed'
| 'expired'
| 'revoked';

type CreateChildDeviceAuthorizationRequest = {
deviceFingerprintHash?: string;
requestCode?: string;
qrToken?: string;
};

type ChildDeviceAuthorizationDto = {
id: string;
familyGroupId: string;
studentProfileId: string;
linkedUserId?: string;
approvedByUserId?: string;
deviceBindingId?: string;
status: ChildDeviceAuthorizationStatus;
requestedAt: string;
approvedAt?: string;
completedAt?: string;
expiresAt: string;
revokedAt?: string;
};

Validation:

  • создать запрос можно только для student_profile с linked_user_id;
  • request/QR code хранится только как hash;
  • до approval это pre-auth / pending flow и не создаёт user session.

POST /api/v2/identity/families/{familyId}/device-authorizations/{authorizationId}/approve

type ApproveChildDeviceAuthorizationRequest = {
deviceName?: string;
};

Validation:

  • текущий user должен быть active adult той же семьи;
  • approval не создаёт delegated session и не меняет actor context взрослого.

POST /api/v2/identity/families/{familyId}/device-authorizations/{authorizationId}/complete

type CompleteChildDeviceAuthorizationRequest = {
authorizationToken: string;
deviceFingerprintHash?: string;
};

type CompleteChildDeviceAuthorizationResponse = {
sessionId: string;
linkedUserId: string;
studentProfileId: string;
refreshTokenIssued: boolean;
};

Validation:

  • completion доступен только по валидному authorization token/code;
  • результатом является user_session linked user ребёнка;
  • audit пишет identity.login.succeeded и identity.child_device_authorization.completed.

POST /api/v2/identity/families/{familyId}/device-authorizations/{authorizationId}/revoke

type RevokeChildDeviceAuthorizationRequest = {
reason?: string;
};

Organizations

Shared DTO

type OrganizationStatus =
| 'draft'
| 'active'
| 'unclaimed'
| 'disputed'
| 'archived'
| 'merged';

type OrganizationType =
| 'systematika'
| 'school'
| 'private_school'
| 'club'
| 'education_center'
| 'commercial_center'
| 'franchise'
| 'foreign_organization'
| 'other';

type OrganizationSummary = {
id: string;
type: OrganizationType;
name: string;
countryId?: string;
regionId?: string;
settlement?: string;
status: OrganizationStatus;
ownerMembershipId?: string;
duplicateStatus: 'none' | 'possible_duplicate' | 'confirmed_duplicate' | 'merged';
};

References

GET /api/v2/identity/organization-references/search

type SearchOrganizationReferencesRequest = {
countryId: string;
regionId?: string;
settlement?: string;
q: string;
includeExistingOrganizations?: boolean;
};

type SearchOrganizationReferencesResponse = {
references: Array<{
id: string;
name: string;
normalizedName: string;
countryId: string;
regionId?: string;
settlement: string;
}>;
organizations: OrganizationSummary[];
};

POST /api/v2/identity/organizations

type CreateOrganizationRequest = {
type: OrganizationType;
name: string;
countryId?: string;
regionId?: string;
settlement?: string;
referenceId?: string;
contactEmail?: string;
contactPhone?: string;
comment?: string;
};

Validation:

  • если referenceId передан, он должен указывать на active organization_reference;
  • если похожая organization найдена, API возвращает identity.organization.duplicate_possible с candidates;
  • создание не даёт owner-права без membership/claim flow.

POST /api/v2/identity/organizations/{id}/invitations

Permission: identity.organization-invitations.create.organization.

type CreateOrganizationInvitationRequest = {
deliveryChannel: 'link' | 'email' | 'cabinet' | 'sms';
email?: string;
phone?: string;
invitedUserId?: string;
proposedRoleId: string;
expiresAt?: string;
};

Validation:

  • deliveryChannel = 'email' requires email;
  • deliveryChannel = 'sms' requires phone;
  • deliveryChannel = 'cabinet' requires invitedUserId;
  • deliveryChannel = 'link' may be link-only without email/phone/user.

Idempotency: same pending invitation for same contact/user + organization returns existing invitation.

Membership request

type CreateMembershipRequest = {
comment?: string;
};

type ApproveMembershipRequest = {
roleId: string;
decisionComment?: string;
};

Ownership claim

type CreateOwnershipClaimRequest = {
applicantFullName: string;
applicantPosition?: string;
applicantComment?: string;
evidenceLinks?: string[];
};

type ReviewOwnershipClaimRequest = {
decision: 'approved' | 'rejected' | 'needs_more_info' | 'disputed';
decisionComment?: string;
ownerRoleId?: string;
};

Ownership transfer

type CreateOwnershipTransferRequest = {
toMembershipId?: string;
toEmail?: string;
oldOwnerNewRoleId: string;
expiresAt?: string;
};

Validation: exactly one of toMembershipId or toEmail is required.

Organization students

type CreateOrganizationStudentRequest = {
lastName: string;
firstName: string;
middleName?: string;
grade: number;
classLetter?: string;
duplicateNote?: string;
};

type UpdateOrganizationStudentRequest = Partial<CreateOrganizationStudentRequest> & {
status?: 'active' | 'archived' | 'possible_duplicate';
};

If duplicate candidates exist, response may return identity.organization_student.duplicate_possible with candidate ids. User can retry with duplicateNote.

Merge and grants

type CreateOrganizationMergeRequest = {
primaryOrganizationId: string;
duplicateOrganizationId: string;
reason: string;
};

type CreatePermissionGrantRequest = {
subjectType: 'membership' | 'team' | 'role';
subjectId: string;
permission: string;
resourceType?: string;
resourceId?: string;
effect: 'allow' | 'deny';
startsAt?: string;
expiresAt?: string;
};

Validation:

  • permission must exist in packages/permissions/catalog.ts;
  • local namespaces such as organization.*, students.*, olympiad.*, problem_bank.* are rejected;
  • merge requires system admin permission and impact preview before complete.

OAuth/OIDC

Authorization request

GET /oauth/authorize

Query:

type OAuthAuthorizeQuery = {
response_type: 'code';
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
nonce?: string;
code_challenge: string;
code_challenge_method: 'S256';
prompt?: 'none' | 'login' | 'consent';
};

Token request

POST /oauth/token

type OAuthTokenRequest =
| {
grant_type: 'authorization_code';
code: string;
redirect_uri: string;
client_id: string;
code_verifier: string;
client_secret?: string;
}
| {
grant_type: 'refresh_token';
refresh_token: string;
client_id: string;
client_secret?: string;
};

Response:

type OAuthTokenResponse = {
access_token: string;
id_token?: string;
refresh_token?: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
};

Notifications

Platform-owned notifications временно хостятся identity-api; canonical endpoint set зафиксирован в features/notifications.md и зарегистрирован в api-map.md.

PATCH /api/v2/platform/notifications/{id}/read

Request body отсутствует. Response использует общий ApiEnvelope<NotificationDto>.

type NotificationDto = {
id: string;
ruleId?: string;
title: string;
message: string;
priority: 'normal' | 'high' | 'critical';
isRead: boolean;
createdAt: string;
};

PATCH /api/v2/platform/notifications/settings/{ruleId}

type NotificationChannel = 'in_app' | 'email' | 'sms' | 'push' | 'telegram';

type NotificationUserSettingDto = {
ruleId: string;
messageType: string;
name: Record<string, string>;
enabled: boolean;
channels: NotificationChannel[];
defaultChannels: NotificationChannel[];
requiredChannels?: NotificationChannel[];
};

type NotificationUserSettingPatchRequest = {
enabled?: boolean;
channels?: NotificationChannel[] | null;
};

/api/v2/platform/admin/notifications/rules

type NotificationRuleUpsertRequest = {
messageType: string;
name: Record<string, string>;
description?: Record<string, string>;
conditions?: Record<string, unknown>;
templateId?: string;
channels: NotificationChannel[];
recipientType: 'event_user' | 'admins' | 'org_owner' | 'org_members' | 'custom';
recipientIds?: string[];
isActive?: boolean;
};

Plugins

POST /api/v2/platform/admin/plugins/install

Permission: platform.plugins.install.

Request: multipart upload or JSON source descriptor.

type PluginInstallRequest = {
sourceType: 'upload' | 'local_path' | 'marketplace';
source: string;
expectedKey?: string;
};

Response:

type PluginInstallResponse = {
pluginId: string;
key: string;
status: 'installed' | 'failed';
validationErrors?: string[];
};

Готовность

  • каждый DTO имеет validation rules;
  • каждый endpoint имеет permission или public marker;
  • все security-sensitive endpoints имеют audit event;
  • примеры ошибок и idempotency behavior описаны для повторяемых команд.