Files
marketplaces/docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md
sdarbinyan 1c87a53f02 feat(identity): account-linking UI + Telegram-as-identity surface (FH-4.7, FH-4.6, FH-4.8)
FH-4.7 - AccountIdentitiesComponent under
features/website/account/identities/. Lists linked identities from
GET /me/identities, offers attach buttons only for OAuth providers not
already linked (reusing SocialLoginButtonComponent), detaches through
unlink(). Refuses to detach the last remaining identity - it is the only
way back in - with the control disabled and an explanatory title, matching
the backend's last-identity 409. Loading / error / ready states; a load
failure surfaces an error rather than rendering an empty account, and a
slot carries the identity-conflict message from PHASE-8 §2.3. 6 unit tests.

Not wired into a route: the storefront has no customer account area yet
and no live OAuth application to authorize against (FH-0.1). This is the
surface both depend on, buildable and tested now.

FH-4.6 (client + contract) - the gateway now separates the two provider
sets. SocialProvider (vk | yandex) is what has an OAuth authorize
redirect; ExternalIdentityProvider (adds telegram | max) is what can be
listed and unlinked. unlink() widened to the latter so Telegram detaches
through the same path as VK, with no second code path. The dev local
gateway seeds a Telegram identity so the linking screen is exercisable
before any real provider exists.

PHASE-8 §2.6 specifies the backend migration: a Telegram login writes an
ExternalIdentity row under the same uniqueness and identity-conflict rule
as VK, appears in /me/identities, is removable subject to the
last-identity 409, and keeps customer (marketplace_session) and admin
(bo_session) sessions as distinct cookies - closing the shared
customer/admin Telegram session the audit flagged. The identity row and
the messaging BotConversationBinding stay separate records.

FH-4.8 - PHASE-8 §3 now states email/phone OTP's position explicitly:
recovery when a linked messenger is unreachable and an addable second
factor, never the primary login, and one more identity on the same
customer rather than a parallel account.

262 tests pass. Build green, boundaries and cycles green, bundle scan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:36:36 +04:00

13 KiB
Raw Blame History

Phase 8 Backend Contract — Customer Identity, VK ID, MAX/Telegram Messaging

Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 8 (Sprints 8.18.5). Covers plan §2.9, §3.4, and all of §14 (the v3.1-only addition).

Status: ready to build. Sprint order fixed by Sprint 0.1 decision: VK ID first, then everything else ("do all after vk"). Sequence below follows that: identity core → VK ID → email/phone OTP → MAX/Telegram → Notification Orchestrator.


1. Entities

interface Customer {
  id: string;
  marketplaceId: string;      // or global identity strategy, tenant-configurable
  name?: string;
  email?: string;
  phone?: string;
  status: 'active' | 'suspended';
  createdAt: string;
}

interface ExternalIdentity {
  customerId: string;
  provider: 'vk_id' | 'yandex_id' | 'telegram' | 'max';   // yandex_id added 2026-08-21, FH-4.5
  providerUserId: string;
  email?: string;          // VK frequently returns none - never require it
  phone?: string;
  displayName?: string;
  verifiedAt: string;
  metadata: Record<string, unknown>;
  lastUsedAt: string;
}

interface ContactMethod {
  customerId: string;
  type: 'email' | 'phone';
  value: string;
  verifiedAt?: string;
}

interface ContactChannel {
  customerId: string;
  provider: 'telegram' | 'vk' | 'max';
  chatId: string;
  verified: boolean;
  notificationsEnabled: boolean;
  deliveryEnabled: boolean;
}

interface MessagingConsent {
  customerId: string;
  channel: string;
  purpose: 'marketing' | 'order_service_messages';
  grantedAt?: string;
  revokedAt?: string;
}

Telegram is demoted from sole identity to one ExternalIdentity provider among several — it must remain fully functional, just no longer the only path.

2. Sprint 8.2 — Social identity: VK ID first, Yandex ID second

Rewritten 2026-08-21 (FH-4.1FH-4.5). Previously this section specified a VK-only pair of endpoints where the callback took { code, codeVerifier } from the browser. Two changes: the surface is provider-agnostic, and the PKCE verifier stops travelling through the client.

2.1 Surface

GET  /api/identity/v1/{provider}/authorize?returnTo=   -> { url }  (or 302)
GET  /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
POST /api/identity/v1/{provider}/unlink                (authenticated)
GET  /api/identity/v1/me/identities                    (authenticated) -> ExternalIdentity[]

{provider} is vk or yandex today; telegram and max join it when §4 migrates them onto ExternalIdentity. One controller, one strategy object per provider. The frontend gateway is a single interface (src/app/core/identity/services/social-identity-gateway.interface.ts) — providers differ only in a path segment, because everything that actually differs between them is backend-side.

