# Krona Universal Business OS — REST API Adapter Specification This document defines the architectural contract, header mappings, error translations, query serialization protocols, and the production `HttpBaseRepository` implementation required to transition the Krona Universal Business OS from client-side IndexedDB mock storage to a production-grade multi-tenant RESTful backend. --- ## 1. Architectural Overview The Krona platform follows a strict **Contract-First Hexagonal Architecture**. Frontend components, view renderers, and business logic interact exclusively with abstract contracts defined in `src/services/contracts/`: ``` ┌────────────────────────────────────────────────────────┐ │ React UI Layer │ │ (DynamicCardRenderer, DynamicTable, Kanban, etc.) │ └───────────────────────────┬────────────────────────────┘ │ IRepository Contract │ ┌───────────────┴───────────────┐ ▼ ▼ ┌───────────────────────┐ ┌───────────────────────┐ │ BaseMockRepository │ │ HttpBaseRepository │ │ (IndexedDB Mock) │ │ (REST API Client) │ └───────────────────────┘ └───────────────────────┘ ``` By decoupling storage from presentation, the transition from client-side prototype to cloud backend requires **zero changes** to UI components, hooks, or state managers. The application simply swaps the repository provider at runtime or build-time. --- ## 2. RequestContext & Multi-Tenancy Protocol Every read and write operation requires a `RequestContext`. The context guarantees multi-tenant storage isolation, auditability, and distributed tracing. ```typescript export interface RequestContext { tenantId: string; // Tenant / Workspace identifier (e.g. 'tenant_main', 'tenant_alpha') actorId: string; // Authenticated user ID (e.g. 'usr_01M26B3...') roleIds: string[]; // Active role keys (e.g. ['owner', 'director']) correlationId: string; // Monotonic ULID tracking the distributed call across microservices } ``` In the frontend, `RequestContext` is managed globally by `TenantProvider` (`useTenant()`) and created with default fallbacks via `createRequestContext()`. --- ## 3. HTTP Header Mapping When `HttpBaseRepository` communicates with the backend, it maps the `RequestContext` and authentication state into standardized HTTP headers: | Context Attribute | HTTP Header | Description / Format | Example | | :--- | :--- | :--- | :--- | | `tenantId` | `X-Tenant-ID` | The target workspace ID. Mandatory for tenant routing & RLS. | `tenant_alpha` | | `actorId` | `X-Actor-ID` | Authenticated actor/user ID. Used for audit logging & ACL. | `usr_01HX98Z...` | | `roleIds` | `X-Role-IDs` | Comma-separated active role identifiers for RBAC/ABAC. | `admin,operations_head` | | `correlationId` | `X-Correlation-ID` | Monotonic ULID for end-to-end distributed tracing. | `01M26B3GB2WERA5TANF94KRJAM` | | Auth State | `Authorization` | Standard JWT bearer token for identity verification. | `Bearer eyJhbGci...` | ### Security & Invariant Rules 1. **Tenant Header Enforcement**: If `X-Tenant-ID` is missing, the API Gateway immediately rejects the request with `400 Bad Request`. 2. **Cross-Tenant Verification**: The backend validates that the actor in `Authorization` has permissions within `X-Tenant-ID`. Unauthorized access yields `403 Forbidden`. 3. **Correlation Propagation**: The backend must echo `X-Correlation-ID` in all response headers and forward it across all asynchronous queues and downstream microservices. --- ## 4. Query Serialization Protocol `IRepository.list(options?: QueryOptions)` accepts pagination, search, sorting, and field-level filters. The HTTP adapter serializes these into URL query parameters according to the following specification: ### 4.1. Pagination - **Format**: `?page=&limit=` - **Example**: `?page=2&limit=50` (1-indexed page) ### 4.2. Sorting - **Format**: `?sort=:` - **Multiple Fields**: `?sort=:asc,:desc` - **Example**: `?sort=updatedAt:desc` ### 4.3. Global Text Search - **Format**: `?search=` - **Example**: `?search=ООО+Вектор` ### 4.4. Field Filtering - **Format**: `?filter[]=` - **Operator-based Filtering**: `?filter[][]=` - **Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains` - **Example**: ``` GET /api/v1/deal?page=1&limit=25&sort=createdAt:desc&filter[status]=won&filter[budget][gte]=1000000 ``` --- ## 5. Standard HTTP Error Code Translations The frontend contracts define domain error types (`TenantAccessDeniedError`, `NotFoundError`). `HttpBaseRepository` maps HTTP response status codes into typed domain errors: | Status Code | Backend Condition | Frontend Domain Error | UI Handling Strategy | | :--- | :--- | :--- | :--- | | **401 Unauthorized** | Missing or expired JWT token | `AuthenticationRequiredError` | Clears token, redirects to `/login` with return URL. | | **403 Forbidden** | Cross-tenant access attempt or insufficient roles | `TenantAccessDeniedError` | Displays access-denied banner; prevents destructive UI actions. | | **404 Not Found** | Record not found in current tenant | `NotFoundError` / `null` | Returns `null` on `getById`; surfaces 404 page for route views. | | **409 Conflict** | Optimistic locking violation (`version` mismatch) | `ConcurrencyConflictError` | Prompts user with "Data changed by another user" reload dialog. | | **422 Unprocessable** | Payload validation failed against field metadata | `ValidationError` | Maps backend field errors directly to React Hook Form field state. | | **5xx / Network** | Gateway timeout or server failure | `ServiceUnavailableError` | TanStack Query automatically retries with exponential backoff. | --- ## 6. Concrete Implementation: `HttpBaseRepository` Below is the production-ready TypeScript implementation of `HttpBaseRepository`, implementing `IRepository`: ```typescript import { IRepository, QueryOptions, PagedResult, QueryFilter, } from '../contracts/repository'; import { RequestContext, TenantAccessDeniedError, NotFoundError, createRequestContext, } from '../contracts/context'; import { BaseEntity } from '../../entities/types'; import { ulid } from '../../shared/lib/ulid'; export class HttpBaseRepository implements IRepository { protected baseUrl: string; constructor( protected entityName: string, baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1' ) { this.baseUrl = `${baseUrl.replace(/\/$/, '')}/${entityName}`; } protected resolveContext(context?: RequestContext): RequestContext { return context || createRequestContext(); } protected getAuthToken(): string | null { return typeof window !== 'undefined' ? localStorage.getItem('krona_auth_token') : null; } protected buildHeaders(context: RequestContext): HeadersInit { const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Tenant-ID': context.tenantId, 'X-Actor-ID': context.actorId, 'X-Role-IDs': context.roleIds.join(','), 'X-Correlation-ID': context.correlationId || ulid(), }; const token = this.getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } return headers; } protected async handleError(response: Response, context: RequestContext): Promise { const status = response.status; let payload: { message?: string; details?: unknown } = {}; try { payload = await response.json(); } catch { payload = { message: response.statusText }; } const message = payload.message || response.statusText; switch (status) { case 401: if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('krona:auth-expired')); } throw new Error(`[401 Unauthorized]: ${message}`); case 403: throw new TenantAccessDeniedError( `[403 Forbidden]: Отказано в доступе к «${this.entityName}» в тенанте «${context.tenantId}»: ${message}` ); case 404: throw new NotFoundError( `[404 Not Found]: Запись «${this.entityName}» не найдена: ${message}` ); case 409: throw new Error( `[409 Conflict]: Конфликт параллельного обновления записи «${this.entityName}»: ${message}` ); case 422: throw new Error( `[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}»: ${message}` ); default: throw new Error(`[${status} HTTP Error]: ${message}`); } } protected serializeQueryParams(options?: QueryOptions): string { if (!options) return ''; const params = new URLSearchParams(); if (options.page) params.set('page', String(options.page)); if (options.pageSize) params.set('limit', String(options.pageSize)); if (options.search) params.set('search', options.search); if (options.sort) { params.set('sort', `${String(options.sort.field)}:${options.sort.order}`); } if (options.filter) { for (const [key, value] of Object.entries(options.filter)) { if (value !== undefined && value !== null && value !== '') { if (typeof value === 'object' && !Array.isArray(value)) { for (const [op, opVal] of Object.entries(value as Record)) { params.set(`filter[${key}][${op}]`, String(opVal)); } } else { params.set(`filter[${key}]`, String(value)); } } } } const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } async getById(id: string, context?: RequestContext): Promise { const ctx = this.resolveContext(context); const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { method: 'GET', headers: this.buildHeaders(ctx), }); if (response.status === 404) { return null; } if (!response.ok) { await this.handleError(response, ctx); } return (await response.json()) as T; } async list(options?: QueryOptions, context?: RequestContext): Promise> { const ctx = this.resolveContext(context); const query = this.serializeQueryParams(options); const response = await fetch(`${this.baseUrl}${query}`, { method: 'GET', headers: this.buildHeaders(ctx), }); if (!response.ok) { await this.handleError(response, ctx); } return (await response.json()) as PagedResult; } async create(data: Omit, context?: RequestContext): Promise { const ctx = this.resolveContext(context); const response = await fetch(this.baseUrl, { method: 'POST', headers: this.buildHeaders(ctx), body: JSON.stringify(data), }); if (!response.ok) { await this.handleError(response, ctx); } return (await response.json()) as T; } async update(id: string, data: Partial, context?: RequestContext): Promise { const ctx = this.resolveContext(context); const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { method: 'PATCH', headers: this.buildHeaders(ctx), body: JSON.stringify(data), }); if (!response.ok) { await this.handleError(response, ctx); } return (await response.json()) as T; } async delete(id: string, context?: RequestContext): Promise { const ctx = this.resolveContext(context); const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { method: 'DELETE', headers: this.buildHeaders(ctx), }); if (response.status === 404) { return false; } if (!response.ok) { await this.handleError(response, ctx); } return true; } async count(filter?: QueryFilter, context?: RequestContext): Promise { const ctx = this.resolveContext(context); const query = this.serializeQueryParams({ filter, pageSize: 1 }); const response = await fetch(`${this.baseUrl}/count${query}`, { method: 'GET', headers: this.buildHeaders(ctx), }); if (!response.ok) { await this.handleError(response, ctx); } const data = (await response.json()) as { count: number }; return data.count; } async batchUpdate( items: { id: string; data: Partial }[], context?: RequestContext ): Promise { const ctx = this.resolveContext(context); const response = await fetch(`${this.baseUrl}/batch`, { method: 'PATCH', headers: this.buildHeaders(ctx), body: JSON.stringify({ items }), }); if (!response.ok) { await this.handleError(response, ctx); } return (await response.json()) as T[]; } } ``` --- ## 7. Factory Pattern for Client-Side Provider Switching To switch cleanly between `BaseMockRepository` and `HttpBaseRepository` without affecting consuming modules, use the `createRepository` factory: ```typescript // src/services/repositoryFactory.ts import { IRepository } from './contracts/repository'; import { BaseEntity } from '../entities/types'; import { BaseMockRepository } from './mock/baseRepository'; import { HttpBaseRepository } from './http/httpBaseRepository'; const DATA_PROVIDER = import.meta.env.VITE_DATA_PROVIDER || 'mock'; export function createRepository(entityName: string): IRepository { if (DATA_PROVIDER === 'http') { return new HttpBaseRepository(entityName); } return new BaseMockRepository(entityName); } ``` --- ## 8. Summary Checklist for Backend Integration - [x] Abstract contract `IRepository` accepts optional `RequestContext`. - [x] Standard HTTP headers configured (`X-Tenant-ID`, `X-Actor-ID`, `X-Role-IDs`, `X-Correlation-ID`). - [x] HTTP status code translation to domain errors (`TenantAccessDeniedError`, `NotFoundError`). - [x] URL query serialization for nested filters, sorting, search, and pagination. - [x] Zero changes required in UI components upon swapping the provider.