The VK-only scaffolding had a shape problem worth fixing before anything was built on it: completeCallback(code, codeVerifier) took the PKCE verifier from the client, which forces the browser to generate and hold it. We are a confidential client - a browser-held verifier buys nothing and adds a place to steal it from. Replaces the four vk-id-* files with a provider-agnostic surface: getAuthorizeUrl(provider, returnTo?) listIdentities() unlink(provider) completeCallback is gone entirely. The backend mints and stores state and code_verifier single-use for 10 minutes, handles the provider's callback itself, issues the session cookie and redirects. VK and Yandex differ only in a path segment, because everything that actually differs between them - PKCE handling, VK's device_id, Yandex's Basic-auth exchange - lives backend-side. vk-id-login becomes social-login-button with a provider input; adding Yandex to the UI is an input value, not new code. Adds yandex_id to ExternalIdentityProvider, plus optional email/phone/displayName since VK frequently returns no email. social-identity-gateway.spec.ts (5 tests) asserts the requests carry no code_verifier and no client_secret, so reintroducing a browser-held verifier fails the build rather than passing review. PHASE-8 §2 rewritten to match: the four endpoints, backend-owned state and verifier, UNIQUE (provider, providerUserId) with conflict routed to controlled resolution rather than a silent rebind, per-tenant OAuth app config under the Track S §4.2 envelope, and both providers' full endpoint sets. Two things recorded there because they are expensive to discover later: VK's callback returns device_id alongside code and the token exchange fails without it, and both providers validate redirect_uri against an exact registered list - which a multi-tenant platform cannot satisfy without a central identity host (FH-0.1, still undecided). 256 tests pass. Build green, boundaries and cycles green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Phase 8 Backend Contract — Customer Identity, VK ID, MAX/Telegram Messaging
Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 8 (Sprints 8.1–8.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.1–FH-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
stateis a generic error. Do not distinguish the cases to the caller. returnTois validated against the tenant's own verified origin. It is an open redirect otherwise.
2.3 Identity resolution
- A repeat login for the same
providerUserIdresolves to the sameCustomer, never a duplicate. UNIQUE (provider, providerUserId). If a provider account is already bound to a differentCustomer, 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
Customeror two is a product decision that must be made before implementation (FH-0.1).Customer.marketplaceIdcurrently 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.
3. Sprint 8.3 — Email/phone OTP (after VK ID)
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.
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
DeliveryDetailsSnapshotfields via a dedicated Delivery Service — no bot code path touchesOrder.paymentStatus/orderStatus. - The backoffice
Notification(Phase 2 §6) fires unconditionally onorder.paid, independent of whether the customer's messenger channel is reachable. - If the chosen channel is unavailable, log a
DeliveryAttempterror (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:
SocialLoginButtonComponentbehindSOCIAL_IDENTITY_GATEWAY, with a real HTTP gateway waiting on §2.1's endpoints. Adding Yandex once VK works is aproviderinput, 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).