Перейти к содержанию

Потоки запросов (Request Flows)

Детальное описание всех потоков данных между Panel, API-шлюзом, Zitadel, Lago и Typesense.


Содержание

  1. Общая цепочка middleware
  2. Аутентификация
  3. 2.1 Panel Login (V2 Session)
  4. 2.2 Panel Register
  5. 2.3 Bearer JWT (SDK / M2M)
  6. 2.4 Scoped API Key (клиентский JS)
  7. 2.5 Auth.js OIDC (legacy)
  8. 2.6 Session Check (Panel)
  9. Поиск
  10. 3.1 POST /api/v1/search
  11. 3.2 Scoped Search Key (генерация)
  12. Typesense Engine Proxy
  13. 4.1 Создание коллекции
  14. 4.2 Запрос через Engine Proxy
  15. 4.3 Feature Detection в прокси
  16. Документы
  17. Биллинг
  18. 6.1 Billing Gate (проверка подписки)
  19. 6.2 Feature Gate (проверка фич)
  20. 6.3 Rate Limiting
  21. 6.4 Lago Webhooks
  22. 6.5 Usage Tracking
  23. 6.6 Dashboard (использование)
  24. Data Sources (краулинг)
  25. Reindex (переиндексация)
  26. AI / RAG
  27. Cron (планировщик)
  28. Panel → API маппинг

1. Общая цепочка middleware

Каждый HTTP-запрос к api.aacsearch.com проходит через цепочку в строгом порядке. Шаг может прервать цепочку (throw HTTPException) или добавить данные в контекст.

graph TD
    REQ["Запрос → Cloudflare Workers Edge"]

    S0["<b>0. resolveSecrets(env)</b><br/>SecretBinding → строки (Promise.all 9)<br/>Результат: c.set('secrets', ResolvedSecrets)<br/>⚠ ДОЛЖЕН быть первым"]

    S1["<b>1. CORS</b><br/>Origins: app/platform/billing/docs.aacsearch.com<br/>Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS<br/>Headers: Content-Type, Authorization, Cookie, X-AACSEARCH-API-KEY<br/>Credentials: true &bull; OPTIONS → 204"]

    S2["<b>2. authConfig()</b><br/>Инициализация Auth.js + Zitadel OIDC<br/>AUTH_SECRET, ZITADEL_CLIENT_SECRET"]

    S3{"<b>3. Маршрутизация</b>"}

    PUB1["/api/auth/v2/* → authProxyRoutes"]
    PUB2["/api/auth/* → Auth.js OIDC"]
    PUB3["/webhooks/lago → без auth"]
    PUB4["/health, /doc → публичные"]

    S4["<b>4. tenantResolver()</b><br/>acme.aacsearch.com → slug='acme'<br/>tenantId из JWT/сессии на шаге 5"]

    S5["<b>5. apiAuth() — 4-факторная</b><br/>① X-AACSEARCH-API-KEY → verifyScopedSearchKey<br/>② Bearer JWT → verifyBearer (JWKS)<br/>③ Cookie aac_session → verifyV2Session<br/>④ Auth.js session → verifyAuth<br/>Результат: c.set('tenantId')<br/>⚠ 401 если ни один не сработал"]

    S6["<b>6. billingGate()</b><br/>search, documents, engine, ai, data-sources, reindex<br/>НЕ применяется к: billing/*, api-keys<br/>Результат: c.set('planCode')<br/>⚠ 402 нет подписки &bull; 502 Lago offline"]

    S7["<b>7. rateLimit()</b><br/>Все /api/v1/*<br/>⚠ 429 если лимит превышен"]

    S8["<b>8. featureGate(feature)</b><br/>/conversations → rag &bull; /synonym_sets → synonyms<br/>/curation_sets → curations &bull; /analytics → analytics_advanced<br/>/data-sources → data_sources &bull; /ai/nl-search → nl_search<br/>⚠ 403 если фича недоступна"]

    HANDLER["<b>Route Handler</b>"]

    REQ --> S0 --> S1 --> S2 --> S3
    S3 -->|"/api/v1/*"| S4
    S3 --> PUB1 & PUB2 & PUB3 & PUB4
    S4 --> S5 --> S6 --> S7 --> S8 --> HANDLER

    style REQ fill:#e3f2fd,color:#000
    style HANDLER fill:#e8f5e9,color:#000
    style S3 fill:#fff3e0,color:#000
    style PUB1 fill:#fce4ec,color:#000
    style PUB2 fill:#fce4ec,color:#000
    style PUB3 fill:#fce4ec,color:#000
    style PUB4 fill:#fce4ec,color:#000

