Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-08-21 13:33:45 +04:00
18 changed files with 1437 additions and 209 deletions

View File

@@ -40,9 +40,19 @@ jobs:
CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }} CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
run: npm run test:coverage run: npm run test:coverage
# The production build is what enforces the bundle budget. The initial
# bundle sits at ~1.55 MB raw against a 700 kB target, so the error
# threshold is a ratchet, not the goal: it is set just above today's
# size so the bundle cannot grow while we work it back down. Lower the
# ratchet in angular.json every time it comes down.
- name: Build - name: Build
run: npm run build run: npm run build
# Stops payment credentials returning to the browser bundle. See
# scripts/ci/scan-bundle.sh for what it looks for and why.
- name: Scan bundle for credentials
run: npm run scan:bundle
- name: E2E - name: E2E
run: | run: |
npx playwright install --with-deps chromium npx playwright install --with-deps chromium

View File

@@ -307,6 +307,18 @@ Base: `ApiConfigService.getBaseUrl()`. Headers on every call (`apiHeadersInterce
| `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }`**literal typo `questiion`, preserve it, matches the client** | | `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }`**literal typo `questiion`, preserve it, matches the client** |
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` | | `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
| `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error | | `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error |
| `/geo/resolve` | GET | — | `GeoIpResponse`**not built yet**, see below |
**`/geo/resolve` — new, required.** Resolves the *caller's* IP to a coarse location so the storefront can pre-select a region. The server reads the client IP (behind the proxy, so honour `X-Forwarded-For` with `trustProxy`); the browser sends nothing and receives no third-party payload.
Response is the existing `GeoIpResponse` shape (`src/app/models/location.model.ts`): `{ city, country, countryCode, region?, timezone?, lat?, lon? }`.
Rules:
- City-level precision only. Do not return coordinates finer than the city centroid, and do not persist the lookup against a customer record — this runs for anonymous visitors.
- Any failure returns a non-2xx. The client already treats every error as "stay on the manual picker", so a degraded geo provider must never block the storefront.
- Rate-limit per IP; it is an unauthenticated endpoint.
This replaces a direct browser call to `http://ip-api.com`, which leaked every visitor's IP to a third party and — being plaintext on an HTTPS origin — was blocked as mixed content, so region auto-detect never actually worked in production. Until this endpoint ships the client silently falls back to the manual region picker, which is the same behaviour production has had all along.
### 6.1 Products — the tolerance contract ### 6.1 Products — the tolerance contract

View File

