Files
marketplaces/docs/FORK-ANALYSIS-2026-08-21.md
sdarbinyan d44565fae9
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
docs(backend): consolidate all backend contracts into one file
Collapses the entire docs/backend/ set - Phase 1-10, Track A/S, the
partner API, the two handoffs, the frontend surface inventory, and the
harvest requirements - into a single source of truth,
docs/backend/BACKEND-INTEGRATION.md.

Every contract's entities, endpoints, and invariants are preserved,
reorganised by domain rather than by sprint. The nine release
invariants, the FH-* harvest mechanisms, the RBAC/audit/secrets
cross-cutting rules, the tenant-routing infra contract, the 15
acceptance tests, build order, dev setup, and open decisions are all in
the one file, with a change log (§14) at the bottom.

The file opens with the maintenance rule: any new backend need, contract
change, or shipped item updates this file in the same change - the
affected section and the change log. No new backend .md files.

Inbound links from BACKEND-API-REFERENCE, the ADRs, the fork docs,
DEPLOYMENT, PACKAGES-USAGE, the delivery plan, and e2e/README are
repointed at the single doc (section anchors collapse to the file; the
prose section refs remain as context). Also recorded the rule in the
repo CLAUDE.md.

17 backend docs removed, 1 added. No implementation changes.

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

33 KiB
Raw Permalink Blame History

Fork Analysis — marketplaces-main.zip (hub.numus.cc/numus/marketplaces)

Date: 2026-08-21 Artifact analysed: C:\Users\darbi\Downloads\marketplaces-main.zip (4.48 MB, 13 MB extracted) Analysed against: this repo, branch B2B, HEAD 92f1c88 Canonical repo of the archive: ssh://git@hub.numus.cc:2222/numus/marketplaces.git, tag handoff-baseline-2026-08-11


0. Verdict in five lines

  1. This is not a fork of our repo. It is a separate monorepo — NestJS backend + PostgreSQL + two Angular apps + real infrastructure — that shares an older common ancestor with us (dexarmarket, Angular 21.2.18).
  2. They received our code on 11 Aug 2026, audited it, and parked it verbatim under reference/parallel-frontend/ with a SHA-256 fingerprint. They explicitly ruled it not production, and wrote a document listing what they will and will not take from us.
  3. They are ahead of us in exactly one dimension, and it is the decisive one: they have a backend, a database, RBAC, payments, tenancy by Host, publish/rollback revisions, and a deploy runbook that exists. We have contracts describing all of that and 22 mock gateways.
  4. We are ahead of them in exactly one dimension, and they admit it in writing: frontend depth and editor UX (530 .ts vs 189, 158 components, 30 spec files, boundary checker, Angular 22). Their backoffice is 45 files and still ships mock-data.service.ts.
  5. VK and Yandex login do not exist in their code. Zero references, backend and frontend. Section 8 covers what they actually have and gives the design to add VK ID + Yandex ID on our side.

1. What the archive actually contains

marketplaces/
├── platform-api/          NestJS 11 + Fastify + Prisma + PostgreSQL 17   (44 .ts, ~4 800 LOC)
├── backoffice/            Angular 21.2.18 admin + order-manager portal    (45 .ts)
├── marketplaces/          Angular 21.2.18 runtime storefront              (189 .ts)
├── infra/                 Docker Compose, Nginx, backup, domain automation (21 files)
├── docs/                  11 canonical documents, ~1 090 lines, Russian
└── reference/
    └── parallel-frontend/ ← OUR REPO, verbatim, 791 files

1.1 Lineage — read this carefully

  • marketplaces/package.json is named dexarmarket, Angular 21.2.18, with brand configs dexar / novo / lavero.
  • Our package.json is named dexarmarket, Angular 22.0.8.
  • reference/parallel-frontend/package.json is our current code, Angular 22.0.8, with our arch:check scripts.

Common ancestor. They branched earlier and went backend-first; we stayed frontend and went deep. reference/parallel-frontend/SOURCE_MANIFEST.md records:

Source: marketplaces-main.zip, received 11 August 2026. SHA-256 4d2d990416f573791b09df9ca8c02266432b5ddf213b9ed38c0bced1f19d854f. 745 files in src/, 26 in public/, 1 in tools/.

They stripped our sprint reports and internal task docs and replaced them with their own summary. Our source they copied unchanged.

1.2 They are not "overtaking us" — they were handed the platform mandate