2. Аутентификация

2.1 Panel Login (V2 Session)

sequenceDiagram
    participant P as Panel SPA
    participant A as API-шлюз
    participant Z as Zitadel

    P->>A: POST /api/auth/v2/login<br/>{email, password}
    Note over A: Извлечь email, password<br/>PAT = secrets.ZITADEL_SERVICE_ACCOUNT_TOKEN

    A->>Z: POST /v2/sessions<br/>Authorization: Bearer {PAT}<br/>checks: { user: { loginName: email } }
    Z-->>A: sessionId

    A->>Z: POST /v2/sessions/{sessionId}<br/>checks: { password: { password } }
    Z-->>A: sessionToken

    Note over A: Set-Cookie: aac_session={sessionId}:{sessionToken}<br/>Domain=.aacsearch.com; Secure; HttpOnly; SameSite=Lax

    A-->>P: 200 + Set-Cookie
    Note over P: Сохранить cookie → Dashboard

2.2 Panel Register

sequenceDiagram
    participant P as Panel SPA
    participant A as API-шлюз
    participant Z as Zitadel
    participant L as Lago

    P->>A: POST /api/auth/v2/register<br/>{email, password, givenName, familyName, orgName}

    A->>Z: POST /v2/users/human<br/>{username, profile, email, password, organization}
    Z-->>A: userId, orgId

    Note over A: Авто-логин (как в §2.1)
    A->>Z: POST /v2/sessions → sessionId
    A->>Z: POST /v2/sessions/{id} → check password
    Z-->>A: sessionToken

    Note over A: waitUntil (неблокирующе)
    A-)L: POST /customers<br/>{external_id: orgId, name: orgName, email}

    A-->>P: 200 + Set-Cookie: aac_session=...

2.3 Bearer JWT (SDK / M2M)

sequenceDiagram
    participant S as SDK / Machine User
    participant A as API-шлюз
    participant Z as Zitadel

    S->>A: GET /api/v1/engine/collections<br/>Authorization: Bearer jwt

    Note over A: apiAuth() → обнаружен Bearer<br/>verifyBearer():

    alt In-memory cache (15 мин TTL) HIT
        Note over A: Использовать кешированный JWKS
    else KV cache "zitadel:jwks" HIT
        Note over A: createLocalJWKSet()
    else MISS — Remote fetch
        A->>Z: GET {ZITADEL_JWKS_URI}
        Z-->>A: JWKS JSON
    end

    Note over A: jwtVerify(token, jwks, {issuer, audience})<br/>⚠ 401 если подпись невалидна

    Note over A: tenantId = jwt[resourceowner:id] ?? org_id<br/>⚠ 403 если tenantId отсутствует<br/><br/>planCode = jwt.plan_code (если есть)<br/>⚠ Machine users: plan_code ОТСУТСТВУЕТ<br/>→ определяется в billingGate

    Note over A: c.set("tenantId"), c.set("planCode")<br/>→ продолжение цепочки

2.4 Scoped API Key (клиентский JS)

sequenceDiagram
    participant B as Браузер (JS SDK)
    participant A as API-шлюз

    B->>A: GET /api/v1/search?q=hello<br/>X-AACSEARCH-API-KEY: scopedKey

    Note over A: apiAuth() → обнаружен X-AACSEARCH-API-KEY<br/>verifyScopedSearchKey(parentKey, scopedKey):

    Note over A: 1) base64 → digest + prefix + JSON<br/>2) HMAC-SHA256(parentKey, JSON) == digest<br/>3) prefix == parentKey[0:4]<br/>4) expires_at > Date.now()<br/>⚠ 401 если невалидный / просрочен

    Note over A: JSON: {collection: "t_12345__products",<br/>filter_by: "tenant_id:=12345", expires_at: ...}

    Note over A: tenantId = "12345" (из collection)<br/>c.set("scopedApiKey"), c.set("scopedCollection")<br/>→ запрос ограничен одной коллекцией