2.2 The backend owns state and the code verifier

/authorize generates state and code_verifier, stores { state, codeVerifier, marketplaceId, returnTo, expiresAt } server-side or in a signed HttpOnly cookie, TTL 10 minutes, single use — deleted on first presentation. It returns (or redirects to) the provider URL carrying code_challenge (S256) and state.

/callback validates state, exchanges the code using the stored verifier, fetches the profile, resolves or links the ExternalIdentity, issues the customer session cookie (Track S §2.1), and redirects to returnTo.

  • The client never sees a client secret, an access token, or a code verifier. We are a confidential client; a browser-held verifier buys nothing and adds a place to steal it from.
  • An unknown, expired, or replayed state is a generic error. Do not distinguish the cases to the caller.
  • returnTo is validated against the tenant's own verified origin. It is an open redirect otherwise.

2.3 Identity resolution

  • A repeat login for the same providerUserId resolves to the same Customer, never a duplicate.
  • UNIQUE (provider, providerUserId). If a provider account is already bound to a different Customer, that is an identity conflict — route to controlled resolution, never silently rebind (plan §14.3). The unique index is what enforces this; a service-layer check is not sufficient.
  • Whether one VK account across two of our storefronts is one Customer or two is a product decision that must be made before implementation (FH-0.1). Customer.marketplaceId currently implies two, and two is the safer default for data protection.
  • Email is optional on Customer. Yandex returns one in most cases; VK frequently does not.

2.4 Per-tenant OAuth applications

Client id and secret are per marketplace, stored with the Track S §4.2 envelope: { clientId, clientSecret, scopes[], redirectUri }. Never returned by any endpoint.

The redirect_uri problem, which must be solved before any code is written. Both providers validate redirect_uri against an exact registered list. We cannot register one per tenant domain and we cannot let tenants supply their own. Resolution: one central identity host as the sole registered callback, the origin tenant carried inside the signed state, then a 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant API exchanges for its session cookie. This is a one-way door — retrofitting it after the first provider is live is expensive.

2.5 Provider notes

Confirm exact parameter and scope names against live provider documentation before implementing; both providers have revised their flows recently.

VK ID — OAuth 2.1, PKCE mandatory (S256).

authorize  GET  https://id.vk.com/authorize
                client_id, redirect_uri, response_type=code,
                code_challenge, code_challenge_method=S256, state, scope
token      POST https://id.vk.com/oauth2/auth
                grant_type=authorization_code, code, code_verifier,
                device_id, client_id, redirect_uri
profile    POST https://id.vk.com/oauth2/user_info
logout          https://id.vk.com/oauth2/logout      (call on unlink)

The callback returns device_id alongside code, and the token exchange fails without it. This is the single most common VK ID integration bug; it is in this contract so it is not rediscovered at debugging time.

Yandex ID — OAuth 2.0, PKCE supported; use it.

authorize  GET  https://oauth.yandex.ru/authorize
                response_type=code, client_id, redirect_uri, state,
                code_challenge, code_challenge_method=S256
token      POST https://oauth.yandex.ru/token
                grant_type=authorization_code, code, code_verifier
                HTTP Basic: client_id:client_secret
profile    GET  https://login.yandex.ru/info?format=json
                Authorization: OAuth <access_token>
                -> id, login, default_email, default_phone, psuid

Yandex is a second strategy object against the same surface, not a second integration. Build it after VK works.

2.6 Migrating Telegram onto ExternalIdentity (FH-4.6)

Added 2026-08-21. Telegram is the current app's only customer login and it does not go through §2's OAuth surface — it authenticates via a QR/bot flow owned by @marketplaces/auth. The migration makes it one identity among several without changing how it authenticates:

  • On a successful Telegram login the backend writes (or updates) an ExternalIdentity with provider: 'telegram' and providerUserId = the Telegram user id, under the same UNIQUE (provider, providerUserId) and same identity-conflict rule as §2.3. Telegram stops being a special-cased column and becomes a row like any other provider.
  • Telegram appears in GET /me/identities and is removable through POST /api/identity/v1/telegram/unlink, subject to the last-identity rule: the backend refuses to unlink a customer's only remaining identity (409, a login they cannot get back). The frontend gateway's unlink() already accepts any ExternalIdentityProvider, not just the OAuth ones, so the linking UI (FH-4.7) drives this without a second code path.
  • The customer/admin session split their audit flagged is closed here. A Telegram customer session (marketplace_session) and an admin session (bo_session) are different cookies checked by different guards (Track S §2.1); the same Telegram account authenticating as a customer must never satisfy an admin guard, and vice versa. Migrating Telegram to ExternalIdentity on the customer side does not grant it any admin scope — admin membership is a separate axis.
  • Telegram's messaging binding (§4 BotConversationBinding, delivery updates) is a contact channel, distinct from the identity row added here. One provider can be both; they are separate records so unlinking the identity does not silently kill an order's delivery conversation, and vice versa.