@@ -59,7 +59,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "700kB", "maximumWarning": "700kB",
"maximumError": "1.8MB" "maximumError": "1.6MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -0,0 +1,392 @@
# 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
```sql
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 `docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.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 `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.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 `TRACK-S-SECURITY-RBAC-CONTRACT.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
7. **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.
8. **Immutable revisions with `version = max+1`, rollback-as-new-revision.** Never rewrite history; `publishedRevision` is an integer pointer flipped in-transaction.
9. **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.
10. **`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.
11. **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.
12. **WAL archiving + `restore-check.sh` + a scheduled restore drill.** We have deploy automation and no proven restore.
### P2 — process, not code
13. 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.
14. 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.
15. 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` / `vkid`**0 hits** in source. The only matches anywhere are inside `package-lock.json` integrity hashes and two Armenian/English FAQ content pages.
- `yandex`**0 hits** in source; the same two content pages only.
- `oauth`**0 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](src/app/core/identity/services/vk-id-gateway.interface.ts) | `getAuthorizeUrl()`, `completeCallback(code, codeVerifier)` |
| [vk-id-api.gateway.ts](src/app/core/identity/services/vk-id-api.gateway.ts) | Real HTTP client → `/api/identity/v1/vk/authorize`, `/vk/callback` |
| [vk-id-local.gateway.ts](src/app/core/identity/services/vk-id-local.gateway.ts) | Mock |
| [vk-id-gateway.token.ts](src/app/core/identity/services/vk-id-gateway.token.ts) | DI seam via `environment.useMockData` |
| [vk-id-login.component.ts](src/app/components/vk-id-login/vk-id-login.component.ts) | Button component |
| [customer-identity.model.ts](src/app/core/identity/models/customer-identity.model.ts) | `ExternalIdentityProvider = 'vk_id' \| 'telegram' \| 'max'` |
| [PHASE-8-IDENTITY-MESSAGING-CONTRACT.md](docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.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:
```ts
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/authorize``client_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/auth``grant_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/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 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.
```prisma
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.
---
## 10. Recommended next actions
| # | 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/` |

236
docs/FORK-HARVEST-TODO.md Normal file
View File

@@ -0,0 +1,236 @@
# Fork Harvest — TODO
**Branch:** `improvements/fork-harvest` (from `B2B` @ `92f1c88`)
**Design:** [2026-08-21-fork-harvest-design.md](superpowers/specs/2026-08-21-fork-harvest-design.md)
**Source analysis:** [FORK-ANALYSIS-2026-08-21.md](FORK-ANALYSIS-2026-08-21.md)
Improvements only. Nothing here regresses our Angular version, test count, or architecture governance.
**Effort:** S ≤ half a day · M ≤ 2 days · L > 2 days
**Lane:** A frontend · B backend contract · C `@marketplaces/auth` package · D infra/ops · E process
---
## Wave 0 — Decide first (blocks Wave 4)
- [ ] **FH-0.1 — Decide the central identity host** · L · Lane C · *blocker*
VK ID and Yandex ID both validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain, and we cannot let tenants supply their own.
**Decision needed:** single central callback host (e.g. `id.<platform-domain>`) as the only registered URI, tenant carried inside signed `state`, 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for a session cookie.
**Also decide:** is one VK account across two of our storefronts one `Customer` or two? Their platform says two; our `Customer.marketplaceId` already implies two.
**Done when:** an ADR exists in `docs/context/adrs/` and both questions have a recorded answer.
- [ ] **FH-0.2 — Confirm the server-priced checkout path covers every live flow** · S · Lane A · *blocks FH-1.3*
`api.service.ts` already has a server-priced checkout session method. Confirm no production flow still depends on `createPayment(payload, headers)` before deleting the header path.
**Done when:** every caller of the legacy header path is enumerated and has a replacement.
---
## Wave 1 — Live defects with a security benefit (Lane A, this sprint)
- [x] **FH-1.1 — Kill the plaintext third-party geo call** · S · Lane A · **done 2026-08-21**
Now `GET {tenantApiBase}/geo/resolve`, same base as `/regions`. Server reads the client IP; nothing leaves our infrastructure. Endpoint specified in [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md) §6 — **not built yet**, and until it is the client falls back to the manual picker, which is what production has effectively had all along. Covered by `src/app/services/location.service.spec.ts` (4 tests, one of which fails the build on any off-origin or plaintext request from this service).
*Was:* `location.service.ts:75` called `http://ip-api.com/json/?fields=…` from an HTTPS origin. Mixed active content is blocked, so `detectLocation()` only ever took its error branch — auto-detect was dead in production, not merely insecure — and the attempt still leaked every visitor's IP to a third party.
- [ ] **FH-1.2 — Stop blindly trusting the bank redirect URL** · M · Lane A
`src/app/pages/cart/cart.component.ts:485``bypassSecurityTrustResourceUrl(bankUrl)` with no validation, rendered into a popup iframe. Most acquirer 3-D Secure pages send `X-Frame-Options: DENY`, so the popup is blank for those banks. Their spec: card checkout navigates the current tab, no intermediate popup.
**Do:** accept only an `https:` URL whose origin the backend returned in the payment response (backend allowlist, per their `safeHttpsUrl()`); navigate the current tab instead of framing.
**Done when:** a non-https or non-allowlisted URL is refused with a visible payment error; a test covers both the accepted and the refused case.
- [x] **FH-1.3 — Remove provider credentials from the browser** · M · Lane A · **landed via the `@marketplaces/payment` migration**
The legacy payment surface on `ApiService` was deleted wholesale in that work. `grep -ri "authorization-key\|userid-value\|web-97ec" src/` now returns nothing. Keep FH-3.5 (bundle secret scan) to stop it coming back.
*Was:* `api.service.ts:675` set `authorization-key` and `userid-value` headers client-side, and `api.service.ts:143` shipped a partner ID literal in the bundle. Their audit's most serious finding, and it was correct.
- [ ] **FH-1.4 — Send `Idempotency-Key` on payment creation** · S · Lane A
Zero `idempot*` anywhere in our codebase. Their API requires the header and rejects a key reused across a different order.
**Do:** generate one key per checkout attempt, stable across retries and across a double-click, sent on payment creation.
**Done when:** the existing `checkout-idempotent-click.spec.ts` asserts both requests carry the *same* key.
---
## Wave 2 — Contract hardening (Lane B, parallel with Wave 1)
Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND-HANDOFF.md`, so it becomes a delivery gate rather than a wish.
- [ ] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md`
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
- [ ] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md`
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
- [ ] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥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`, checked together with marketplace scope.
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
- [ ] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
- [ ] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md`
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
- [ ] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable.
**Acceptance:** a mutation attempted in preview mode is refused.
- [ ] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
- [ ] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md`
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
**Acceptance:** any current quantity is reconstructible from the journal alone.
- [ ] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
- [ ] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md`
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
- [ ] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
`FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`.
**Acceptance:** an unpaid order never returns a code.
- [ ] **FH-2.12 — Marketplace status machine** · S · `PHASE-9-…`
`DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition.
- [ ] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md`
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
- [ ] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged.
- [ ] **FH-2.15 — CSV marketplace import with `dryRun` default true** · S · `PARTNER-PROVISIONING-API-CONTRACT.md`
Bulk tenant creation as a first-class operation: validate fully without writing, report create/update/skip/error per row, then confirm.
---
## Wave 3 — Proof (Lane A)
- [ ] **FH-3.1 — E2E: concurrent purchase of the last unit** · M · `e2e/`
Their §22 scenario 3. Two sessions race for the final unit; exactly one payable order results, the other gets a clean out-of-stock state.
- [ ] **FH-3.2 — E2E: replayed webhook** · M · `e2e/`
Their §22 scenario 10. The same provider event delivered twice does not complete the order twice or move stock twice.
- [x] **FH-3.3 — Bundle budget as a blocking CI check** · S · **done 2026-08-21**
`maximumError` on the initial bundle lowered `1.8MB → 1.6MB` in `angular.json`. Measured today: **1.55 MB raw / 324.58 kB transfer** — worse than the 1.15 MB they measured on 11 Aug, so this had been growing unwatched. The threshold is a **ratchet**, not the target: set just above today's size so the bundle cannot grow, with the 700 kB warning left in place as the goal. Lower it every time the number comes down. CI now runs the production build (`npm run build` already defaults to production).
- [ ] **FH-3.4 — Get the initial bundle down** · L · `angular.json`, `src/app/`
**Premise corrected after measuring.** Admin, editor, catalog, cart and the `en`/`hy` locales are *already* lazy chunks — nothing admin-shaped ships to an anonymous visitor. The entire 1.55 MB is `main` alone. So this is not a "split the deployables" job; it is a "find what is eager" job.
Known contributors: `src/app/i18n/ru.ts` (141 kB of source, default locale, eager while `en`/`hy` are lazy), the eagerly-provided core services and their DI tokens, `icon-registry.ts`.
Also: `qrcode`, pulled in by `@marketplaces/auth`, is not ESM and causes an optimizer bailout — worth fixing in the package.
**Done when:** initial is under the 700 kB warning, with the ratchet lowered in steps along the way.
- [x] **FH-3.5 — Bundle secret scan in CI** · S · **done 2026-08-21**
`scripts/ci/scan-bundle.sh`, wired as `npm run scan:bundle` and a CI step after Build. Seven patterns: both provider auth headers, the partner ID shape, `client_secret`, private key blocks, AWS keys, Telegram bot tokens. Verified in both directions — clean against the real `dist/`, and fails with exit 1 against a planted credential.
---
## Wave 4 — Identity: VK ID + Yandex ID (Lane C, `@marketplaces/auth`)
Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
- [ ] **FH-4.1 — Provider-agnostic social identity surface** · M
Collapse `VkIdGateway` into `SocialIdentityGateway`:
```ts
export type SocialProvider = 'vk' | 'yandex';
export interface SocialIdentityGateway {
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
listIdentities(): Observable<ExternalIdentity[]>;
unlink(provider: SocialProvider): Observable<void>;
}
```
Touches: `src/app/core/identity/services/vk-id-gateway.interface.ts`, `vk-id-api.gateway.ts`, `vk-id-local.gateway.ts`, `vk-id-gateway.token.ts`, `src/app/components/vk-id-login/` → `social-login-button`. Add `'yandex_id'` to `ExternalIdentityProvider` in `core/identity/models/customer-identity.model.ts`.
- [ ] **FH-4.2 — Move PKCE ownership to the backend** · S · Lane B + C
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `PHASE-8-IDENTITY-MESSAGING-CONTRACT.md` §2.
- [ ] **FH-4.3 — `ExternalIdentity` model** · S · Lane B
```prisma
@@unique([provider, providerUserId]) // one provider account -> one customer
```
Conflict is **not** an upsert: a `providerUserId` already bound to a different `Customer` routes to controlled resolution. The unique index makes the database refuse a silent rebind. Per-tenant OAuth app config stored encrypted (same envelope as FH-2.9): `{ clientId, clientSecret, scopes[], redirectUri }`.
- [ ] **FH-4.4 — VK ID** · M
OAuth 2.1, PKCE mandatory (S256). Authorize `https://id.vk.com/authorize`; token `POST https://id.vk.com/oauth2/auth`; profile `POST https://id.vk.com/oauth2/user_info`; logout `https://id.vk.com/oauth2/logout` on unlink.
**Trap to write into the contract:** the callback returns `device_id` alongside `code`, and the token exchange fails without it. This is the most common VK ID integration bug.
VK often does not return an email — email must stay optional on `Customer`.
- [ ] **FH-4.5 — Yandex ID** · S · *after FH-4.4*
OAuth 2.0 with PKCE. Authorize `https://oauth.yandex.ru/authorize`; token `POST https://oauth.yandex.ru/token` with HTTP Basic `client_id:client_secret`; profile `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <token>` → `id`, `login`, `default_email`, `default_phone`, `psuid`.
A second strategy object against the same surface — roughly a day once VK works.
*Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.*
- [ ] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · Lane B + C
Telegram becomes one provider among several rather than the schema's only key. Ends the shared customer/admin Telegram session their audit flagged.
- [ ] **FH-4.7 — Account linking UI** · M
`/me/identities` — show linked providers, link, unlink, and surface the conflict-resolution path from FH-4.3.
- [ ] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · Lane E
Our approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid but drops below VK ID and becomes the fallback when a messenger channel is unavailable, per v3.1 §14.
---
## Continuous — Ops (Lane D)
- [ ] **FH-D.1 — Proven restore drill** · M
We have deploy automation and no proven restore. Add a restore-check script and schedule it. Their `restore-check.sh` + WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) is the model.
**Done when:** a restore into a clean environment has been executed and its result recorded.
- [ ] **FH-D.2 — Database unreachable from the internet, structurally** · S · Lane B/D
Data network `internal: true`; API bound to loopback only; `no-new-privileges` on every service. Makes it a property of the topology rather than a firewall promise.
- [ ] **FH-D.3 — Host hardening we lack** · M
fail2ban jail, sshd hardening drop-in, sysctl hardening, scoped sudoers per deploy role. Add to `scripts/deploy/server-setup.sh`.
*Keep ours where ours is better:* `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Do **not** copy their hardcoded server IP.
---
## Continuous — Process (Lane E)
- [ ] **FH-E.1 — Adopt the nine invariants as a signed acceptance gate** · S
From their handoff §7: tenant by Host not by a browser-supplied ID; order price computed backend; stock and reservation atomic; payment webhook idempotent; credentials never leave the backend; published revision immutable; design rollback does not roll back live inventory; no user reads an unassigned marketplace through UI or API; every admin mutation leaves an audit trail.
Put them at the head of `docs/backend/BACKEND-HANDOFF.md` as gates, not aspirations.
- [ ] **FH-E.2 — PR policy** · S
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.
- [ ] **FH-E.3 — Release discipline** · S
"A local build or the existence of a UI does not mean production readiness." Every release records version, migrations, healthcheck, smoke result, dependency audit, and a rollback path.
- [ ] **FH-E.4 — ADR for the harvest** · S
Record the decision: adopt these improvements, reject their architecture, keep our frontend and governance. Include the §9 rejection list so it does not get relitigated.
- [ ] **FH-E.6 — Keep mock gateways out of production builds** · M · Lane A
Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users.
This is the concrete form of their strongest objection — "mock repositories as production implementation" — and it is mechanical to fix. The pattern to copy is already in this repo: `mock-data.interceptor.production.ts` swapped in via `fileReplacements`.
**Done when:** `scan-bundle.sh` can gate on mock fixture markers and pass.
- [ ] **FH-E.5 — Reduce `localStorage` to cache, never truth** · M · Lane A
19 files touch `localStorage`, mostly admin facades and `project-editor-draft-storage.service.ts`. Their disqualifying objection is not "you use localStorage" — it is "localStorage is your source of truth."
**Do:** keep local drafts as an offline convenience with an explicit "unsaved local draft" indicator and server-wins reconciliation; never let a local value be the published state.
**Done when:** no admin or editor write path can publish without a server round-trip.
---
## Scoreboard
| Wave | Items | Lane | Blocked by |
|---|---:|---|---|
| 0 — Decide | 2 | C, A | — |
| 1 — Live defects | 4 | A | FH-0.2 (one item) |
| 2 — Contracts | 15 | B | — |
| 3 — Proof | 5 | A | — |
| 4 — Identity | 8 | C | FH-0.1 |
| Ops | 3 | D | — |
| Process | 6 | E | — |
| **Total** | **43** | | |
**Start here:** FH-0.1 (escalate today, it is a one-way door), then FH-1.1 and FH-1.4 — both are small, both are live defects, and both close findings their audit will otherwise keep raising.

View File

@@ -68,13 +68,11 @@ These come from `BACKEND-API-REFERENCE.md`, not `docs/backend/`. Base URL is `en
| POST | `/items/{itemID}/questiion` | `api.service.ts` | ❌ Undocumented — product Q&A (note: `questiion` typo is load-bearing, do not silently "fix" without checking the live backend uses the same typo) | | POST | `/items/{itemID}/questiion` | `api.service.ts` | ❌ Undocumented — product Q&A (note: `questiion` typo is load-bearing, do not silently "fix" without checking the live backend uses the same typo) |
| POST | `/items/{itemID}/notify-me` | `api.service.ts` | ❌ Undocumented — back-in-stock subscription | | POST | `/items/{itemID}/notify-me` | `api.service.ts` | ❌ Undocumented — back-in-stock subscription |
| POST | `/purchase-email` | `api.service.ts` | ❌ Undocumented — post-purchase email collection | | POST | `/purchase-email` | `api.service.ts` | ❌ Undocumented — post-purchase email collection |
| POST | `{qrBaseUrl}/qr` | `api.service.ts` | ❌ Undocumented — direct QR payment creation |
| POST | `/cart` | `api.service.ts` | ❌ Undocumented — legacy payment creation, client-sent `amount` (superseded by §2 below for new checkout flow; **still live** for any caller not yet migrated) |
| GET | `{qrBaseUrl}/qr/dynamic/{partnerId}/{qrId}` | `api.service.ts` | ❌ Undocumented — QR payment status poll |
| GET | `{qrBaseUrl}/card/{partnerId}/{orderId}` | `api.service.ts` | ❌ Undocumented — card payment status poll |
| POST | `/orders` | `api.service.ts` | ❌ Undocumented — records a paid cart as a backoffice order, fire-and-forget | | POST | `/orders` | `api.service.ts` | ❌ Undocumented — records a paid cart as a backoffice order, fire-and-forget |
**Recommendation:** these 15 need their own contract doc if they are staying, or a deprecation timeline if `/api/v2/storefront/*` replaces them. Right now they are simply undocumented and live — the single biggest gap in `docs/backend/`. **Recommendation:** these 11 need their own contract doc if they are staying, or a deprecation timeline if `/api/v2/storefront/*` replaces them. Right now they are simply undocumented and live — the single biggest gap in `docs/backend/`.
**Removed 2026-08-21, no longer called by this codebase:** `POST {qrBaseUrl}/qr` (direct QR creation), `POST /cart` (legacy payment creation with client-sent `amount`), `GET {qrBaseUrl}/qr/dynamic/{partnerId}/{qrId}` and `GET {qrBaseUrl}/card/{partnerId}/{orderId}` (status polls). §2's checkout flow made these dead in code (`createPaymentIntent`) as of the earlier `@marketplaces/payment` integration commit; confirmed zero remaining callers and deleted the dead `ApiService` methods, request/response types, and unused fields (`qrBaseUrl`, `cartPaymentPartnerId`) in the same pass. If a real backend still serves these endpoints, they are now backend-only surface with no frontend caller — decide their fate independently of this doc.
--- ---
@@ -373,8 +371,8 @@ The frontend added a client-side guard against double-submitting checkout (a rea
|---|---| |---|---|
| ✅ Specified | 54 | | ✅ Specified | 54 |
| ⚠️ Inferred (needs confirmation) | 24 | | ⚠️ Inferred (needs confirmation) | 24 |
| ❌ Undocumented (legacy) | 15 | | ❌ Undocumented (legacy) | 11 |
| **Total distinct endpoints called** | **97** | | **Total distinct endpoints called** | **93** |
(§0's 3 Telegram endpoints count as Specified+live; its 4 ed25519 endpoints count as Specified+not-built — both are real, named endpoints with a known shape, distinct from §1's "no contract exists anywhere" undocumented status.) (§0's 3 Telegram endpoints count as Specified+live; its 4 ed25519 endpoints count as Specified+not-built — both are real, named endpoints with a known shape, distinct from §1's "no contract exists anywhere" undocumented status.)

View File

@@ -0,0 +1,201 @@
# Fork Harvest — Design / Working Description
**Branch:** `improvements/fork-harvest` (cut from `B2B` @ `92f1c88`)
**Date:** 2026-08-21
**Input:** [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md)
**Companion:** [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md)
---
## 1. Purpose
Take **only the improvements** from the `hub.numus.cc/numus/marketplaces` archive. Nothing else. No architecture adoption, no code copying, no rewrite, no framework regression.
The archive is a competing platform monorepo, not a fork of us. It contains our repo verbatim as `reference/parallel-frontend/`. It is ahead of us on backend truth and operations, behind us on frontend depth, testing, and framework currency.
This document is the working brief for whoever executes the harvest — including a future session of me. It states what is true today in this repo, what changes, and how each item is proven done.
---
## 2. Constraint that shapes everything
**This repo has no backend.** 530 `.ts` files, Angular 22, zero server code. Our backend exists only as 17 contract documents in `docs/backend/`, implemented by another team.
That splits every harvested improvement into one of five lanes:
| Lane | Meaning | Where it lands |
|---|---|---|
| **A — Frontend** | We write the code, this sprint | `src/`, `e2e/`, `angular.json`, CI |
| **B — Contracts** | We write the requirement; backend implements | `docs/backend/*.md` |
| **C — Packages** | Ships in `@marketplaces/auth` (external repo `vitanovaPackages`) | package repo + DI wiring here |
| **D — Infra/Ops** | Deploy scripts and runbook | `scripts/deploy/`, `docs/DEPLOYMENT.md` |
| **E — Process** | Governance, gates, policy | `docs/backend/BACKEND-HANDOFF.md`, ADRs |
Anything that would require us to stand up Prisma, Postgres, or NestJS in *this* repo is out of scope. It becomes a Lane B contract line instead.
---
## 3. What we verified in our own code (2026-08-21, current HEAD)
Their audit was written against our 11 Aug snapshot. Re-checked against today's code:
| Their finding | Status now | Evidence |
|---|---|---|
| Admin session cookie set by JS, readable via `document.cookie` | **Already fixed** | zero `document.cookie` hits in `src/` |
| Admin JWT / refresh token in `localStorage` | **Already fixed** | no `setItem(*token*)` anywhere |
| `http://ip-api.com` from an HTTPS storefront | **STILL LIVE** | `src/app/services/location.service.ts:75` |
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **STILL LIVE** | `src/app/pages/cart/cart.component.ts:485` |
| Client-side `authorization-key` / `userid-value` headers | **STILL LIVE** | `src/app/services/api.service.ts:675` |
| Hardcoded partner ID in the bundle | **STILL LIVE** | `src/app/services/api.service.ts:143``'web-97ec-9c57-4dde-9037-3a68f7f83750'` |
| `localStorage` as persistence | **19 files**, mostly admin facades + editor draft storage | see TODO FH-A7 |
| Bundle 452 kB over a 700 kB budget | **Still true** | their measured build |
| Zero `idempot*` in the codebase | **Still true** | no Idempotency-Key sent on payment creation |
Two of those are live bugs, not just security posture:
- **`http://ip-api.com`** — browsers block mixed active content on an HTTPS origin. `detectLocation()` therefore always takes its error branch in production. Region auto-detect has been silently dead.
- **Bank URL in an iframe** — most acquirer 3-D Secure pages send `X-Frame-Options: DENY` / frame-ancestors CSP. The popup renders blank for those banks. Their spec calls this out explicitly: card checkout should navigate the current tab, not open an intermediate popup.
That reframes three of the "security" items as **defect fixes with a security benefit**, which is a much easier sell and a much better use of the sprint.
---
## 4. Selection rule — what counts as "an improvement"
An item is harvested only if it passes all four:
1. **It is better than what we have**, not merely different.
2. **It survives without their backend.** Either we can build it, or it is a contract line the backend team can implement against.
3. **It does not regress us.** Nothing that drops us to Angular 21, reintroduces mocks, or lowers our test bar.
4. **It is falsifiable.** There is a test, a check, or an observable state that proves it done.
Explicitly rejected by this rule (from the analysis §9): their Angular version, their `mock-data.service.ts`, their 25-test/zero-e2e posture, their env-pinned `ORDER_MANAGER_MARKETPLACE_SLUG`, their hardcoded server IP, their narrower 5-type section schema.
---
## 5. The harvest, by theme
### 5.1 Correctness primitives (the highest-value cluster)
Three patterns from their backend that are worth more than everything else combined, because each replaces application logic with a database guarantee:
**Conditional-UPDATE reservation.** One statement is their entire oversell defence:
```sql
UPDATE "MarketplaceInventory"
SET "reserved" = "reserved" + $qty
WHERE "marketplaceId" = $mp AND "variantId" = $variant
AND ("onHand" - "reserved") >= $qty
RETURNING "id"
```
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `PHASE-6-CART-CHECKOUT-CONTRACT.md` as a normative requirement, not a suggestion.
**Idempotency as a unique constraint.** `Payment.idempotencyKey UNIQUE` and `PaymentWebhookEvent @@unique([provider, eventKey])`. A duplicate insert throws, and the catch returns `{accepted: true, duplicate: true}`. Replay protection becomes structurally impossible to forget, versus an `if` somebody eventually deletes. Goes into `PHASE-7`.
**Append-only inventory journal.** Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. This is the direct answer to the v3.1 plan's "we cannot explain your numbers" complaint — it makes every quantity reconstructible after the fact.
None of these are hard. All three are cheap to specify and expensive to retrofit.
### 5.2 Session and credential hygiene
Their model, which we adopt as the contract target: server-stored sessions, random 32 bytes, **stored as SHA-256 hash only**, HttpOnly + Secure + SameSite, revocable, one distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`), Argon2id `memoryCost 65536 / timeCost 3 / parallelism 1`, mandatory TOTP with a signed 10-minute setup token, and password change revoking every live session in the same transaction.
Plus the twelve-line CSRF defence we do not have: a global `onRequest` hook rejecting any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist.
Our side of this is subtractive: stop sending provider credentials from the browser, stop shipping a partner ID literal in the bundle.
### 5.3 Tenant and preview safety
Host → verified domain row → tenant, 30-second cache with explicit invalidation, `404` on unknown host with no fallback tenant. We have bootstrap-driven runtime config and no equivalent guarantee written down.
Their preview mechanism is the piece worth copying outright: an HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, delivered as a `storefront_preview` cookie, plus a global hook that returns `404 Preview mode is read-only` for any non-GET while that cookie is present. We have preview UI and no preview safety at all.
### 5.4 Publish, revisions, clone
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction, rollback creates a *new* revision rather than rewriting history. Clone copies design and catalog assignments, **forces inventory to zero**, and never copies domains, customers, orders, or secrets; its category walk is topological with explicit cycle detection.
### 5.5 Product ideas worth taking
- **Order-manager as a fully separate contour** — separate URL, shell, cookie, login, scoped to one marketplace, with no visibility into catalog, design, domains, or payment settings. Removes an entire permissions surface rather than guarding it.
- **Digital goods in one table** — `FulfillmentMode: MANUAL | CODE_POOL`, a `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, codes revealed only once the order is `PAID`. We have no digital-goods story; this is a complete one.
- **`DOMAIN_PENDING` as a real marketplace state**, not an error condition.
- **CSV marketplace import with `dryRun` default true** — bulk tenant creation as a first-class operation.
- **Their §22 acceptance list** as a ready-made e2e suite. Two of the fifteen are worth writing immediately: concurrent purchase of the last unit, and a replayed webhook.
### 5.6 Social identity — VK ID and Yandex ID
The archive has **zero** VK/Yandex/OAuth code; there is nothing to copy. What we take is the *session-issuing shape* of their Telegram flow and terminate VK/Yandex into it.
Design decisions, all of which belong in `@marketplaces/auth`:
1. **Provider-agnostic surface.** `SocialIdentityGateway` with a `SocialProvider` union, replacing today's VK-specific `VkIdGateway`. One controller pattern backend-side, one strategy object per provider.
2. **Backend-owned PKCE.** Our current interface passes `codeVerifier` from the client, which forces the browser to generate and hold the verifier. We are a confidential client. The backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, and the browser only ever gets redirected. `completeCallback()` disappears from the frontend entirely.
3. **VK ID gotcha:** the callback returns `device_id` next to `code`, and the token exchange fails without it. This is the single most common VK ID integration bug and it must be in the contract text.
4. **Multi-tenant `redirect_uri` is a one-way door.** Both providers validate `redirect_uri` against an exact registered list; we cannot register one per tenant domain. Resolution: a single central identity host as the only registered callback, tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for its session cookie. **This must be decided before any code is written.**
5. **Identity conflict is not an upsert.** `@@unique([provider, providerUserId])` so the database refuses a silent rebind; conflicts route to controlled resolution.
Build order: provider-agnostic surface → VK ID → Yandex ID (a second strategy, roughly a day) → migrate Telegram onto `ExternalIdentity` → linking UI → email/phone OTP demoted to recovery.
### 5.7 Operations
Adopt: WAL archiving plus a *scheduled, proven* restore drill; a data network that is `internal: true` so "the database is not reachable from the internet" is structural rather than a firewall promise; host hardening we lack (fail2ban, sshd drop-in, sysctl).
Already better on our side, keep as-is: our `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Their `provision-domain.sh` hardcodes the server IP — do not copy that shape.
### 5.8 Process
Their `DEVELOPER_HANDOFF.md` §7 is nine falsifiable invariants and is a better acceptance gate than anything currently in our delivery plan. Their PR policy (one functional area per PR; mandatory security impact and rollback plan; never touch payment/inventory/order state machines inside a redesign PR) and their release discipline ("a local build or the existence of a UI does not mean production readiness") are both worth adopting verbatim.
---
## 6. Sequencing
Four waves. Each wave is independently shippable; nothing in a later wave blocks an earlier one.
**Wave 1 — Defect fixes with a security benefit (this sprint, Lane A).**
The three live bugs: geo over HTTP, bank URL in an iframe, provider credentials and partner ID in the bundle. Plus `Idempotency-Key` on payment creation. All frontend, all provable, all things their audit will otherwise keep pointing at.
**Wave 2 — Contract hardening (Lane B, parallel with Wave 1).**
Write the correctness primitives, session model, tenant/preview rules, revision semantics, and inventory journal into `docs/backend/`. Costs no engineering capacity from the frontend team and immediately raises the bar the backend is built to.
**Wave 3 — Proof (Lane A).**
The two acceptance e2e tests, bundle budget as a blocking CI check, and the deployable split that gets us under budget.
**Wave 4 — Identity (Lane C).**
Blocked on the central-identity-host decision. Provider-agnostic surface, VK ID, Yandex ID, Telegram migration, linking UI.
Ops (Lane D) and process (Lane E) run continuously alongside.
---
## 7. Explicitly out of scope
- Merging the two codebases, in either direction.
- Reimplementing their backend here.
- Adopting their section schema, their template list, or their backoffice.
- Any dependency downgrade.
- Removing our boundary checker, cycle check, or coverage floor to match their looser governance.
---
## 8. Risks
| Risk | Mitigation |
|---|---|
| Contract lines in `docs/backend/` are written and never implemented | Pair each with an acceptance scenario in the handoff doc so it is a delivery gate, not a wish |
| The central-identity-host decision slips and blocks all of Wave 4 | It is the first item in the TODO; escalate on day one |
| Removing the client-side payment credential path breaks checkout before the server side exists | Confirm the server-priced checkout session path (already in `api.service.ts`) covers every live flow before deleting the legacy header path |
| The deployable split is larger than estimated | Wave 3 item, not a blocker for Waves 12; can ship the bundle-budget CI check first and let it fail loudly |
| Harvest is read as "they were right about everything" | The analysis records where they are behind us — tests, e2e, framework currency, frontend depth — and the TODO carries no item that regresses those |
---
## 9. Done means
- Every Wave 1 item has a test or an observable check proving it.
- Every Lane B item exists as normative text in `docs/backend/` with an acceptance scenario attached.
- The two §22 acceptance tests run in CI.
- Bundle budget is a blocking check and the storefront is under it.
- VK ID and Yandex ID both log a customer in through `@marketplaces/auth`, with the client never holding a secret, a token, or a code verifier.
- No item in this harvest lowered our Angular version, our test count, or our architecture governance.

View File

@@ -28,6 +28,9 @@ BASE_URL=https://staging.example.com npm run e2e
|---|---| |---|---|
| `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10F16 in the frontend backlog), specifically so that rewrite has a net under it. | | `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10F16 in the frontend backlog), specifically so that rewrite has a net under it. |
| `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. | | `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. |
| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). |
| `checkout-request-shape.spec.ts` | ⚠️ **Currently failing, known issue, not resolved (2026-08-21).** The checkout request-shape assertions are correct on paper; the customer-session fake this test relies on doesn't work right now for a reason not yet found — see the `fakeCustomerSession` comment in the file. Do not trust a green *or* red run of this specific test as a verdict on checkout correctness until it's root-caused. |
| `checkout-idempotent-click.spec.ts` | ⚠️ Same known issue as above (Track Q F62) - fails the same way, for the same unresolved reason. |
## Adding a test ## Adding a test

View File

@@ -20,7 +20,10 @@ test('double-clicking checkout sends exactly one checkout-session request', asyn
window.localStorage.setItem('marketplace_cart', JSON.stringify([item])); window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
}, FAKE_ITEM); }, FAKE_ITEM);
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', domain: 'localhost', path: '/' }]); // KNOWN ISSUE, NOT RESOLVED (2026-08-21) - see checkout-request-shape.spec.ts's
// fakeCustomerSession comment. This test currently fails the same way:
// the session check never fires despite the cookie being present.
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
await page.route('**/users/sessions/**', route => await page.route('**/users/sessions/**', route =>
route.fulfill({ route.fulfill({
status: 200, contentType: 'application/json', status: 200, contentType: 'application/json',

View File

@@ -62,10 +62,15 @@ test.describe('checkout request shape', () => {
const body = await intentRequest; const body = await intentRequest;
// Payment creation now goes through @marketplaces/payment
// (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments),
// not api.service.ts's superseded createPaymentIntent - see
// cart.component.ts's createPaymentIntent() comment.
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture'); expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
expect(body).not.toHaveProperty('amount'); expect(body).not.toHaveProperty('amount');
expect(typeof body.merchantReference).toBe('string'); const metadata = body.metadata as Record<string, string> | undefined;
expect(body.merchantReference.length).toBeGreaterThan(0); expect(typeof metadata?.merchantReference).toBe('string');
expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0);
}); });
}); });
@@ -76,12 +81,24 @@ async function seedCart(page: Page): Promise<void> {
} }
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> { async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
// KNOWN ISSUE, NOT RESOLVED (2026-08-21): this test currently fails.
// Traced with page.on('request'): the customer-session check
// (AuthService.checkSession -> getStoredWebSessionID) never fires at all
// once Angular bootstraps on this page, even though the cookie is
// confirmed present via context.cookies() and via document.cookie read
// from a plain (non-Angular) page on the same origin immediately before.
// Switching { domain, path } to { url } here did not fix it - kept anyway
// since it is the more correct form regardless. Something in the app's
// own bootstrap/DI path is not seeing a cookie that unambiguously exists
// in the browser; root cause not yet found. Do not trust a green run of
// this specific test until this is root-caused - the checkout REQUEST
// SHAPE assertions this test makes are still correct on paper, just
// currently unverifiable through this harness.
await context.addCookies([ await context.addCookies([
{ {
name: 'webSessionID', name: 'webSessionID',
value: FAKE_SESSION_ID, value: FAKE_SESSION_ID,
domain: 'localhost', url: 'http://localhost:4200',
path: '/',
}, },
]); ]);
@@ -149,16 +166,19 @@ function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>>
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> { function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
return new Promise(resolve => { return new Promise(resolve => {
page.route('**/api/v2/storefront/payments/intents', (route: Route) => { // @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api
// stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'.
page.route('**/api/v1/payments', (route: Route) => {
const body = route.request().postDataJSON(); const body = route.request().postDataJSON();
resolve(body); resolve(body);
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
qrId: 'qr_e2e_fixture', paymentId: 'qr_e2e_fixture',
nspkurl: 'https://example.com/pay/e2e', method: 'qr',
qrTTL: 5, status: 'pending',
action: { type: 'qr', url: 'https://example.com/pay/e2e' },
}), }),
}); });
}); });

297
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@angular/router": "22.0.8", "@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8", "@angular/service-worker": "22.0.8",
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth", "@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
"@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"zone.js": "~0.16.0" "zone.js": "~0.16.0"
@@ -1808,16 +1809,35 @@
] ]
}, },
"node_modules/@marketplaces/auth": { "node_modules/@marketplaces/auth": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#93f99cc7b19f88112337e7a6544c1c09d9904744", "resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#e8052159e97a167f4c3d8bb056013b730feb8aee",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": {
"qrcode": "^1.5.4",
"tslib": "^2.8.0"
},
"peerDependencies": { "peerDependencies": {
"@angular/common": ">=22.0.0", "@angular/common": ">=22.0.0",
"@angular/core": ">=22.0.0", "@angular/core": ">=22.0.0",
"@angular/forms": ">=22.0.0",
"@angular/router": ">=22.0.0", "@angular/router": ">=22.0.0",
"rxjs": ">=7.8.0" "rxjs": ">=7.8.0"
} }
}, },
"node_modules/@marketplaces/payment": {
"version": "0.2.0",
"resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#61b000f43f7d4630f6dcb6ac534cc1f2d3aa6f72",
"license": "UNLICENSED",
"dependencies": {
"qrcode": "^1.5.4",
"tslib": "^2.8.0"
},
"peerDependencies": {
"@angular/common": ">=22.0.0",
"@angular/core": ">=22.0.0",
"rxjs": ">=7.8.0"
}
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"dev": true, "dev": true,
@@ -3853,6 +3873,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001760", "version": "1.0.30001760",
"dev": true, "dev": true,
@@ -4004,7 +4033,6 @@
}, },
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -4015,7 +4043,6 @@
}, },
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-map": { "node_modules/concat-map": {
@@ -4215,6 +4242,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"dev": true, "dev": true,
@@ -4246,6 +4282,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serialize": { "node_modules/dom-serialize": {
"version": "2.2.1", "version": "2.2.1",
"dev": true, "dev": true,
@@ -4740,6 +4782,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.3.3",
"dev": true, "dev": true,
@@ -4842,7 +4897,6 @@
}, },
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -5915,6 +5969,18 @@
"@lmdb/lmdb-win32-x64": "3.5.4" "@lmdb/lmdb-win32-x64": "3.5.4"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"dev": true, "dev": true,
@@ -6629,6 +6695,33 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-map": { "node_modules/p-map": {
"version": "7.0.6", "version": "7.0.6",
"dev": true, "dev": true,
@@ -6640,6 +6733,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pacote": { "node_modules/pacote": {
"version": "21.5.1", "version": "21.5.1",
"dev": true, "dev": true,
@@ -6733,6 +6835,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
@@ -6868,6 +6979,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"dev": true, "dev": true,
@@ -6958,6 +7078,154 @@
"node": ">=0.9" "node": ">=0.9"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.1",
"dev": true, "dev": true,
@@ -7013,7 +7281,6 @@
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -7027,6 +7294,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": { "node_modules/requires-port": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
@@ -7313,6 +7586,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"dev": true, "dev": true,
@@ -8094,6 +8373,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "10.0.0", "version": "10.0.0",
"dev": true, "dev": true,

View File

@@ -14,6 +14,7 @@
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs", "arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json", "arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles", "arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles",
"scan:bundle": "bash scripts/ci/scan-bundle.sh",
"barry": "barry-cache", "barry": "barry-cache",
"barry:validate": "barry-cache validate", "barry:validate": "barry-cache validate",
"barry:resume": "barry-cache resume", "barry:resume": "barry-cache resume",
@@ -35,6 +36,7 @@
"@angular/router": "22.0.8", "@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8", "@angular/service-worker": "22.0.8",
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth", "@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
"@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"zone.js": "~0.16.0" "zone.js": "~0.16.0"

56
scripts/ci/scan-bundle.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Fails the build if a production bundle contains anything that should only
# ever exist server-side.
#
# Why this exists: the storefront used to send provider payment credentials
# from the browser - an `authorization-key` header, a `userid-value` header,
# and a hardcoded partner ID literal compiled into the bundle. That code is
# gone (FH-1.3), and this check is what stops it coming back. A credential in
# a JS bundle is not a leak you can revoke quietly; it is published.
#
# Usage:
# npm run build && scripts/ci/scan-bundle.sh [dist-dir]
set -euo pipefail
DIST="${1:-dist}"
if [[ ! -d "$DIST" ]]; then
echo "scan-bundle: '$DIST' does not exist - build first" >&2
exit 2
fi
# Each entry is "label|extended-regex". Keep patterns specific: a pattern that
# fires on ordinary code trains people to ignore this check.
PATTERNS=(
"provider auth header|authorization-key"
"provider user header|userid-value"
"hardcoded partner id|web-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
"oauth client secret|client_secret[\"']?[[:space:]]*[:=]"
"private key block|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY"
"aws access key|AKIA[0-9A-Z]{16}"
"telegram bot token|[0-9]{8,10}:AA[0-9A-Za-z_-]{33}"
)
failed=0
for entry in "${PATTERNS[@]}"; do
label="${entry%%|*}"
pattern="${entry#*|}"
if matches="$(grep -rIlE "$pattern" "$DIST" 2>/dev/null)"; then
if [[ -n "$matches" ]]; then
echo "FAIL: $label found in the built bundle" >&2
echo "$matches" | sed 's/^/ /' >&2
failed=1
fi
fi
done
if [[ $failed -ne 0 ]]; then
echo >&2
echo "A credential reached the browser bundle. Move it behind the API." >&2
exit 1
fi
echo "scan-bundle: clean ($DIST)"

View File

@@ -9,10 +9,12 @@ import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth'; import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
import { provideMarketplacesPayment } from '@marketplaces/payment';
import { provideServiceWorker } from '@angular/service-worker'; import { provideServiceWorker } from '@angular/service-worker';
import { MediaRepository } from './core/media/media-repository'; import { MediaRepository } from './core/media/media-repository';
import { MockMediaRepository } from './core/media/mock-media-repository.service'; import { MockMediaRepository } from './core/media/mock-media-repository.service';
import { ApiConfigService } from './core/config/api-config.service'; import { ApiConfigService } from './core/config/api-config.service';
import { TenantResolverService } from './core/config/tenant-resolver.service';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
@@ -43,6 +45,30 @@ export const appConfig: ApplicationConfig = {
// Real fix belongs in vitanovaPackages: publish with ng-packagr. // Real fix belongs in vitanovaPackages: publish with ng-packagr.
{ provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() }, { provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() },
{ provide: MediaRepository, useClass: MockMediaRepository }, { provide: MediaRepository, useClass: MockMediaRepository },
// apiUrl: environment.qrApiUrl ('https://qr.vitanova.network/api') is the
// same "central payment service" the legacy /qr and
// /card/{partnerId}/{orderId} endpoints already used (api.service.ts) -
// one service shared across every tenant, unlike the per-tenant
// AUTH_API_URL above. Stripped the trailing /api here: the package's own
// default paymentsPath is '/api/v1/payments', so passing qrApiUrl
// unchanged would double it to .../api/api/v1/payments. Confirmed by
// reading the package's baseUrl() directly (apiUrl + paymentsPath,
// simple concatenation, no de-dup) - not yet confirmed against a live
// backend, since qrApiUrl's own /api suffix was never meant for this
// package. Revisit once a real payment request has actually been made.
//
// marketplaceDomain: a plain closure, not TenantResolverService.
// provideMarketplacesPayment runs outside the injector (it returns
// EnvironmentProviders, called before DI exists), so inject(DOCUMENT)
// isn't available here. The package evaluates this function lazily
// inside PaymentMarketplaceContext, which IS a real injection context -
// this closure just can't be one itself. Mirrors
// TenantResolverService.getHostname() intentionally; if that method's
// logic changes, this needs to change with it.
provideMarketplacesPayment({
apiUrl: environment.qrApiUrl.replace(/\/api\/?$/, ''),
marketplaceDomain: () => window.location.hostname.toLowerCase(),
}),
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000' registrationStrategy: 'registerWhenStable:30000'

View File

@@ -24,6 +24,7 @@ import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-d
import { DialogComponent } from '../../shared/ui/dialog/dialog.component'; import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe'; import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
import { CurrencyRatesService } from '../../services/currency-rates.service'; import { CurrencyRatesService } from '../../services/currency-rates.service';
import { MARKETPLACES_PAYMENT_GATEWAY, PaymentAttempt, PaymentMethod as PackagePaymentMethod } from '@marketplaces/payment';
type PaymentMethod = 'qr' | 'card'; type PaymentMethod = 'qr' | 'card';
@@ -82,6 +83,18 @@ export class CartComponent implements OnDestroy {
private currencyRates = inject(CurrencyRatesService); private currencyRates = inject(CurrencyRatesService);
private readonly analytics = inject(AnalyticsService); private readonly analytics = inject(AnalyticsService);
/**
* Payment creation and status polling now go through @marketplaces/payment
* (POST/GET {qrApiUrl}/api/v1/payments) instead of api.service.ts's
* createPaymentIntent/checkCartPaymentStatus - that endpoint pair is now
* superseded, see the comment on createPaymentIntent() below. Only the I/O
* layer changed; the surrounding popup state machine (paymentStatus,
* checkoutInFlight, the bank-iframe UX, timeout/success handling) is
* untouched and stays hand-rolled - <mp-payment>'s own UI is a different,
* simpler paradigm (window.open for redirects, no iframe) that would be a
* separate, much larger change to adopt wholesale.
*/
private readonly paymentGateway = inject(MARKETPLACES_PAYMENT_GATEWAY);
constructor( constructor(
private cartService: CartService, private cartService: CartService,
@@ -317,39 +330,26 @@ export class CartComponent implements OnDestroy {
}); });
} }
/**
* Superseded api.service.ts's createPaymentIntent (POST
* /api/v2/storefront/payments/intents, our own inferred contract) with
* @marketplaces/payment's real, published one. That method, createPayment
* (legacy /qr), createCartPayment (legacy /cart), checkCartPaymentStatus,
* checkCartCardPaymentStatus, checkPaymentStatus, and the
* QrCreateResponse-based resolvePaymentQrId/resolvePaymentQrUrl/
* resolvePaymentLink/resolveBankPaymentUrl helpers - all now deleted from
* ApiService, confirmed dead first (zero remaining callers) before removal.
*/
private createPaymentIntent( private createPaymentIntent(
session: import('../../services/api.service').CheckoutSessionResponse, session: import('../../services/api.service').CheckoutSessionResponse,
paymentMethod: PaymentMethod, paymentMethod: PaymentMethod,
merchantReference: string, merchantReference: string,
): void { ): void {
this.apiService.createPaymentIntent({ this.paymentGateway.create(paymentMethod as PackagePaymentMethod, {
checkoutSessionId: session.checkoutSessionId, checkoutSessionId: session.checkoutSessionId,
paymentMethod, metadata: { merchantReference },
merchantReference,
}).subscribe({ }).subscribe({
next: (response) => { next: (attempt) => this.handlePaymentAttempt(attempt, paymentMethod),
const qrId = this.apiService.resolvePaymentQrId(response);
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
const paymentLink = this.apiService.resolvePaymentLink(response);
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
console.error('Payment intent response missing payment fields:', response);
this.setPaymentError();
return;
}
this.paymentId.set(qrId);
this.qrCodeUrl.set(qrUrl);
this.paymentUrl.set(paymentLink);
this.bankPaymentUrl.set(bankUrl);
this.paymentStatus.set('waiting');
this.startPolling(response.qrTTL);
if (paymentMethod === 'card') {
this.openBankPaymentPopup();
}
},
error: (err) => { error: (err) => {
console.error('Error creating payment intent:', err); console.error('Error creating payment intent:', err);
this.setPaymentError(); this.setPaymentError();
@@ -357,33 +357,59 @@ export class CartComponent implements OnDestroy {
}); });
} }
startPolling(qrTTL?: number): void { private handlePaymentAttempt(attempt: PaymentAttempt, paymentMethod: PaymentMethod): void {
if (!attempt.paymentId || (attempt.status !== 'created' && attempt.status !== 'pending' && !attempt.action)) {
console.error('Payment attempt missing required fields:', attempt);
this.setPaymentError();
return;
}
this.paymentId.set(attempt.paymentId);
if (attempt.action?.type === 'qr') {
// Same external QR-image rendering used everywhere else in this
// component (previously via ApiService.resolvePaymentQrUrl) - kept
// rather than switching to the package's own client-side qrcode
// generation, to avoid adding a second QR-rendering path for one call site.
this.qrCodeUrl.set(`https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(attempt.action.url)}`);
this.paymentUrl.set(attempt.action.url);
} else if (attempt.action?.type === 'redirect') {
this.bankPaymentUrl.set(attempt.action.url);
}
this.paymentStatus.set('waiting');
// The package's PaymentAttempt carries no TTL/expiry field, unlike the
// legacy provider's qrTTL - polling duration falls back to
// PAYMENT_MIN_POLL_SECONDS alone. Revisit if the real backend adds one.
this.startPolling();
if (paymentMethod === 'card' && attempt.action?.type === 'redirect') {
this.openBankPaymentPopup();
}
}
startPolling(): void {
this.stopPolling(); this.stopPolling();
if (!this.paymentId()) { if (!this.paymentId()) {
this.setPaymentError(); this.setPaymentError();
return; return;
} }
const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60); const pollSeconds = PAYMENT_MIN_POLL_SECONDS;
this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000)); this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000));
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS) this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
.pipe( .pipe(
take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute take(this.maxChecks),
exhaustMap(() => { exhaustMap(() =>
const statusRequest = this.selectedPaymentMethod() === 'card' this.paymentGateway.status(this.paymentId(), this.selectedPaymentMethod() as PackagePaymentMethod).pipe(
? this.apiService.checkCartCardPaymentStatus(this.paymentId())
: this.apiService.checkCartPaymentStatus(this.paymentId());
return statusRequest.pipe(
timeout(8000), timeout(8000),
catchError((err) => { catchError((err) => {
console.error('Error checking payment status:', err); console.error('Error checking payment status:', err);
this.setPaymentError(); this.setPaymentError();
return EMPTY; return EMPTY;
}) })
); )
}) )
) )
.subscribe({ .subscribe({
next: (response) => { next: (response) => {
@@ -391,10 +417,14 @@ export class CartComponent implements OnDestroy {
return; return;
} }
const paymentStatus = response.status?.toUpperCase() || ''; // Package's PaymentStatus is a fixed union
const paymentCode = response.code?.toUpperCase() || ''; // ('created'|'pending'|'authorized'|'paid'|'failed'|'cancelled'|'expired'),
// not a free-form string+code pair like the legacy provider - no
// .toUpperCase() normalization needed, and no 'REJECTED'/'APPROVED'
// equivalents exist (those were legacy-provider-specific spellings).
const paymentStatus = response.status;
if (paymentStatus === 'FAILED' || paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED' || paymentStatus === 'REJECTED') { if (paymentStatus === 'failed' || paymentStatus === 'expired' || paymentStatus === 'cancelled') {
this.paymentStatus.set('timeout'); this.paymentStatus.set('timeout');
this.closeBankPaymentPopup(); this.closeBankPaymentPopup();
this.stopPolling(); this.stopPolling();
@@ -405,8 +435,9 @@ export class CartComponent implements OnDestroy {
return; return;
} }
// Check if payment is successful // 'authorized' counts as success too (PaymentResult's own status
if (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') { // union) - a card payment can settle as authorized before capture.
if (paymentStatus === 'paid' || paymentStatus === 'authorized') {
this.paymentStatus.set('success'); this.paymentStatus.set('success');
this.closeBankPaymentPopup(); this.closeBankPaymentPopup();
this.stopPolling(); this.stopPolling();

View File

@@ -1,58 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, timer } from 'rxjs'; import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators'; import { map, retry } from 'rxjs/operators';
import { CategoryApiModel, DeliveryOption, Item, Subcategory } from '../models'; import { CategoryApiModel, DeliveryOption, Item, Subcategory } from '../models';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils'; import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
import { ApiConfigService } from '../core/config/api-config.service'; import { ApiConfigService } from '../core/config/api-config.service';
export interface QrCreateRequest {
qrtype: 'QRDynamic';
amount: number;
currency: 'RUB';
partnerqrID?: string;
qrDescription?: string;
Userid?: string;
Reference?: string;
RedirectUrl?: string;
}
export interface QrCreateResponse {
qrId?: string;
qrID?: string;
nspkID?: string;
nspkId?: string;
nspkurl?: string;
orderID?: string;
url?: string;
bankUrl?: string;
status?: string;
qrStatus?: string;
qrExpirationDate?: string;
qrTTL?: number;
payload?: string;
Payload?: string;
qrUrl?: string;
partnerqrID?: string | number;
partnerID?: string | number;
partnerId?: string | number;
PartnerID?: string | number;
}
export interface CartPaymentRequest {
amount: number;
currency: string;
siteuserID: string;
siteorderID: string;
redirectUrl: string;
telegramUsername: string;
paymentMethod: 'qr' | 'card';
qrDescription?: string;
customerID?: string;
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
}
/** /**
* Server-authoritative checkout. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2. * Server-authoritative checkout. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.
* No `amount` or `price` field anywhere in this pair - the backend prices * No `amount` or `price` field anywhere in this pair - the backend prices
@@ -88,18 +41,6 @@ export interface CheckoutSessionResponse {
expiresAt: string; expiresAt: string;
} }
/**
* References checkoutSessionId only - the amount charged is read
* server-side from the session, never re-sent by the client (contract §5.2).
* merchantReference is PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
* field: our own correlation id, echoed back on every related event.
*/
export interface PaymentIntentRequest {
checkoutSessionId: string;
paymentMethod: 'qr' | 'card';
merchantReference: string;
}
export interface CreateOrderRequest { export interface CreateOrderRequest {
/** /**
* No `price` field: the backend must price each line item from its own * No `price` field: the backend must price each line item from its own
@@ -120,28 +61,10 @@ export interface CreateOrderResponse {
currency: string; currency: string;
} }
export interface QrDynamicStatusResponse {
additionalInfo: string;
paymentPurpose: string;
amount: number;
code: string;
createDate: string;
currency: string;
order: string;
status: string;
qrId: string;
transactionDate: string;
transactionId: number;
qrExpirationDate: string;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ApiService { export class ApiService {
private readonly qrBaseUrl = (environment as any).qrApiUrl as string;
private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750';
private readonly retryConfig = { private readonly retryConfig = {
count: 2, count: 2,
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500) delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
@@ -669,21 +592,6 @@ export class ApiService {
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body); return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
} }
createPayment(payload: QrCreateRequest, headers?: { authorizationKey?: string; userIdValue?: string }): Observable<QrCreateResponse> {
let httpHeaders = new HttpHeaders();
if (headers?.authorizationKey) {
httpHeaders = httpHeaders.set('authorization-key', headers.authorizationKey);
}
if (headers?.userIdValue) {
httpHeaders = httpHeaders.set('userid-value', headers.userIdValue);
}
return this.http.post<QrCreateResponse>(`${this.qrBaseUrl}/qr`, payload, { headers: httpHeaders });
}
createCartPayment(payload: CartPaymentRequest): Observable<QrCreateResponse> {
return this.http.post<QrCreateResponse>(`${this.baseUrl}/cart`, payload);
}
/** /**
* Creates a server-priced checkout session. Contract §5.2 - the frontend * Creates a server-priced checkout session. Contract §5.2 - the frontend
* sends offer ids and quantities only; the response carries the total that * sends offer ids and quantities only; the response carries the total that
@@ -694,16 +602,6 @@ export class ApiService {
return this.http.post<CheckoutSessionResponse>('/api/v2/storefront/checkout', payload); return this.http.post<CheckoutSessionResponse>('/api/v2/storefront/checkout', payload);
} }
/**
* Creates a payment intent against an existing checkout session. Same
* response shape as createCartPayment (QrCreateResponse) - this replaces
* how the amount is determined, not the QR/card provider integration
* itself, which Phase 1 does not redesign.
*/
createPaymentIntent(payload: PaymentIntentRequest): Observable<QrCreateResponse> {
return this.http.post<QrCreateResponse>('/api/v2/storefront/payments/intents', payload);
}
/** /**
* Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget * Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget
* from the caller's perspective - a failure here must never block the existing * from the caller's perspective - a failure here must never block the existing
@@ -713,45 +611,6 @@ export class ApiService {
return this.http.post<CreateOrderResponse>(`${this.baseUrl}/orders`, payload); return this.http.post<CreateOrderResponse>(`${this.baseUrl}/orders`, payload);
} }
checkCartPaymentStatus(qrId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/qr/dynamic/${this.cartPaymentPartnerId}/${encodeURIComponent(qrId)}`
);
}
checkCartCardPaymentStatus(orderId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/card/${this.cartPaymentPartnerId}/${encodeURIComponent(orderId)}`
);
}
checkPaymentStatus(partnerQrId: string, qrId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/qr/dynamic/${encodeURIComponent(partnerQrId)}/${encodeURIComponent(qrId)}`
);
}
resolvePaymentQrId(response: QrCreateResponse): string {
return response.qrId ?? response.qrID ?? response.nspkID ?? response.nspkId ?? response.orderID ?? '';
}
resolvePaymentLink(response: QrCreateResponse): string {
return response.nspkurl ?? response.Payload ?? response.payload ?? response.qrUrl ?? '';
}
resolveBankPaymentUrl(response: QrCreateResponse): string {
return response.bankUrl ?? response.url ?? '';
}
resolvePaymentQrUrl(response: QrCreateResponse): string {
const paymentLink = this.resolvePaymentLink(response);
if (paymentLink) {
return `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(paymentLink)}`;
}
return response.qrUrl ?? '';
}
submitPurchaseEmail(emailData: { submitPurchaseEmail(emailData: {
email: string; email: string;
phone?: string; phone?: string;

View File

@@ -0,0 +1,89 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ApiConfigService } from '../core/config/api-config.service';
import { LocalStorageService } from '../core/storage/local-storage.service';
import { LocationService } from './location.service';
/**
* FH-1.1. detectLocation() used to call http://ip-api.com directly. On an
* HTTPS storefront the browser blocks mixed active content, so the request
* never completed and region auto-detect silently did nothing in production
* - and the attempt still exposed the visitor's IP to a third party.
*
* These tests pin both halves of the fix: geo resolution goes to our own
* tenant API, and nothing in this service reaches a foreign origin.
*/
describe('LocationService', () => {
const baseUrl = 'https://api.gorbushka.market';
let service: LocationService;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
LocationService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ApiConfigService, useValue: { getBaseUrl: () => baseUrl } },
{
provide: LocalStorageService,
useValue: { getJSON: () => null, setJSON: () => {}, removeItem: () => {} },
},
],
});
service = TestBed.inject(LocationService);
httpTesting = TestBed.inject(HttpTestingController);
// The constructor loads regions; flush it so each test starts clean.
httpTesting.expectOne(`${baseUrl}/regions`).flush([]);
});
afterEach(() => httpTesting.verify());
it('resolves geo through the tenant API, not a third-party host', () => {
service.detectLocation();
const request = httpTesting.expectOne(`${baseUrl}/geo/resolve`);
expect(request.request.method).toBe('GET');
request.flush({ city: 'Москва', country: 'Россия', countryCode: 'RU' });
});
it('issues no request to a foreign or plaintext origin', () => {
service.detectLocation();
for (const request of httpTesting.match(() => true)) {
expect(request.request.url.startsWith(baseUrl))
.withContext(`unexpected off-origin request: ${request.request.url}`)
.toBe(true);
expect(request.request.url.startsWith('http://'))
.withContext(`plaintext request: ${request.request.url}`)
.toBe(false);
request.flush({});
}
});
it('degrades to the manual picker when geo resolution fails', () => {
service.detectLocation();
httpTesting
.expectOne(`${baseUrl}/geo/resolve`)
.flush(null, { status: 503, statusText: 'Service Unavailable' });
expect(service.region()).toBeNull();
expect(service.detecting()).toBe(false);
expect(service.autoDetected()).toBe(true);
});
it('does not re-request geo once detection has been attempted', () => {
service.detectLocation();
httpTesting.expectOne(`${baseUrl}/geo/resolve`).flush({
city: 'Ереван',
country: 'Армения',
countryCode: 'AM',
});
service.detectLocation();
httpTesting.expectNone(`${baseUrl}/geo/resolve`);
});
});

View File

@@ -71,8 +71,13 @@ export class LocationService {
if (this.detectedSignal()) return; // already tried if (this.detectedSignal()) return; // already tried
this.loadingSignal.set(true); this.loadingSignal.set(true);
// Using free ip-api.com — no key required, 45 req/min // Was a direct plaintext call to ip-api.com. Two problems, one of them
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon') // fatal: browsers block mixed active content, so on an HTTPS storefront
// this request never completed and auto-detect only ever took the error
// branch below. It also handed every visitor's IP to a third party from
// the page itself. The client IP is the server's to read - same tenant
// API base as /regions, same-origin, nothing leaves our infrastructure.
this.http.get<GeoIpResponse>(`${this.apiConfig.getBaseUrl()}/geo/resolve`)
.subscribe({ .subscribe({
next: (geo) => { next: (geo) => {
this.detectedSignal.set(true); this.detectedSignal.set(true);