2.5 Auth.js OIDC (legacy)

sequenceDiagram
    participant B as Браузер
    participant A as API-шлюз
    participant Z as Zitadel

    B->>A: GET /api/auth/signin/zitadel
    A-->>B: Redirect → {issuer}/oauth/v2/authorize<br/>?response_type=code&scope=openid resourceowner

    B->>Z: Redirect to Zitadel Login UI
    Note over Z: Ввод email/пароль<br/>Zitadel Action "inject-plan-code":<br/>ctx.v1.org.getMetadata()<br/>→ plan_code → JWT claim
    Z-->>B: Redirect → callback?code=auth_code

    B->>A: GET /api/auth/callback/zitadel?code=...
    A->>Z: POST /oauth/v2/token<br/>grant_type=authorization_code
    Z-->>A: access_token (JWT с plan_code)

    Note over A: JWT callback:<br/>token.org_id = profile[resourceowner:id]<br/>token.plan_code = profile["plan_code"]

    A-->>B: Set-Cookie: auth.js session

2.6 Session Check (Panel)

sequenceDiagram
    participant P as Panel SPA
    participant A as API-шлюз
    participant Z as Zitadel

    Note over P: authProvider.checkAuth()<br/>(каждые 30 сек, кеш на клиенте)

    P->>A: GET /api/auth/v2/session<br/>Cookie: aac_session={sessionId}:{sessionToken}

    Note over A: Извлечь sessionId из cookie

    alt KV cache "session:{sessionId}" HIT (TTL 5 мин)
        A-->>P: 200 {userId, orgId, displayName}
    else KV MISS
        A->>Z: GET /v2/sessions/{sessionId}<br/>Authorization: Bearer {PAT}

        alt Zitadel 200
            Z-->>A: session data
            Note over A: tenantId = session.factors.user.organizationId<br/>userId = session.factors.user.id<br/>KV PUT "session:{sessionId}" TTL=300
            A-->>P: 200 {userId, orgId, displayName, email}
        else Zitadel 401/403
            Z-->>A: ошибка авторизации
            Note over A: ⚠ Ответ 503 (НЕ 401!)<br/>чтобы Panel не сделал logout
            A-->>P: 503
            Note over P: Тихая ошибка, stale кеш
        else Zitadel 404
            Z-->>A: сессия не найдена
            Note over A: Сессия реально истекла
            A-->>P: 401
            Note over P: authProvider.logout()
        end
    end

3. Поиск

3.1 POST /api/v1/search

sequenceDiagram
    participant C as Клиент
    participant A as API-шлюз
    participant T as Typesense
    participant L as Lago

    C->>A: POST /api/v1/search<br/>Authorization: Bearer jwt<br/>{q, limit, page, filters}

    Note over A: [apiAuth] → tenantId = "org123"<br/>[billingGate] → planCode = "professional_v1"<br/>[rateLimit] → OK (< 500 req/min)

    Note over A: 1) Zod валидация<br/>q: string, limit: 1-100, page: ≥1<br/>filters: SAFE_FILTER_KEYS only

    Note over A: 2) buildFilterBy(tenantId, filters)<br/>→ "tenant_id:=org123 && status:=active && category:=news"<br/>⚠ Whitelist предотвращает инъекцию

    A->>T: POST /collections/documents/documents/search<br/>{q, query_by: "title,content", filter_by, per_page, page}
    T-->>A: Результаты поиска

    Note over A: 4) Маппинг: hit → {id, title, snippet, score}<br/>highlights → snippet с &lt;mark&gt; тегами

    A-)L: waitUntil: trackSearchRequest()<br/>(неблокирующе)

    A-->>C: {total: 150, hits: [{id, title, snippet, score}]}

3.2 Scoped Search Key (генерация)