Client state today: SocialProvider (vk | yandex) is the set with an OAuth authorize redirect; ExternalIdentityProvider (vk_id | yandex_id | telegram | max) is the set that can be listed and unlinked. unlink() takes the wider type; getAuthorizeUrl() takes the narrower one. See src/app/core/identity/services/social-identity-gateway.interface.ts.

3. Sprint 8.3 — Email/phone OTP (after VK ID), positioned as recovery (FH-4.8)

Implements the already-approved email/phone login spec. Per v3.1 §14, position this as recovery/fallback when a messenger channel is unavailable — not the primary login path. No new contract beyond that spec; this section exists only to fix its place in the build order relative to VK ID.

Concretely, "recovery/fallback" means: email/phone OTP is offered as a way back in when a customer's linked messenger identity is unreachable, and as a second factor a customer can add — never as the front-and-centre first option on the login surface, which is VK ID (and Telegram, where it is already the norm). It is one more ExternalIdentity/ContactMethod on the same customer, not a parallel account.

4. Sprint 8.4 — MAX + Telegram bot channels

interface BotConversationBinding {
  customerId: string;
  marketplaceId: string;
  provider: 'telegram' | 'max';
  chatId: string;
  state: string;              // see §5 state machine
  orderId?: string;
  lastMessageAt: string;
}

MAX linking flow (bot-assisted, one-time code):

POST /api/identity/v1/max/link-code       -> { code, expiresAt }  (TTL, single-use, bound to marketplace + browser session)

User opens the MAX bot, sends the code; a confirmed bot update on the backend calls:

POST /api/providers/v1/max/bot-webhook    -- idempotent; a repeated update must not create a duplicate binding

which links the pending Customer session to the MAX chatId.

All three providers' incoming bot updates (VK, MAX, Telegram) normalize into one shape:

interface MessagingEvent {
  provider: 'telegram' | 'vk' | 'max';
  chatId: string;
  orderId?: string;
  text?: string;
  receivedAt: string;
}

Provider bot tokens/secrets never reach the frontend, ever — only the backend calls each provider's Bot API.

5. Sprint 8.5 — Notification Orchestrator + Delivery Conversation State Machine

On order.paid (Phase 2 event bus), the orchestrator picks the customer's chosen channel (captured at checkout, see Phase 6 and OrderContactSnapshot in Phase 2) and drives:

not_started -> awaiting_customer -> details_received -> manager_assigned/auto_confirmed -> shipment_planned -> completed
interface DeliveryDetailsSnapshot {
  orderId: string;
  city?: string;
  address?: string;
  recipientName?: string;
  phone?: string;
  timeWindow?: string;
  comment?: string;
  receivedAt: string;
}

Hard rules:

  • The bot never changes financial statuses. It can only write DeliveryDetailsSnapshot fields via a dedicated Delivery Service — no bot code path touches Order.paymentStatus/orderStatus.
  • The backoffice Notification (Phase 2 §6) fires unconditionally on order.paid, independent of whether the customer's messenger channel is reachable.
  • If the chosen channel is unavailable, log a DeliveryAttempt error (Phase 2 §6) and fall back per tenant-configured policy (e.g. email/SMS) — never block the order itself.
  • Follow-up messages are rate-limited per tenant policy; after the configured attempt limit, hand off to a human manager instead of continuing to message.
POST /api/providers/v1/{provider}/bot-webhook   -- generic entrypoint for all three providers
GET  /api/admin/v2/orders/{orderId}/conversation -- message history + current state, for manager handoff
POST /api/admin/v2/orders/{orderId}/conversation/handoff

6. What the frontend will start doing once this ships

  • VK ID and Yandex ID login buttons on the storefront. The client half already exists and is provider-agnostic: SocialLoginButtonComponent behind SOCIAL_IDENTITY_GATEWAY, with a real HTTP gateway waiting on §2.1's endpoints. Adding Yandex once VK works is a provider input, not new code.
  • Account linking screen (GET /me/identities, link/unlink), including the identity-conflict resolution path from §2.3.
  • MAX/Telegram linking UI (one-time code flow).
  • Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
  • Manager-facing conversation view (message history, current delivery state, accept handoff).