docs/DEVELOPER_HANDOFF.md is a handover-to-a-new-developer document, with a priority ladder that puts our work last:

  1. Security, tenant isolation and financial correctness.
  2. docs/PRODUCT_SPECIFICATION.md.
  3. Real models and invariants of platform-api.
  4. Existing confirmed production scenarios.
  5. UI/UX patterns of the parallel implementation.

That is the political read: our repo has been reclassified from "the product" to "the design reference". Everything below assumes we want that reversed or renegotiated.


2. Inventory comparison

Them (archive) Us (B2B @ 92f1c88)
Backend NestJS 11 / Fastify, 44 files, running None. 17 contract docs in docs/backend/
Database PostgreSQL 17, Prisma, 36 models, 3 migrations None
Storefront Angular 21.2.18, 189 .ts Angular 22.0.8, 530 .ts, 158 components
Backoffice Angular 21.2.18, 45 .ts, still mock-backed 14 admin modules, 22 local + 22 API gateway pairs
Auth (admin) Email + Argon2id + mandatory TOTP, HttpOnly cookie, server sessions admin-auth.guard.ts + dev bypass
Auth (customer) Telegram QR via external USERAUTH_API_URL, server session, HttpOnly cookie Telegram, client-side
RBAC 5 roles enforced server-side + per-marketplace membership Client-side permission model
Payments Vitanova + NUMUS adapters, encrypted per-tenant credentials, HMAC webhooks, idempotency, poll fallback FX/pricing gateways, payment contracts, no server
Multi-tenancy Host → verified MarketplaceDomain → tenant, 30 s cache, 404 on unknown Bootstrap-driven runtime config
Publish Immutable MarketplaceRevision snapshots, atomic publish, rollback-as-new-revision Draft/publish UI, local persistence
Infra Compose (internal + egress networks), Nginx templates, certbot, WAL archiving, backup timers, restore check, fail2ban, sysctl/ssh hardening scripts/deploy/*.sh, GH Actions deploy, wildcard TLS
Tests 9 spec files, 25 tests total, no e2e at all 30 spec files, Playwright e2e, coverage floor in CI
Arch governance None check-boundaries.mjs, madge cycles, architecture-governance.yml
Bundle storefront 648 kB (48 kB over) 1.15 MB (452 kB over a 700 kB budget) — their measurement of us

3. Their backend, in detail — the part worth studying

3.1 Tenant resolution (common/tenant.service.ts)

  • normalizeHost() lowercases, strips trailing dot, strips port.
  • Looks up MarketplaceDomain by hostname unique index, requires verifiedAt != null, requires status = ACTIVE.
  • 30-second in-process cache keyed by hostname, with invalidate(hostname?).
  • Unknown host → 404, never a fallback tenant.
  • Preview: HMAC-signed token base64url(payload).base64url(hmac) carrying {marketplaceId, expiresAt, nonce}, 15 min TTL, delivered as a storefront_preview cookie. A global Fastify onRequest hook returns 404 Preview mode is read-only for any non-GET on /api/v1/* while that cookie is present. Cheap, clean, and something we do not have.

3.2 Admin auth (auth/admin-auth.service.ts)

  • Argon2id (memoryCost 65536, timeCost 3, parallelism 1), TOTP mandatory — first login without TOTP returns a signed 10-minute setupToken + otpauth:// URI and refuses to issue a session until TOTP is confirmed.
  • Sessions are random 32 bytes, stored as SHA-256 hash only, 12 h TTL, with ipAddress + userAgent.
  • authenticate() rejects if revokedAt, expired, user inactive, or TOTP not enabled.
  • Password change requires ≥16 chars and revokes every live session in the same transaction.
  • Role weights: ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4; hasAccess() checks weight and marketplace scope.
  • Manager portal uses a separate cookie (manager_session) and a separate guard, pinned to one marketplace slug.

3.3 CSRF / origin control (main.ts, auth/admin-origins.ts)

A global hook rejects any non-GET on /api/admin/* or /api/manager/* whose Origin header is not in ADMIN_ORIGIN + ADMIN_ORIGINS. CORS origin is the same allowlist with credentials: true. Rate limit 120/min per IP; multipart capped at 1 file / 10 MB / 4 fields; body limit 12 MB; trustProxy: true; global ValidationPipe({ whitelist, forbidNonWhitelisted, transform }).

3.4 Checkout (checkout/checkout.service.ts) — the atomicity pattern

UPDATE "MarketplaceInventory"
SET "reserved" = "reserved" + $qty, "updatedAt" = NOW()
WHERE "marketplaceId" = $mp AND "variantId" = $variant
  AND ("onHand" - "reserved") >= $qty
RETURNING "id"

Empty result → 409 Insufficient stock. This single conditional UPDATE inside a Prisma transaction is the whole oversell defence: no read-then-write race, no advisory locks. Then a StockReservation (15 min), one InventoryMovement per line with reason: 'checkout_reservation', and an Order carrying a full productSnapshot per item. Price comes only from the server-side snapshot; the browser's price is never read. publicToken is randomBytes(24).base64url — no sequential IDs leak. Digital codes are decrypted into the response only when order status is PAID/PROCESSING/FULFILLED.

3.5 Payments (payments/payment.service.ts)

  • Payment.idempotencyKey is a unique column; a repeat POST with the same key returns the existing payment, and a key reused across a different order/marketplace → 409.
  • Provider credentials live in PaymentCredential.encryptedConfig, AES-256-GCM (v1.iv.tag.ciphertext, base64url) with a 32-byte FIELD_ENCRYPTION_KEY. Decrypted only inside the service, never serialized into a response.
  • NUMUS webhook: requires eventId/eventType/timestamp/signature headers, validates the envelope (schemaVersion === 1), HMAC-verifies the raw body, then inserts into PaymentWebhookEvent with @@unique([provider, eventKey]). A Prisma P2002 collision returns {accepted: true, duplicate: true} — replay is a no-op by construction, not by an if.
  • Vitanova webhook: same shape, per-marketplace webhookSecret overriding the global one, eventKey falling back to sha256(rawBody).
  • pollPending(50) is the reconciliation fallback for the last 24 h; failures are swallowed so the next tick retries.
  • checkoutUrl passes through safeHttpsUrl() before it is ever returned to a browser.

3.6 Publish / revisions (admin/admin.service.ts)

publish() materializes the draft into a full snapshot, computes version = max(version) + 1, writes an immutable MarketplaceRevision, and flips publishedRevision in the same transaction. Clone copies theme/categories/offers/variants/category links and forces inventory to zero, drops domains/customers/orders/secrets, and creates an OWNER membership for the actor. Category cloning is a topological walk that throws Category tree contains a cycle.

3.7 Config validation (common/storefront-config.ts)

The section schema is validated server-side, per section type, with hard clamps: max 40 sections; id regex; per-type height ranges (categoryRail 98360, productRail 390760, hero fallback 312); colour must match #rrggbb or fall back; URLs accepted only if local /path or https://; product/category ID lists deduped and capped at 24 UUIDs. This is exactly the "validation engine" they praised in our editor — except theirs runs where it actually binds.

3.8 Infrastructure (infra/)

  • Compose with two networks: platform (internal: true, no egress — Postgres lives here) and egress (API + worker + migrate only).
  • API bound to 127.0.0.1:3000 only. no-new-privileges on every service.
  • Postgres 17.7 with wal_level=replica, archive_mode=on, archive_timeout=300, archive command copying WAL into a backup volume.
  • migrate is a separate one-shot service; api and worker both depends_on: migrate: service_completed_successfully.
  • Healthchecks: the API container hits its own /health/ready.
  • systemd timers: marketplaces-backup, marketplaces-domain-sync, marketplaces-thumbnails (path-triggered), plus *-healthcheck timers per app.
  • provision-domain.sh refuses to run unless the domain's A record already resolves to the server IP, then certbot webroot, then a manual review step before Nginx reload.
  • Hardening set we do not have: fail2ban/jail.local, sshd hardening drop-in, sysctl hardening, docker/daemon.json, scoped sudoers per deploy role, restore-check.sh.

4. What they wrote about us (docs/PARALLEL_IMPLEMENTATION_AUDIT.md)

Their measurements of our code, 11 Aug 2026:

  • Production build passes on Node 24.18.1.
  • Initial bundle ~1.15 MB against a 700 kB budget — 452 kB over.
  • Boundary + cycle checks pass.
  • 57 unit tests pass; 5 spec files total; "checkout, RBAC, publishing, orders and admin CRUD flows are not meaningfully covered".
  • npm audit --omit=dev: 0 production vulnerabilities — better than all three of their own packages.

Their blocking objections:

Their objection Is it fair?
Mock/localStorage repositories as production implementation Fair. 22 local gateways, 19 files touching localStorage.
Publishing config through localStorage Fair for the modules that still do it.
Admin JWT / refresh token in localStorage Fair as of the snapshot.
Admin session cookie set by JS and readable via document.cookie Fair and serious.
Shared Telegram session for customer and admin Fair and serious.
Client-side authorization-key, userid-value, partner ID Fair and serious — payment credentials in the browser.
Direct call to http://ip-api.com from an HTTPS storefront Fair — mixed content plus a third-party geo leak.
Unconditional bypassSecurityTrustResourceUrl on a bank URL Fair. Redirect targets must be backend-allowlisted.
Storefront + editor + backoffice in one deployable bundle Fair, and it is also why our bundle is 452 kB over.
Hardcoded fallback regions / provider URLs / brands in components Fair.

What they said they want from us (their P1 list, their order): editor information architecture; the section-editor schema; the validation engine with blockers/warnings/notices; undo/redo + dirty state + change summary; device preview and per-device media; searchable product/category pickers with SKU/price/stock; the media-library interaction model; semantic design tokens; the admin IA; and our boundary checker and ADR tooling.

That list is our leverage. It is also a precise statement of which of our modules are worth hardening first.


5. Differences that matter, ranked by consequence

  1. Truth ownership. Their price, stock, tenant and payment truth is server-side and provably so. Ours is a contract document. Every argument about "who is ahead" reduces to this one.
  2. Session model. They: server-stored, hashed, HttpOnly, revocable, TOTP-gated, separate cookies per contour. Us: client-held.
  3. Idempotency. They: unique constraints doing the work (Payment.idempotencyKey, PaymentWebhookEvent(provider,eventKey)). Us: zero idempot* anywhere in the codebase.
  4. Deployability. They: Compose + migrations + healthchecks + WAL + restore check + domain automation. Us: shell scripts and GH Actions, with no database to migrate.
  5. Frontend depth. Us: 2.8× their storefront file count, 158 components, dynamic renderer, widget system, theme system, i18n, 30 spec files, Playwright e2e, boundary governance. Them: a 45-file backoffice with mock-data.service.ts still in it.
  6. Test posture. They have no e2e whatsoever and 25 unit tests across the whole platform. Their own VERIFICATION.md says the count "is insufficient to conclude production readiness". We have e2e plus a CI coverage floor. This is a real gap on their side and worth naming out loud.
  7. Framework currency. We are on Angular 22 / TS 6.0.3; both of their apps are on 21.2.18 with 78 fixable high findings in production dependencies.

6. What we should take — concrete, ordered

P0 — take these regardless of how the org question resolves

  1. Conditional-UPDATE stock reservation. Adopt the pattern verbatim in backend/BACKEND-INTEGRATION.md: reserve via WHERE (onHand - reserved) >= qty RETURNING id, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
  2. Idempotency as a unique constraint, not application logic. Payment.idempotencyKey UNIQUE, PaymentWebhookEvent @@unique([provider, eventKey]), P2002 → {duplicate: true}. Push this into backend/BACKEND-INTEGRATION.md as a schema requirement, not a behavioural note.
  3. Move every credential out of the browser. Their audit is right about authorization-key / userid-value / partner ID. Mirror PaymentCredential.encryptedConfig (AES-256-GCM, versioned v1.iv.tag.ct) in our contract and delete the client-side header path.
  4. HttpOnly server sessions, separate cookie per contour (bo_session, manager_session, marketplace_session). Kill the JS-set cookie and the shared Telegram session for admin + customer. This is our single worst finding in their audit.
  5. Origin allowlist hook for all admin mutations. Twelve lines in main.ts; kills CSRF for cookie-authenticated mutations. Mirror in backend/BACKEND-INTEGRATION.md.
  6. Backend-side config validation. Our validation engine is better than theirs, but it runs in the browser. The server must re-run it. Their clamp-and-fallback ergonomics are right: never reject a colour, clamp it; never accept a non-https URL, blank it.

P1 — take into our own architecture

  1. Signed preview token + read-only preview enforcement. HMAC token, 15 min, storefront_preview cookie, global hook rejecting non-GET. We have preview UI and no preview safety.
  2. Immutable revisions with version = max+1, rollback-as-new-revision. Never rewrite history; publishedRevision is an integer pointer flipped in-transaction.
  3. Clone semantics. Copy design + catalog assignments, force inventory to 0, never copy domains/customers/orders/secrets. Their topological category walk with cycle detection is worth copying line for line.
  4. MarketplaceAuthCredential table. They have it and do not use it. It is exactly the right home for per-tenant VK/Yandex OAuth app credentials — see §8.
  5. Two-network Compose split (internal: true for the data network) and API bound to loopback. Makes "the database is not reachable from the internet" structural rather than a firewall promise.
  6. WAL archiving + restore-check.sh + a scheduled restore drill. We have deploy automation and no proven restore.

P2 — process, not code

  1. Their DEVELOPER_HANDOFF.md §7 "inviolable invariants" list is a better acceptance gate than anything currently in our delivery plan. Nine lines, each falsifiable. Adopt it as the header of our own handoff doc.
  2. Their PR policy: one functional area per PR; mandatory purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan; never change payment/inventory/order state machines inside a redesign PR.
  3. Their status discipline: "a local build or the existence of a UI does not mean production readiness". Every release records version, migration, healthcheck, smoke, audit, rollback.

7. Ideas worth stealing (product-level)

  • ORDER_MANAGER as a fully separate contour — separate URL, separate shell, separate cookie, separate login, pinned to one marketplace, cannot see catalog/design/domains/payment settings. Genuinely good product thinking: the people who touch orders all day are not admins, and giving them their own small app removes an entire permissions surface.
  • FulfillmentMode: MANUAL | CODE_POOL + a DigitalCode pool with AVAILABLE/RESERVED/ASSIGNED/REVOKED, encrypted values, valueHash unique per (marketplace, variant), and codes revealed only after payment. We have no digital-goods story at all; this is a complete one in one table.
  • Marketplace status machine DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED, with DOMAIN_PENDING as a real state rather than an error condition.
  • Per-tenant delivery options priced in minor units on the offer, validated at checkout ("select a delivery option for each physical product").
  • InventoryMovement as an append-only journal with reason, referenceType, referenceId, actorId — every stock change explainable after the fact. This directly answers the "we do not trust your numbers" complaint in the v3.1 plan.
  • CSV marketplace import (POST /marketplaces/import, dryRun default true) — bulk tenant creation as a first-class operation.
  • Their §22 acceptance scenarios (15 of them) are a ready-made e2e suite. Scenario 3 (two concurrent purchases of the last unit) and scenario 10 (replayed webhook) are the two tests that would catch the most expensive possible bugs. Write those two this sprint regardless of anything else in this document.

8. VK ID and Yandex login — what is actually there, and how we add it

8.1 Finding: they do not have it

Exhaustive search of the archive (*.ts, *.html, *.md, *.json, *.prisma, *.sql, *.yml, *.conf, env examples), excluding node_modules and excluding our own code under reference/:

  • vk / vkontakte / vkid0 hits in source. The only matches anywhere are inside package-lock.json integrity hashes and two Armenian/English FAQ content pages.
  • yandex0 hits in source; the same two content pages only.
  • oauth0 hits in their code. The single oauth-adjacent file in the whole archive is ours: reference/parallel-frontend/src/app/core/auth/services/auth.service.ts.
  • The Prisma schema has no ExternalIdentity, no provider column on Customer, and no social tables. Customer identity is @@unique([marketplaceId, telegramUserId]) — Telegram only.

Their only customer login is Telegram, and it is not even self-hosted: CustomerAuthService proxies to an external service at USERAUTH_API_URL (https://users.vitanova.network:456), creates a web session, polls /users/sessions/{id} until status is confirmed, then upserts a Customer and issues its own 30-day session cookie.

So there is nothing to copy from them here. But their session-issuing half is the right shape, and it is what VK/Yandex should terminate into.

8.2 What we already have

File State
vk-id-gateway.interface.ts getAuthorizeUrl(), completeCallback(code, codeVerifier)
vk-id-api.gateway.ts Real HTTP client → /api/identity/v1/vk/authorize, /vk/callback
vk-id-local.gateway.ts Mock
vk-id-gateway.token.ts DI seam via environment.useMockData
vk-id-login.component.ts Button component
customer-identity.model.ts ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max'
backend/BACKEND-INTEGRATION.md §2 defines the VK ID contract

We have the scaffolding and the contract. Yandex is absent everywhere except one mention of Yandex Market as a possible marketplace connector in the gap analysis — a different thing entirely.

8.3 Design — one provider-agnostic social login, VK ID and Yandex ID as instances

Principle (already in our contract — keep it): the OAuth code exchange happens entirely backend-side. No client secret, no access token, and no code_verifier ever reaches the browser.

Change to make: our current interface passes codeVerifier from the client, which forces the browser to generate and store the PKCE verifier. We are a confidential client — the backend should own the verifier. Recommended surface:

GET  /api/identity/v1/{provider}/authorize
       -> 302 to the provider, OR { url } for the client to navigate to.
          Backend generates state + code_verifier and stores both in a
          short-lived HttpOnly cookie (or server-side, keyed by state),
          10 min TTL, single use.

GET  /api/identity/v1/{provider}/callback?code=…&state=…[&device_id=…]
       -> backend validates state, exchanges code + stored verifier,
          fetches the profile, resolves/links Customer, issues the
          marketplace session cookie, 302 back into the storefront.

POST /api/identity/v1/{provider}/unlink      (authenticated)
GET  /api/identity/v1/me/identities          (authenticated) -> linked providers

{provider}vk | yandex (later telegram, max). One controller, one service, a per-provider strategy object. The frontend keeps exactly one gateway interface:

export type SocialProvider = 'vk' | 'yandex';

export interface SocialIdentityGateway {
  getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
  listIdentities(): Observable<ExternalIdentity[]>;
  unlink(provider: SocialProvider): Observable<void>;
}

VkIdGateway collapses into it, completeCallback() disappears from the frontend entirely (the backend handles the callback and redirects), and vk-id-login.component becomes social-login-button with a provider input. Add 'yandex_id' to ExternalIdentityProvider in customer-identity.model.ts.

Provider specifics — confirm exact parameter and scope names against the live provider docs before implementing; both providers have revised their flows recently.

VK ID — OAuth 2.1, PKCE mandatory, S256.

  • Authorize: https://id.vk.com/authorizeclient_id, redirect_uri, response_type=code, code_challenge, code_challenge_method=S256, state, scope (typically vkid.personal_info email phone).
  • The callback returns a device_id alongside code, and it is required for the token exchange. Missing it makes every exchange fail; this is the single most common VK ID integration bug.
  • Token: POST https://id.vk.com/oauth2/authgrant_type=authorization_code, code, code_verifier, device_id, client_id, redirect_uri.
  • Profile: POST https://id.vk.com/oauth2/user_info with the access token → stable user_id, name, optional email/phone.
  • Logout: https://id.vk.com/oauth2/logout — call it on unlink so the provider session is not left dangling.

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

  • Authorize: https://oauth.yandex.ru/authorizeresponse_type=code, client_id, redirect_uri, state, code_challenge, code_challenge_method=S256.
  • Token: POST https://oauth.yandex.ru/tokengrant_type=authorization_code, code, code_verifier, HTTP Basic auth with client_id:client_secret.
  • Profile: GET https://login.yandex.ru/info?format=json with header Authorization: OAuth <access_token>id (stable), login, default_email, default_phone, psuid, avatar id.
  • Yandex returns an email in most cases; VK often will not. Do not make email a required field on Customer.

Data model — add to whatever schema we land on. Their MarketplaceAuthCredential is the right precedent for the credentials half.

model ExternalIdentity {
  id             String   @id @default(uuid()) @db.Uuid
  customerId     String   @db.Uuid
  provider       String   // 'vk_id' | 'yandex_id' | 'telegram' | 'max'
  providerUserId String
  email          String?
  phone          String?
  displayName    String?
  verifiedAt     DateTime @default(now())
  lastUsedAt     DateTime @default(now())
  customer       Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)

  @@unique([provider, providerUserId])          // one provider account -> one customer
  @@index([customerId])
}

Plus, per tenant, an encrypted OAuth app config using the same envelope as their FieldEncryptionService:

MarketplaceAuthCredential { marketplaceId, provider, encryptedConfig, active }
encryptedConfig = { clientId, clientSecret, scopes[], redirectUri }

Five rules that decide whether this ships correctly:

  1. Redirect URI vs. multi-tenant domains. VK and Yandex both validate redirect_uri against an exact registered list. With N tenant domains you cannot register N URIs per app, and you cannot let tenants supply their own. Use one central identity host (e.g. id.<platform-domain>) as the only registered callback, carry the origin tenant inside the signed state, and 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant's API exchanges for the session cookie. Decide this before writing any code — retrofitting it is expensive.
  2. @@unique([provider, providerUserId]), plus a decision on per-tenant customer separation. Their platform isolates Customer per marketplace even for the same Telegram ID. Decide explicitly whether one VK account across two of our storefronts is one customer or two. Their answer is two; that is the safer default for data protection and the one our Customer.marketplaceId already implies.
  3. Identity conflict is not an upsert. Our PHASE-8 §2 already says this: if providerUserId is already bound to a different Customer, route to controlled resolution — never silently rebind. Enforce it with the unique index so the database refuses, rather than trusting the service layer.
  4. state is single-use and bound to the browser. Store { state, codeVerifier, marketplaceId, returnTo, expiresAt } server-side or in a signed HttpOnly cookie; delete on first use. Reject unknown/expired/replayed state with a generic error.
  5. The session that comes out is our normal session. VK/Yandex end where Telegram ends: a random 32-byte token, stored as SHA-256, HttpOnly + Secure + SameSite=Lax, per-marketplace, revocable. Social login is an entry path, not a session format.

Build order: provider-agnostic backend endpoints + ExternalIdentity table → VK ID (v3.1 names it the primary social login) → Yandex ID (a second instance of the same strategy, roughly a day once VK works) → migrate Telegram onto ExternalIdentity so it becomes one provider among several rather than the schema's only key → account-linking UI (/me/identities, link/unlink) → email/phone OTP as recovery.


9. What we must not copy from them

  • Angular 21.2.18 with 78 open high findings in production dependencies, in both apps. We are on 22.0.8 with a clean production audit. Do not regress.
  • mock-data.service.ts in the backoffice — they still ship one while telling us mocks are disqualifying.
  • 25 unit tests and zero e2e. Their own verification doc concedes this is not sufficient.
  • ORDER_MANAGER_MARKETPLACE_SLUG pinned by environment variable (default 'dexar', 'novo' in the example env). Manager scope should come from membership rows, not an env string.
  • Server IP hardcoded in provision-domain.sh (109.120.134.244), and the sslip.io staging hosts baked into the committed env example.
  • Their section schema is narrower than ours (5 section types vs our widget system). Take their server-side validation discipline, not their schema.

# Action Why now
1 Write the two e2e tests from their §22: concurrent purchase of the last unit, and a replayed webhook Highest bug-cost coverage per hour, and they have neither
2 Remove client-held payment credentials and JS-set admin cookies Their audit's most serious finding, and it is correct
3 Fold their invariant list (§7 of their handoff) into our own handoff doc as a signed acceptance gate Turns their strongest document into our shared standard
4 Decide the central-identity-host question in §8.3 rule 1 Blocks VK ID, and it is a one-way door
5 Implement provider-agnostic social identity, then VK ID, then Yandex ID v3.1 §14 names VK ID the primary social login; nobody has it yet, including them
6 Cut the storefront bundle below budget by splitting storefront / editor / backoffice deployables 452 kB over, and it is the one performance criticism that is objectively measured
7 Take the position explicitly that the two codebases merge as their backend + our frontend Their handoff doc already ranks our work fifth; unchallenged, that becomes the plan of record

Appendix — where things live in the archive

Concern Path
Tenant by Host, preview tokens platform-api/src/common/tenant.service.ts
Admin auth, TOTP, RBAC weights platform-api/src/auth/admin-auth.service.ts
Origin allowlist / CSRF hook platform-api/src/main.ts, src/auth/admin-origins.ts
Stock reservation SQL platform-api/src/checkout/checkout.service.ts
Idempotency, webhooks, polling platform-api/src/payments/payment.service.ts
Field encryption (AES-256-GCM) platform-api/src/common/field-encryption.service.ts
Server-side section validation platform-api/src/common/storefront-config.ts
Publish / rollback / clone platform-api/src/admin/admin.service.ts
Customer (Telegram) sessions platform-api/src/storefront/customer-auth.service.ts
Data model, 36 entities platform-api/prisma/schema.prisma
Compose, networks, WAL infra/compose.yml
Domain provisioning, backup, restore check infra/scripts/
Host hardening (fail2ban, sshd, sysctl) marketplaces/infra/server/
Their audit of our code docs/PARALLEL_IMPLEMENTATION_AUDIT.md
Their target spec (479 lines) docs/PRODUCT_SPECIFICATION.md
Their handoff + invariants docs/DEVELOPER_HANDOFF.md
Our code, verbatim reference/parallel-frontend/