sequenceDiagram
    participant P as Panel / SDK
    participant A as API-шлюз

    P->>A: POST /api/v1/api-keys/scoped<br/>Authorization: Bearer jwt<br/>{collection: "products", filters, ttlSeconds: 600}

    Note over A: [apiAuth: JWT only, scoped keys запрещены]<br/>tenantId = "org123"<br/>prefixedCollection = "t_org123__products"

    Note over A: generateScopedSearchKey():<br/>params = {collection: "t_org123__products",<br/>filter_by: "tenant_id:=org123 && status:=active",<br/>expires_at: now + 600}<br/>digest = HMAC-SHA256(parentKey, JSON)<br/>prefix = parentKey[0:4]<br/>key = base64(digest + prefix + JSON)

    A-->>P: {key: "base64...", expiresAt: "..."}

4. Typesense Engine Proxy

4.1 Создание коллекции

sequenceDiagram
    participant P as Panel
    participant A as API-шлюз
    participant T as Typesense

    P->>A: POST /api/v1/engine/collections<br/>Cookie: aac_session=...<br/>{name: "products", fields: [...]}

    Note over A: [apiAuth] → tenantId = "org123"<br/>[billingGate] → planCode<br/>[rateLimit] → OK

    Note over A: 1) Перезапись тела:<br/>body.name = "t_org123__products"

    A->>T: POST /collections<br/>{name: "t_org123__products", fields: [...]}
    T-->>A: Коллекция создана

    Note over A: 3) Перезапись ответа:<br/>response.name = "products"<br/>(удаление префикса)

    A-->>P: {name: "products", fields: [...], num_documents: 0}

4.2 Запрос через Engine Proxy

Любой /api/v1/engine/* запрос проходит через 5 этапов:

Этап Действие Пример
1. URL rewrite Добавление префикса t_{tenantId}__ /engine/collections/products{TYPESENSE_URL}/collections/t_org123__products
2. Body rewrite POST/PUT/PATCH: body.name, body.collection, body.collection_name, multi_search: searches[].collection "products""t_org123__products"
3. Blocked paths /keys, /debug, /stats.json, /metrics.json, /operations, /analytics/flush 403 "Access denied"
4. Response rewrite Удаление t_{tenantId}__ из имён + фильтрация по тенанту "t_org123__products""products"
5. Error rewrite Typesense 401/403 → 502 Предотвращает logout Panel

4.3 Feature Detection в прокси

При каждом запросе через engine proxy определяются используемые фичи:

URL / Query / Body Фича Lago событие
_geoloc, field:(lat,lng,r km) geo_search geo_query
vector_query semantic_search semantic_query
image_query, image_embedding image_search image_query
voice_query voice_search voice_query
join, join_as joins join_query
group_by grouping
/multi_search multi_search multi_search_query
/conversations rag rag_query
/nl_search_models nl_search

Note

Фичи проверяются featureGate middleware ДО прокси. Usage events отправляются ПОСЛЕ успешного ответа через waitUntil().


5. Документы

Эндпоинты: POST /api/v1/documents (единичный), POST /api/v1/documents/bulk (до 1000, JSONL), DELETE /api/v1/documents/{id}

sequenceDiagram
    participant P as Panel / SDK
    participant A as API-шлюз
    participant T as Typesense
    participant L as Lago

    P->>A: POST /api/v1/documents<br/>{id, title, content, category}
    Note over A: [apiAuth] → tenantId<br/>[billingGate] → planCode<br/>[rateLimit] → OK

    Note over A: 1) Zod валидация<br/>2) Авто-поля: +tenant_id, +created_at,<br/>+type="document", +status="active", +lang="en"

    A->>T: POST /collections/t_org123__documents/documents?action=upsert
    T-->>A: OK

    A-)L: waitUntil: trackDocumentsIndexed(1)
    A-->>P: Документ создан

DELETE /api/v1/documents/{id}

  1. GET документ по ID
  2. Проверить tenant_id == tenantId (404 если чужой документ)
  3. DELETE из Typesense

6. Биллинг

6.1 Billing Gate (проверка подписки)

graph TD
    START["billingGate()<br/>tenantId = c.get('tenantId')"]

    subgraph L0["Уровень 0: Entitlement Cache"]
        KV0{"KV GET<br/>billing:{tenantId}:entitlement"}
        KV0_HIT_OK["HIT + ok:true<br/>planCode из кеша<br/>TTL 300с (активные)"]
        KV0_HIT_FAIL["HIT + ok:false<br/>TTL 60с (неактивные)"]
    end

    subgraph L1["Уровень 1: Webhook Marker"]
        KV1{"KV GET<br/>billing:{tenantId}:subscription_active"}
        KV1_HIT["HIT → JSON<br/>{external_id, plan_code, started_at}<br/>TTL 30 дней"]
    end

    subgraph L2["Уровень 2: Lago API (fallback)"]
        LAGO["lago.customers.<br/>findAllCustomerSubscriptions(tenantId)<br/>Таймаут: 5 сек"]
        LAGO_ACTIVE["Есть active подписка<br/>→ записать маркер + кеш"]
        LAGO_NONE["Нет active<br/>→ кеш negative (60с)"]
        LAGO_FAIL["Lago offline / таймаут"]
    end

    PASS["✅ PASS<br/>c.set('planCode')<br/>+ X-Billing-Warning headers"]
    E402["❌ 402<br/>Active subscription required"]
    E502["❌ 502<br/>Billing provider unavailable"]

    START --> KV0
    KV0 -->|"HIT ok:true"| KV0_HIT_OK --> PASS
    KV0 -->|"HIT ok:false"| KV0_HIT_FAIL --> E402
    KV0 -->|"MISS"| KV1
    KV1 -->|"HIT"| KV1_HIT --> PASS
    KV1 -->|"MISS"| LAGO
    LAGO --> LAGO_ACTIVE --> PASS
    LAGO --> LAGO_NONE --> E402
    LAGO --> LAGO_FAIL --> E502

    style PASS fill:#c8e6c9,color:#000
    style E402 fill:#ffcdd2,color:#000
    style E502 fill:#ffcdd2,color:#000
    style L0 fill:#e3f2fd22
    style L1 fill:#fff3e022
    style L2 fill:#fce4ec22

6.2 Feature Gate (проверка фич)

graph TD
    START["featureGate('synonyms')<br/>tenantId = c.get('tenantId')"]

    subgraph C["1. KV Cache"]
        KV{"KV GET<br/>entitlements:{tenantId}"}
        KV_HIT["HIT → features JSON<br/>TTL 5 мин<br/>10% early refresh в последние 60с"]
    end

    subgraph S["2. Subscription lookup"]
        SUB["KV billing:{tenantId}:subscription_active<br/>→ external_id<br/>ИЛИ Lago API fallback"]
    end

    subgraph L["3. Lago Entitlements"]
        LAGO["findAllSubscriptionEntitlements(id)<br/>→ [{code, privileges}]"]
        PARSE["privileges.find(p => p.code == 'enabled')<br/>override_value ?? value → boolean<br/>Записать KV TTL=300"]
    end

    CHECK_FEAT{"features[synonyms]?"}
    PASS["✅ PASS"]
    E403["❌ 403 Feature unavailable"]
    E402["❌ 402 No subscription"]

    START --> KV
    KV -->|"HIT"| KV_HIT --> CHECK_FEAT
    KV -->|"MISS"| SUB --> LAGO --> PARSE --> CHECK_FEAT
    CHECK_FEAT -->|"true"| PASS
    CHECK_FEAT -->|"false"| E403
    LAGO -->|"Нет entitlements"| E402

    style PASS fill:#c8e6c9,color:#000
    style E403 fill:#ffcdd2,color:#000
    style E402 fill:#ffcdd2,color:#000

6.3 Rate Limiting

graph TD
    START["rateLimit()"]
    TID{"tenantId?"}
    SKIP["PASS<br/>(health, webhooks)"]

    PLAN["planCode = c.get('planCode')<br/>Fallback: KV entitlement → KV subscription<br/>Default: starter_v1"]

    LIMITS["RATE_LIMITS:<br/>starter_v1: 100/мин<br/>professional_v1: 500/мин<br/>enterprise_v1: unlimited<br/>payg_v1: 1000/мин"]

    UNL{"enterprise?"}
    PASS_UNL["PASS"]

    COUNT["KV GET ratelimit:{tenantId}:{minute}<br/>→ current count"]

    CHECK{"current >= limit?"}

    BLOCK["❌ 429<br/>X-RateLimit-Remaining: 0"]
    PASS_OK["✅ PASS<br/>KV PUT current+1 (TTL 60с)<br/>X-RateLimit-Remaining: N"]

    START --> TID
    TID -->|"Нет"| SKIP
    TID -->|"Да"| PLAN --> LIMITS --> UNL
    UNL -->|"Да"| PASS_UNL
    UNL -->|"Нет"| COUNT --> CHECK
    CHECK -->|"Да"| BLOCK
    CHECK -->|"Нет"| PASS_OK

    style BLOCK fill:#ffcdd2,color:#000
    style PASS_OK fill:#c8e6c9,color:#000
    style PASS_UNL fill:#c8e6c9,color:#000
    style SKIP fill:#e0e0e0,color:#000

6.4 Lago Webhooks

sequenceDiagram
    participant L as Lago
    participant A as API-шлюз
    participant KV as KV Store
    participant Z as Zitadel

    L->>A: POST /webhooks/lago<br/>X-Lago-Signature: base64(HMAC)<br/>X-Lago-Unique-Key: {key}

    Note over A: 1) HMAC верификация<br/>base64(HMAC-SHA256(key, body))<br/>⚠ 401 если не совпадают

    A->>KV: GET "wh:{uniqueKey}"
    alt Дубль
        KV-->>A: HIT
        A-->>L: 200 "Already processed"
    else Новый
        KV-->>A: MISS
        A->>KV: PUT "wh:{uniqueKey}" TTL=7 дней
    end

    Note over A: 3) Обработка по типу
  • tenantId = subscription.external_customer_id
  • planCode = subscription.plan_code
  • KV PUT billing:{tenantId}:subscription_active{external_id, plan_code, started_at} TTL=30 дней
  • KV DELETE billing:{tenantId}:entitlement, entitlements:{tenantId}
  • waitUntil: POST {issuer}/management/v1/metadata/plan_code с x-zitadel-orgid → синхронизация plan_code в Zitadel
  • KV DELETE billing:{tenantId}:subscription_active
  • KV DELETE billing:{tenantId}:entitlement, billing:{tenantId}:plan_code, entitlements:{tenantId}
  • waitUntil: DELETE {issuer}/management/v1/metadata/plan_code
  • Очистка всех billing KV ключей
  • Обновление plan_code в KV и Zitadel metadata
Событие KV ключ TTL
invoice.paid_credit_added billing:{tenantId}:last_paid_credit 1 день
wallet.depleted billing:{tenantId}:wallet_depleted 1 час
payment_request.payment_failure billing:{tenantId}:payment_failure 7 дней
invoice.payment_overdue billing:{tenantId}:payment_overdue 7 дней

6.5 Usage Tracking

Все usage events отправляются неблокирующе через executionCtx.waitUntil().

sequenceDiagram
    participant H as Route Handler
    participant A as trackUsageEvent()
    participant KV as KV Store
    participant L as Lago

    H-)A: waitUntil(trackSearchRequest(env, secrets, tenantId, {query, results_count}))

    A->>KV: GET "billing:{tenantId}:subscription_active"
    alt HIT
        KV-->>A: external_id
    else MISS
        A->>L: Lago API fallback → external_id
    end

    A->>L: POST /events<br/>{transaction_id: UUID, external_subscription_id,<br/>code: "search_request", timestamp: Unix epoch,<br/>properties: {tenant_id, query, results_count}}

Важно

  • Lago timestamps ДОЛЖНЫ быть Unix epoch integer (Math.floor(Date.now() / 1000)), НЕ ISO 8601
  • Ошибки проглатываются — не блокируют основной ответ

6.6 Dashboard (использование)

sequenceDiagram
    participant P as Panel
    participant A as API-шлюз
    participant KV as KV Store
    participant L as Lago

    P->>A: GET /api/v1/billing/dashboard/usage<br/>Cookie: aac_session=...

    Note over A: [apiAuth] → tenantId<br/>[rateLimit] → OK<br/>⚠ НЕТ billingGate — billing доступен всегда

    A->>KV: subscription, entitlements, rate limit status, billing warnings
    A->>L: current_usage (фильтр по периоду)
    L-->>A: usage data

    A-->>P: {planCode, usage, features, rateLimit, billing, subscription}
Пример ответа
{
  "planCode": "professional_v1",
  "planName": "Professional",
  "usage": {
    "search_request": { "current": 1234, "limit": null },
    "documents_stored": { "current": 5000, "limit": 100000 }
  },
  "features": ["synonyms", "curations", "geo_search"],
  "rateLimit": { "limit": 500, "remaining": 423, "unlimited": false },
  "billing": {
    "paymentFailed": false,
    "paymentOverdue": false,
    "walletDepleted": false
  },
  "subscription": { "status": "active", "startedAt": "..." },
  "nextBillingDate": "2026-03-01"
}

Info

Panel authProvider.canAccess() использует features из этого ответа для скрытия/показа ресурсов в сайдбаре (кеш 60 сек).


7. Data Sources (краулинг)

sequenceDiagram
    participant P as Panel
    participant A as API-шлюз
    participant D as D1
    participant Q as Queue
    participant T as Typesense

    Note over P,A: Создание Data Source
    P->>A: POST /api/v1/data-sources<br/>{name, feed_url, feed_type, collection, schedule, field_mapping}
    Note over A: [apiAuth] → tenantId<br/>[billingGate] → planCode<br/>[featureGate("data_sources")] → OK<br/>SSRF protection: блок localhost, 127.*, 10.*, 192.168.*
    A->>D: INSERT INTO data_sources
    D-->>A: id: 1
    A-->>P: {id: 1, name: "Tech News", ...}

    Note over P,A: Ручной запуск синхронизации
    P->>A: POST /api/v1/data-sources/1/sync
    A->>D: INSERT INTO crawl_runs (status: "queued")
    A->>Q: CRAWL_QUEUE.send({type: "crawl", dataSourceId: 1, runId: 42, tenantId})
    A-->>P: {runId: 42, status: "queued"}

    Note over Q,T: Queue Consumer (async)
    Note over Q: resolveSecrets(env)<br/>executeCrawl():
    Q->>D: SELECT data_source
    D-->>Q: feed_url, field_mapping
    Note over Q: Fetch feed_url (etag/last-modified)<br/>Parse (RSS/Atom/JSON/CSV)<br/>Apply field_mapping<br/>Add tenant_id prefix
    Q->>T: POST /collections/t_org123__articles/documents/import?action=upsert
    T-->>Q: OK (47 docs)
    Q->>D: UPDATE crawl_runs SET status="completed", docs_processed=47
    Note over Q: waitUntil: trackDocumentsIndexed(47)

8. Reindex (переиндексация)

POST /api/v1/reindex{collection, type}

Тип Описание
full_rebuild Новая коллекция → alias swap (zero downtime)
full_refresh Truncate + перекраулить все data sources
source_reindex Перекраулить конкретный data source
filter_delete Удалить документы по фильтру
incremental_delta Только изменённые (etag/lastmod)
graph LR
    A["POST /reindex<br/>type: full_rebuild"] --> B["Создать<br/>t_{id}__products_rebuild_{ts}"]
    B --> C["Enqueue reindex_chunk<br/>для каждого data source"]
    C --> D["Chunk: краулинг +<br/>индексация в _rebuild"]
    D --> E["Все chunks готовы?"]
    E -->|"Да"| F["Alias swap"]
    F --> G["Удалить старую коллекцию"]

    style F fill:#c8e6c9,color:#000

Warning

Нельзя запустить 2 reindex для одной коллекции одновременно.


9. AI / RAG

Эндпоинт Feature Gate Действие
POST /api/v1/ai/conversations rag Typesense: POST /conversations/models, name = t_{tenantId}__conversation_{uuid}, OPENAI_API_KEY инжектируется
GET /api/v1/ai/presets Список AI-моделей: [{name: "gpt-4o", provider: "openai"}, ...]
POST /api/v1/ai/nl-search nl_search Typesense: POST /nl_search_models, OPENAI_API_KEY автоматически

10. Cron (планировщик)

sequenceDiagram
    participant CR as Cron Trigger
    participant W as API Worker
    participant D as D1
    participant Q as Queue

    CR->>W: */15 * * * *
    Note over W: resolveSecrets(env)

    W->>D: SELECT data_sources<br/>WHERE schedule IS NOT NULL<br/>AND next_run_at <= NOW()
    D-->>W: sources[]

    loop Для каждого source
        W->>D: INSERT crawl_runs (status: "queued")
        W->>Q: CRAWL_QUEUE.send({type: "crawl", ...})
        W->>D: UPDATE data_sources SET next_run_at = ...
    end
sequenceDiagram
    participant CR as Cron Trigger
    participant W as API Worker
    participant L as Lago
    participant T as Typesense

    CR->>W: 0 2 * * * (ежедневно 2:00 UTC)
    Note over W: resolveSecrets(env)

    W->>L: GET /customers (все клиенты)
    W->>T: GET /collections
    T-->>W: все коллекции с кол-вом документов

    Note over W: Агрегация по тенантам:<br/>t_org123__products: 5000<br/>t_org123__articles: 3000<br/>→ org123 total = 8000

    loop Для каждого тенанта
        W->>L: trackUsageEvent("documents_stored",<br/>{count: 8000})
    end

    Note over W: ⚠ Агрегация MAX —<br/>Lago хранит максимум за период

11. Panel → API маппинг

Как Panel (ra-core) транслирует CRUD-операции в HTTP-запросы.

authProvider

ra-core метод HTTP запрос Действие
login({email, password}) POST /api/auth/v2/login Создать V2 Session
logout() POST /api/auth/v2/logout Удалить сессию + cookie
checkAuth() GET /api/auth/v2/session Проверить cookie (кеш 30с)
checkError({status}) 401→logout, 402/403/429→keep session
getIdentity() из кеша checkAuth {displayName, email}
canAccess({resource}) GET /api/v1/billing/dashboard/usage features массив (кеш 60с) → RESOURCE_FEATURE_MAP[resource]

dataProvider (Engine ресурсы)

ra-core метод HTTP запрос Пример
getList("collections") GET /api/v1/engine/collections Список коллекций тенанта
getOne("collections", {id}) GET /api/v1/engine/collections/{id} Одна коллекция
create("collections", {data}) POST /api/v1/engine/collections Создать (body.name)
update("collections", {id, data}) PATCH /api/v1/engine/collections/{id} Обновить схему
delete("collections", {id}) DELETE /api/v1/engine/collections/{id} Удалить
getList("documents") GET /api/v1/engine/collections/{col}/documents/search?q=* Поиск с q=*
create("synonym-sets", {data}) PUT /api/v1/engine/synonym_sets/{id} Upsert по имени

dataProvider (Billing ресурсы)

ra-core метод HTTP запрос Ответ Lago
getList("invoices") GET /api/v1/billing/invoices { invoices: [...] }
getOne("invoices", {id}) GET /api/v1/billing/invoices/{id} { invoice: {...} }
getList("subscriptions") GET /api/v1/billing/subscriptions { subscriptions: [...] }
getList("wallets") GET /api/v1/billing/wallets { wallets: [...] }

dataProvider (Data Sources)

ra-core метод HTTP запрос
getList("data-sources") GET /api/v1/data-sources{ data: [...], total: N }
create("data-sources", {data}) POST /api/v1/data-sources
update("data-sources", {id, data}) PUT /api/v1/data-sources/{id}
delete("data-sources", {id}) DELETE /api/v1/data-sources/{id}

Обработка ошибок в Panel

HTTP код Panel действие
200 Успех
401 authProvider.logout() → редирект /login
402 НЕ logout; toast "Требуется оплата"
403 НЕ logout; toast "Фича недоступна в вашем плане"
429 НЕ logout; toast "Превышен лимит запросов"
502 НЕ logout; toast "Сервис временно недоступен"
503 НЕ logout; сохранить stale кеш